diff --git a/.github/workflows/sub_gradle_test_and_build.yml b/.github/workflows/sub_gradle_test_and_build.yml index 9a9c8244a6..f847bb0bcc 100644 --- a/.github/workflows/sub_gradle_test_and_build.yml +++ b/.github/workflows/sub_gradle_test_and_build.yml @@ -42,7 +42,7 @@ jobs: # Prepare Stellar Validation Tests - name: Pull Stellar Validation Tests Docker Image - run: docker pull stellar/anchor-tests:v0.6.5 & + run: docker pull stellar/anchor-tests:v0.6.9 & # Set up JDK 11 - name: Set up JDK 11 @@ -109,7 +109,7 @@ jobs: - name: Run Stellar validation tool run: | - docker run --network host -v ${GITHUB_WORKSPACE}/platform/src/test/resources://config stellar/anchor-tests:v0.6.5 --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.9 --home-domain http://host.docker.internal:8080 --seps 1 6 10 12 24 31 38 --sep-config //config/stellar-anchor-tests-sep-config.json --verbose - name: Upload Artifacts if: always() 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/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/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/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/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/PlatformTransactionData.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java index 448090bb6a..831161534b 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; @@ -29,6 +30,8 @@ public class PlatformTransactionData { Kind kind; SepTransactionStatus status; + String type; + @SerializedName("amount_expected") Amount amountExpected; @@ -89,7 +92,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), @@ -127,8 +146,13 @@ public enum Kind { RECEIVE("receive"), @SerializedName("deposit") DEPOSIT("deposit"), + @SerializedName("deposit-exchange") + DEPOSIT_EXCHANGE("deposit-exchange"), @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/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/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestCustomerInfoUpdateRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestCustomerInfoUpdateRequest.java index e8e2b6af39..a261b6bbe5 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestCustomerInfoUpdateRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestCustomerInfoUpdateRequest.java @@ -1,5 +1,7 @@ package org.stellar.anchor.api.rpc.method; +import com.google.gson.annotations.SerializedName; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -9,4 +11,11 @@ @SuperBuilder @AllArgsConstructor @EqualsAndHashCode(callSuper = false) -public class RequestCustomerInfoUpdateRequest extends RpcMethodParamsRequest {} +public class RequestCustomerInfoUpdateRequest extends RpcMethodParamsRequest { + + @SerializedName("required_customer_info_message") + private String requiredCustomerInfoMessage; + + @SerializedName("required_customer_info_updates") + private List requiredCustomerInfoUpdates; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOffchainFundsRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOffchainFundsRequest.java index 12813a8d64..9417086799 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOffchainFundsRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOffchainFundsRequest.java @@ -1,10 +1,12 @@ package org.stellar.anchor.api.rpc.method; import com.google.gson.annotations.SerializedName; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; +import org.stellar.anchor.api.shared.InstructionField; @Data @SuperBuilder @@ -23,4 +25,7 @@ public class RequestOffchainFundsRequest extends RpcMethodParamsRequest { @SerializedName("amount_expected") private AmountRequest amountExpected; + + @SerializedName("instructions") + Map instructions; } 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..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,30 @@ public class AssetInfo { String code; String issuer; - public String getAssetName() { - if (issuer != null) { - return schema + ":" + 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); + } + + /** + * 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) { + return assetCode + ":" + assetIssuer; + } else { + return assetCode; } - return schema + ":" + code; } @SerializedName("distribution_account") 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/Sep12PutCustomerRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12PutCustomerRequest.java index 40c0f3610e..573f23751b 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12PutCustomerRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12PutCustomerRequest.java @@ -1,6 +1,7 @@ package org.stellar.anchor.api.sep.sep12; import com.google.gson.annotations.SerializedName; +import java.time.Instant; import lombok.Builder; import lombok.Data; @@ -108,10 +109,10 @@ public class Sep12PutCustomerRequest implements Sep12CustomerRequestBase { String idCountryCode; @SerializedName("id_issue_date") - String idIssueDate; + Instant idIssueDate; @SerializedName("id_expiration_date") - String idExpirationDate; + Instant idExpirationDate; @SerializedName("id_number") String idNumber; 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/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/InfoResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java index 730341f588..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. * @@ -100,7 +116,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/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 68% 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 887871cc17..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 @@ -1,13 +1,16 @@ 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 @Builder -public class Sep6Transaction { +public class Sep6TransactionResponse { String id; String kind; @@ -45,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") @@ -77,7 +85,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/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/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 new file mode 100644 index 0000000000..dc0f67b70f --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.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 StartDepositRequest { + /** 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. */ + 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. */ + 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/StartDepositResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositResponse.java new file mode 100644 index 0000000000..147a6f5b8d --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositResponse.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 StartDepositResponse { + /** + * 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/StartWithdrawExchangeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java new file mode 100644 index 0000000000..3888ead1b9 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java @@ -0,0 +1,56 @@ +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 account to withdraw from. */ + String account; + + /** 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/StartWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java new file mode 100644 index 0000000000..fe30c031da --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java @@ -0,0 +1,43 @@ +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 endpoint. + * + * @see GET + * /withdraw + */ +@Builder +@Data +public class StartWithdrawRequest { + /** The asset code of the asset to withdraw. */ + @SerializedName("asset_code") + @NonNull + String assetCode; + + /** Type of withdrawal. */ + String type; + + /** The account to withdraw from. */ + String account; + + /** The amount to withdraw. */ + 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/StartWithdrawResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawResponse.java new file mode 100644 index 0000000000..d73d481a07 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawResponse.java @@ -0,0 +1,30 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; + +/** + * The response to the GET /withdraw endpoint. + * + * @see GET + * /withdraw response + */ +@Builder +@Data +public class StartWithdrawResponse { + /** 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/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/api-schema/src/main/java/org/stellar/anchor/api/shared/StellarId.java b/api-schema/src/main/java/org/stellar/anchor/api/shared/StellarId.java index 4ccbedabc5..fa459ee12d 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/shared/StellarId.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/shared/StellarId.java @@ -10,6 +10,7 @@ public class StellarId { String id; String account; + String memo; public StellarId() {} } diff --git a/core/src/main/java/org/stellar/anchor/apiclient/PlatformApiClient.java b/core/src/main/java/org/stellar/anchor/apiclient/PlatformApiClient.java index fcd70776cc..5a7d810724 100644 --- a/core/src/main/java/org/stellar/anchor/apiclient/PlatformApiClient.java +++ b/core/src/main/java/org/stellar/anchor/apiclient/PlatformApiClient.java @@ -19,10 +19,7 @@ import org.springframework.data.domain.Sort; import org.stellar.anchor.api.exception.AnchorException; import org.stellar.anchor.api.exception.InvalidConfigException; -import org.stellar.anchor.api.platform.GetTransactionResponse; -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.platform.*; import org.stellar.anchor.api.rpc.RpcRequest; import org.stellar.anchor.api.rpc.method.AmountAssetRequest; import org.stellar.anchor.api.rpc.method.AmountRequest; 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/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/asset/DefaultAssetService.java b/core/src/main/java/org/stellar/anchor/asset/DefaultAssetService.java index 9c2786934d..3939509b84 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/client/ClientFinder.java b/core/src/main/java/org/stellar/anchor/client/ClientFinder.java new file mode 100644 index 0000000000..8ade86f931 --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/client/ClientFinder.java @@ -0,0 +1,67 @@ +package org.stellar.anchor.client; + +import javax.annotation.Nullable; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.stellar.anchor.api.exception.BadRequestException; +import org.stellar.anchor.auth.Sep10Jwt; +import org.stellar.anchor.config.ClientsConfig; +import org.stellar.anchor.config.ClientsConfig.ClientConfig; +import org.stellar.anchor.config.Sep10Config; + +/** Finds the client ID for a SEP-10 JWT. */ +@RequiredArgsConstructor +public class ClientFinder { + @NonNull private final Sep10Config sep10Config; + @NonNull private final ClientsConfig clientsConfig; + + /** + * Returns the client ID for a SEP-10 JWT. If the client attribution is not required, the client + * ID is returned if the client is found. If the client attribution is required, the client ID is + * returned if the client is found and the client domain and name are allowed. + * + * @param token the SEP-10 JWT + * @return the client ID + * @throws BadRequestException if the client is not found or the client domain or name is not + */ + @Nullable + public String getClientId(Sep10Jwt token) throws BadRequestException { + ClientsConfig.ClientConfig client = getClient(token); + + // If client attribution is not required, return the client name + if (!sep10Config.isClientAttributionRequired()) { + return client != null ? client.getName() : null; + } + + // Check if the client domain and name are allowed + if (client == null) { + throw new BadRequestException("Client not found"); + } + + if (token.getClientDomain() != null && !isDomainAllowed(client.getDomain())) { + throw new BadRequestException("Client domain not allowed"); + } + if (!isClientNameAllowed(client.getName())) { + throw new BadRequestException("Client name not allowed"); + } + + return client.getName(); + } + + @Nullable + private ClientConfig getClient(Sep10Jwt token) { + ClientConfig clientByDomain = clientsConfig.getClientConfigByDomain(token.getClientDomain()); + ClientConfig clientByAccount = clientsConfig.getClientConfigBySigningKey(token.getAccount()); + return clientByDomain != null ? clientByDomain : clientByAccount; + } + + private boolean isDomainAllowed(String domain) { + return sep10Config.getAllowedClientDomains().contains(domain) + || sep10Config.getAllowedClientDomains().isEmpty(); + } + + private boolean isClientNameAllowed(String name) { + return sep10Config.getAllowedClientNames().contains(name) + || sep10Config.getAllowedClientNames().isEmpty(); + } +} diff --git a/core/src/main/java/org/stellar/anchor/config/Sep6Config.java b/core/src/main/java/org/stellar/anchor/config/Sep6Config.java index 296ba04cad..1c3176f892 100644 --- a/core/src/main/java/org/stellar/anchor/config/Sep6Config.java +++ b/core/src/main/java/org/stellar/anchor/config/Sep6Config.java @@ -11,6 +11,8 @@ public interface Sep6Config { Features getFeatures(); + DepositInfoGeneratorType getDepositInfoGeneratorType(); + @Getter @Setter @AllArgsConstructor @@ -22,4 +24,10 @@ class Features { @SerializedName("claimable_balances") boolean claimableBalances; } + + enum DepositInfoGeneratorType { + SELF, + CUSTODY, + NONE + } } diff --git a/core/src/main/java/org/stellar/anchor/custody/CustodyService.java b/core/src/main/java/org/stellar/anchor/custody/CustodyService.java index 3ade2d82b2..2b3e5c1105 100644 --- a/core/src/main/java/org/stellar/anchor/custody/CustodyService.java +++ b/core/src/main/java/org/stellar/anchor/custody/CustodyService.java @@ -5,9 +5,18 @@ import org.stellar.anchor.api.rpc.method.DoStellarRefundRequest; import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep31.Sep31Transaction; +import org.stellar.anchor.sep6.Sep6Transaction; public interface CustodyService { + /** + * Create custody transaction for SEP6 transaction + * + * @param txn SEP6 transaction + * @throws AnchorException if error happens + */ + void createTransaction(Sep6Transaction txn) throws AnchorException; + /** * Create custody transaction for SEP24 transaction * 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/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 55b241167d..a800e22568 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -184,6 +184,7 @@ public Sep31PostTransactionResponse postTransaction( StellarId creatorStellarId = StellarId.builder() .account(Objects.requireNonNullElse(sep10Jwt.getMuxedAccount(), sep10Jwt.getAccount())) + .memo(sep10Jwt.getAccountMemo()) .build(); Amount fee = Context.get().getFee(); @@ -213,7 +214,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 ⬇️ @@ -324,7 +325,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); @@ -511,7 +512,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 ({})", @@ -549,7 +550,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/sep6/ExchangeAmountsCalculator.java b/core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java new file mode 100644 index 0000000000..70860dc0b6 --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java @@ -0,0 +1,75 @@ +package org.stellar.anchor.sep6; + +import static org.stellar.anchor.util.SepHelper.amountEquals; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +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.sep38.Sep38Quote; +import org.stellar.anchor.sep38.Sep38QuoteStore; + +/** Calculates the amounts for an exchange request. */ +@RequiredArgsConstructor +public class ExchangeAmountsCalculator { + @NonNull private final Sep38QuoteStore sep38QuoteStore; + + /** + * 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.getSep38AssetName().equals(quote.getSellAsset())) { + throw new BadRequestException( + String.format( + "source asset(%s) does not match quote sell asset(%s)", + sellAsset.getSep38AssetName(), 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(); + } + + /** 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/RequestValidator.java b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java new file mode 100644 index 0000000000..29a45f42af --- /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.*; +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 AnchorException { + 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/Sep6DepositInfoGenerator.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6DepositInfoGenerator.java new file mode 100644 index 0000000000..0fc26f6442 --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6DepositInfoGenerator.java @@ -0,0 +1,16 @@ +package org.stellar.anchor.sep6; + +import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.shared.SepDepositInfo; + +public interface Sep6DepositInfoGenerator { + + /** + * Gets the deposit info based on the input parameter. + * + * @param txn the original SEP-6 transaction the deposit info will be used for. + * @return a SepDepositInfo instance containing the destination address, memo and memoType. + * @throws AnchorException if the deposit info cannot be generated + */ + SepDepositInfo generate(Sep6Transaction txn) throws AnchorException; +} 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..bff3b0a884 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -1,31 +1,50 @@ package org.stellar.anchor.sep6; +import static org.stellar.anchor.util.MemoHelper.*; + 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; -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; +import org.stellar.anchor.event.EventService; +import org.stellar.anchor.sep6.ExchangeAmountsCalculator.Amounts; +import org.stellar.anchor.util.SepHelper; +import org.stellar.anchor.util.TransactionHelper; +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; private final InfoResponse infoResponse; public Sep6Service( - Sep6Config sep6Config, AssetService assetService, Sep6TransactionStore txnStore) { + 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 = + eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); this.infoResponse = buildInfoResponse(); } @@ -33,6 +52,327 @@ public InfoResponse getInfo() { return infoResponse; } + public StartDepositResponse deposit(Sep10Jwt token, StartDepositRequest request) + throws AnchorException { + // Pre-validation + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo asset = requestValidator.getDepositAsset(request.getAssetCode()); + if (request.getType() != null) { + requestValidator.validateTypes( + request.getType(), asset.getCode(), asset.getDeposit().getMethods()); + } + 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(); + + 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()) + // NB: these are purposely set to incorrect values. + // amount_out and amount_fee assets cannot be determined when the + // platform creates the transaction, but the RPC API requires + // these to be set during a notify_amounts_updated call. + .amountOut(request.getAmount()) + .amountOutAsset(asset.getSep38AssetName()) + .amountFee("0") + .amountFeeAsset(asset.getSep38AssetName()) + .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(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(txn.getId()) + .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()); + + Amounts amounts; + if (request.getQuoteId() != null) { + amounts = + exchangeAmountsCalculator.calculateFromQuote( + request.getQuoteId(), sellAsset, request.getAmount()); + } else { + // If a quote is not provided, set the fee and out amounts to 0. + // The business server should use the notify_amounts_updated RPC to update the amounts. + amounts = + Amounts.builder() + .amountIn(request.getAmount()) + .amountInAsset(sellAsset.getSep38AssetName()) + .amountOut("0") + .amountOutAsset(buyAsset.getSep38AssetName()) + .amountFee("0") + .amountFeeAsset(sellAsset.getSep38AssetName()) + .build(); + } + + 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()) + .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 + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo asset = requestValidator.getWithdrawAsset(request.getAssetCode()); + if (request.getType() != null) { + requestValidator.validateTypes( + request.getType(), asset.getCode(), asset.getWithdraw().getMethods()); + } + if (request.getAmount() != null) { + requestValidator.validateAmount( + request.getAmount(), + asset.getCode(), + asset.getSignificantDecimals(), + asset.getWithdraw().getMinAmount(), + asset.getWithdraw().getMaxAmount()); + } + String sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); + requestValidator.validateAccount(sourceAccount); + + String id = SepHelper.generateSepTransactionId(); + + 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()) + .amountIn(request.getAmount()) + .amountInAsset(asset.getSep38AssetName()) + // NB: these are purposely set to incorrect values. + // amount_out and amount_fee assets cannot be determined when the + // platform creates the transaction, but the RPC API requires + // these to be set during a notify_amounts_updated call. + .amountOut(request.getAmount()) + .amountOutAsset(asset.getSep38AssetName()) + .amountFee("0") + .amountFeeAsset(asset.getSep38AssetName()) + .amountExpected(request.getAmount()) + .startedAt(Instant.now()) + .sep10Account(token.getAccount()) + .sep10AccountMemo(token.getAccountMemo()) + .fromAccount(sourceAccount) + .refundMemo(request.getRefundMemo()) + .refundMemoType(request.getRefundMemoType()); + + 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().id(txn.getId()).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 = 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 sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); + requestValidator.validateAccount(sourceAccount); + + String id = SepHelper.generateSepTransactionId(); + + Amounts amounts; + if (request.getQuoteId() != null) { + amounts = + exchangeAmountsCalculator.calculateFromQuote( + request.getQuoteId(), sellAsset, request.getAmount()); + } else { + // If a quote is not provided, set the fee and out amounts to 0. + // The business server should use the notify_amounts_updated RPC to update the amounts. + amounts = + Amounts.builder() + .amountIn(request.getAmount()) + .amountInAsset(sellAsset.getSep38AssetName()) + .amountOut("0") + .amountOutAsset(buyAsset.getSep38AssetName()) + .amountFee("0") + .amountFeeAsset(sellAsset.getSep38AssetName()) + .build(); + } + + 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()) + .fromAccount(sourceAccount) + .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().id(txn.getId()).build(); + } + public GetTransactionsResponse findTransactions(Sep10Jwt token, GetTransactionsRequest request) throws SepException { // Pre-validation @@ -53,8 +393,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); } @@ -93,60 +433,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().toString()) - .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()); - - if (org.stellar.anchor.sep6.Sep6Transaction.Kind.DEPOSIT.toString().equals(txn.getKind())) { - return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); - } else { - throw new NotImplementedException(String.format("kind %s not implemented", txn.getKind())); - } + return new GetTransactionResponse(Sep6TransactionUtils.fromTxn(txn)); } private InfoResponse buildInfoResponse() { @@ -187,6 +474,8 @@ private InfoResponse buildInfoResponse() { DepositAssetResponse.builder() .enabled(true) .authenticationRequired(true) + .minAmount(asset.getDeposit().getMinAmount()) + .maxAmount(asset.getDeposit().getMaxAmount()) .fields(ImmutableMap.of("type", type)) .build(); @@ -196,15 +485,17 @@ 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 = WithdrawAssetResponse.builder() .enabled(true) .authenticationRequired(true) + .minAmount(asset.getWithdraw().getMinAmount()) + .maxAmount(asset.getWithdraw().getMaxAmount()) .types(types) .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..0d5d97091e 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 { @@ -100,6 +103,15 @@ public interface Sep6Transaction extends SepTransaction { void setCompletedAt(Instant completedAt); + /** + * The date and time the user funds were received. + * + * @return the transfer received at timestamp. + */ + Instant getTransferReceivedAt(); + + void setTransferReceivedAt(Instant transferReceivedAt); + /** * The deposit or withdrawal method used. E.g. bank_account, cash * @@ -326,9 +338,38 @@ public interface Sep6Transaction extends SepTransaction { * * @return the required info updates. */ - String getRequiredInfoUpdates(); + List getRequiredInfoUpdates(); + + 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 setRequiredInfoUpdates(String requiredInfoUpdates); + void setInstructions(Map instructions); enum Kind { DEPOSIT("deposit"), @@ -347,5 +388,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/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/sep6/Sep6TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java index 79b62d2b6d..757a13f0ef 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,7 @@ List findTransactions( Sep6Transaction save(Sep6Transaction sep6Transaction) throws SepException; List findTransactions(TransactionsParams params) throws SepException; + + Sep6Transaction findOneByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status); } 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..7692f53f3d --- /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().replace("-", "_")).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/main/java/org/stellar/anchor/util/TransactionHelper.java b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java index 00d644fe2e..4ae9e63399 100644 --- a/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java @@ -1,9 +1,8 @@ package org.stellar.anchor.util; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.*; +import com.google.common.collect.ImmutableSet; import java.util.Optional; import javax.annotation.Nullable; import org.stellar.anchor.api.custody.CreateCustodyTransactionRequest; @@ -11,18 +10,44 @@ 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.RefundPayment; -import org.stellar.anchor.api.shared.Refunds; +import org.stellar.anchor.api.shared.*; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.sep24.Sep24RefundPayment; import org.stellar.anchor.sep24.Sep24Refunds; 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 CreateCustodyTransactionRequest toCustodyTransaction(Sep6Transaction txn) { + PlatformTransactionData.Kind kind = PlatformTransactionData.Kind.from(txn.getKind()); + return CreateCustodyTransactionRequest.builder() + .id(txn.getId()) + .memo(txn.getMemo()) + .memoType(txn.getMemoType()) + .protocol("6") + .fromAccount( + ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(kind) + ? txn.getFromAccount() + : null) + .toAccount( + ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(kind) + ? txn.getToAccount() + : txn.getWithdrawAnchorAccount()) + .amount( + ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(kind) + ? txn.getAmountOut() + : Optional.ofNullable(txn.getAmountExpected()).orElse(txn.getAmountIn())) + .asset( + ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(kind) + ? txn.getAmountOutAsset() + : txn.getAmountInAsset()) + .kind(txn.getKind()) + .build(); + } + public static CreateCustodyTransactionRequest toCustodyTransaction(Sep24Transaction txn) { return CreateCustodyTransactionRequest.builder() .id(txn.getId()) @@ -88,6 +113,49 @@ 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); + StellarId customer = + StellarId.builder().account(txn.getSep10Account()).memo(txn.getSep10AccountMemo()).build(); + + return GetTransactionResponse.builder() + .id(txn.getId()) + .sep(PlatformTransactionData.Sep.SEP_6) + .kind(PlatformTransactionData.Kind.from(txn.getKind())) + .status(SepTransactionStatus.from(txn.getStatus())) + .type(txn.getType()) + .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()) + .transferReceivedAt(txn.getTransferReceivedAt()) + .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()) + .customers(Customers.builder().sender(customer).receiver(customer).build()) + .build(); + } + public static GetTransactionResponse toGetTransactionResponse( Sep24Transaction txn, AssetService assetService) { Refunds refunds = null; @@ -127,6 +195,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) { @@ -136,7 +205,18 @@ 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( + @Nullable String dbAsset, AssetService service, Sep6Transaction txn) { + if (dbAsset != null) { + return dbAsset; + } + + AssetInfo info = service.getAsset(txn.getRequestAssetCode(), txn.getRequestAssetIssuer()); + + return info.getSep38AssetName(); } static RefundPayment toRefundPayment(Sep24RefundPayment refundPayment, String assetName) { 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/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java b/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java index 332ba92810..191dd09a1c 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; @@ -20,6 +22,7 @@ public class PojoSep6Transaction implements Sep6Transaction { Instant startedAt; Instant completedAt; Instant updatedAt; + Instant transferReceivedAt; String type; String requestAssetCode; String requestAssetIssuer; @@ -43,6 +46,8 @@ public class PojoSep6Transaction implements Sep6Transaction { String refundMemo; 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/TestConstants.kt b/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt index c9998e24f2..e5f51cb4cc 100644 --- a/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt +++ b/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt @@ -4,6 +4,7 @@ class TestConstants { companion object { const val TEST_SIGNING_SEED = "SBVEOFAHGJCKGR4AAM7RTDRCP6RMYYV5YUV32ZK7ZD3VPDGGHYLXTZRZ" const val TEST_ACCOUNT = "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + const val TEST_CUSTOMER_ID = "test-customer-id" const val TEST_MEMO = "123" const val TEST_WEB_AUTH_DOMAIN = "test.stellar.org/auth" const val TEST_CLIENT_DOMAIN = "test.client.stellar.org" @@ -13,8 +14,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/sep10/Sep10ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt index 15b534bf81..0431925c32 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep10/Sep10ServiceTest.kt @@ -4,8 +4,11 @@ package org.stellar.anchor.sep10 import com.google.common.io.BaseEncoding import com.google.gson.annotations.SerializedName -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.spyk +import io.mockk.verify import java.io.IOException import java.security.SecureRandom import java.util.concurrent.TimeUnit @@ -14,11 +17,13 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.parallel.ExecutionMode.* import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.MethodSource @@ -287,14 +292,12 @@ internal class Sep10ServiceTest { } @ParameterizedTest - @CsvSource( - value = ["true,test.client.stellar.org", "false,test.client.stellar.org", "false,null"] - ) + @CsvSource(value = ["true,test.client.stellar.org", "false,test.client.stellar.org", "false,"]) @LockAndMockStatic([NetUtil::class, Sep10Challenge::class]) - fun `test create challenge ok`(clientAttributionRequired: String, clientDomain: String) { + fun `test create challenge ok`(clientAttributionRequired: Boolean, clientDomain: String?) { every { NetUtil.fetch(any()) } returns TEST_CLIENT_TOML - every { sep10Config.isClientAttributionRequired } returns clientAttributionRequired.toBoolean() + every { sep10Config.isClientAttributionRequired } returns clientAttributionRequired every { sep10Config.allowedClientDomains } returns listOf(TEST_CLIENT_DOMAIN) val cr = ChallengeRequest.builder() @@ -303,12 +306,13 @@ internal class Sep10ServiceTest { .homeDomain(TEST_HOME_DOMAIN) .clientDomain(TEST_CLIENT_DOMAIN) .build() - cr.clientDomain = if (clientDomain == "null") null else clientDomain + cr.clientDomain = clientDomain val challengeResponse = sep10Service.createChallenge(cr) assertEquals(challengeResponse.networkPassphrase, TESTNET.networkPassphrase) - verify(exactly = 1) { + // TODO: This should be at most once but there is a concurrency bug in the test. + verify(atLeast = 1, atMost = 2) { Sep10Challenge.newChallenge( any(), Network(TESTNET.networkPassphrase), @@ -316,7 +320,7 @@ internal class Sep10ServiceTest { TEST_HOME_DOMAIN, TEST_WEB_AUTH_DOMAIN, any(), - any(), + clientDomain ?: "", any(), any() ) @@ -573,7 +577,7 @@ internal class Sep10ServiceTest { } @Test - @LockAndMockStatic([NetUtil::class, KeyPair::class]) + @LockAndMockStatic([NetUtil::class]) fun `test getClientAccountId failure`() { every { NetUtil.fetch(any()) } returns " NETWORK_PASSPHRASE=\"Public Global Stellar Network ; September 2015\"\n" @@ -583,8 +587,13 @@ internal class Sep10ServiceTest { every { NetUtil.fetch(any()) } answers { throw IOException("Cannot connect") } assertThrows { Sep10Helper.fetchSigningKeyFromClientDomain(TEST_CLIENT_DOMAIN) } - every { NetUtil.fetch(any()) } returns TEST_CLIENT_TOML - every { KeyPair.fromAccountId(any()) } answers { throw FormatException("Bad Format") } + every { NetUtil.fetch(any()) } returns + """ + NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" + HORIZON_URL="https://horizon.stellar.org" + FEDERATION_SERVER="https://preview.lobstr.co/federation/" + SIGNING_KEY="BADKEY" + """ assertThrows { Sep10Helper.fetchSigningKeyFromClientDomain(TEST_CLIENT_DOMAIN) } } 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 63d9a7297b..a4fdbd514e 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) } @Test @@ -218,10 +228,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 = @@ -246,11 +258,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/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 4b649b8118..b8656e2385 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -840,7 +840,8 @@ class Sep31ServiceTest { "receiverId":"137938d4-43a7-4252-a452-842adcee474c", "senderId":"d2bd1412-e2f6-4047-ad70-a1a2f133b25c", "creator": { - "account": "GBJDSMTMG4YBP27ZILV665XBISBBNRP62YB7WZA2IQX2HIPK7ABLF4C2" + "account": "GBJDSMTMG4YBP27ZILV665XBISBBNRP62YB7WZA2IQX2HIPK7ABLF4C2", + "memo": "123456" } }""" .trimMargin() diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31TransactionTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31TransactionTest.kt index e9d7fa6009..8187d9df58 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31TransactionTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31TransactionTest.kt @@ -124,7 +124,7 @@ class Sep31TransactionTest { .clientDomain("test.com") .senderId("6c1770b0-0ea4-11ed-861d-0242ac120002") .receiverId("31212353-f265-4dba-9eb4-0bbeda3ba7f2") - .creator(StellarId("141ee445-f32c-4c38-9d25-f4475d6c5558", null)) + .creator(StellarId("141ee445-f32c-4c38-9d25-f4475d6c5558", null, null)) .build() } 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..151b4bc285 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/ExchangeAmountsCalculatorTest.kt @@ -0,0 +1,106 @@ +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 +import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET_SEP38_FORMAT +import org.stellar.anchor.TestHelper +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.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 { + companion object { + val token = TestHelper.createSep10Jwt(TEST_ACCOUNT, TestConstants.TEST_MEMO) + } + + private val assetService: AssetService = DefaultAssetService.fromJsonResource("test_assets.json") + + @MockK(relaxed = true) lateinit var sep38QuoteStore: Sep38QuoteStore + + private lateinit var calculator: ExchangeAmountsCalculator + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + calculator = ExchangeAmountsCalculator(sep38QuoteStore) + } + + private val usdcQuote = + PojoSep38Quote().apply { + sellAsset = TEST_ASSET_SEP38_FORMAT + 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(TEST_ASSET_SEP38_FORMAT) + .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") + } + } +} 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..500f7ff7f5 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt @@ -0,0 +1,148 @@ +package org.stellar.anchor.sep6 + +import io.mockk.* +import io.mockk.impl.annotations.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 7e95fff95b..4eec4f174d 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -6,36 +6,47 @@ import io.mockk.impl.annotations.MockK import java.time.Instant import java.util.* import kotlin.test.assertEquals +import kotlin.test.assertNotNull 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.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 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.sep6.ExchangeAmountsCalculator.Amounts import org.stellar.anchor.util.GsonUtils class Sep6ServiceTest { companion object { val gson: Gson = GsonUtils.getInstance() + val token = TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO) } 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 + @MockK(relaxed = true) lateinit var eventSession: EventService.Session private lateinit var sep6Service: Sep6Service @@ -44,145 +55,1154 @@ class Sep6ServiceTest { MockKAnnotations.init(this, relaxUnitFun = true) every { sep6Config.features.isAccountCreation } returns false every { sep6Config.features.isClaimableBalances } returns false - sep6Service = Sep6Service(sep6Config, assetService, txnStore) - } - - 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": {}, - "bank_account": {} - } - } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } - } - }, - "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", - "depositMemo": "some memo", - "depositMemoType": "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": "some info updates" - } - ] - } - """ - .trimIndent() - - @Test - fun `test INFO response`() { + 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, + requestValidator, + txnStore, + exchangeAmountsCalculator, + eventService + ) + } + + 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 + 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 = + StartDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + val response = sep6Service.deposit(token, 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( + Sep6ServiceTestData.depositTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + 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) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @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(token, 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 + ) + 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 with unsupported asset`() { + val unsupportedAsset = "??" + val request = + StartDepositRequest.builder() + .assetCode(unsupportedAsset) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + every { requestValidator.getDepositAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") + + assertThrows { sep6Service.deposit(token, 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(token, 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(token, 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 } + } + + @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 = + StartDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + + assertThrows { sep6Service.deposit(token, 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 { 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(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // 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 + + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(destinationAsset) + .sourceAsset(sourceAsset) + .amount(amount) + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + val response = sep6Service.depositExchange(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + 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(token, 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(token, 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(token, 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(token, 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(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + 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 = + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount("100") + .refundMemo("some text") + .refundMemoType("text") + .build() + + val response = sep6Service.withdraw(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawTxnEventJson, + 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) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @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(token, 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() + 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(token, request) + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(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.withdrawTxnWithoutAmountOrTypeJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + 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()) + 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(unsupportedAsset) + .type("bank_account") + .amount("100") + .build() + every { requestValidator.getWithdrawAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") + + assertThrows { sep6Service.withdraw(token, 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(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(token, 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 } + } + + @Test + fun `test withdraw with bad amount`() { + val badAmount = "0" + val request = + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount(badAmount) + .build() + every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws + SepValidationException("bad amount") + + assertThrows { sep6Service.withdraw(token, 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 } + } + + @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 = + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount("100") + .build() + + assertThrows { sep6Service.withdraw(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + 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(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // 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.withdrawExchangeTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawExchangeTxnEventJson, + 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) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals( + Sep6ServiceTestData.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 + + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(sourceAsset) + .destinationAsset(destinationAsset) + .type("bank_account") + .amount("100") + .refundMemo("some text") + .refundMemoType("text") + .build() + val response = sep6Service.withdrawExchange(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawExchangeTxnWithoutQuoteJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawExchangeTxnWithoutQuoteEventJson, + 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) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @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(token, 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 = "??" + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(unsupportedAsset) + .destinationAsset("iso4217:USD") + .type("bank_account") + .amount("100") + .build() + every { requestValidator.getWithdrawAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") + + assertThrows { sep6Service.withdrawExchange(token, request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(unsupportedAsset) } + + // Verify effects + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw-exchange with unsupported destination asset`() { + val unsupportedAsset = "??" + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset(unsupportedAsset) + .type("bank_account") + .amount("100") + .build() + + assertThrows { sep6Service.withdrawExchange(token, request) } + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw-exchange with unsupported type`() { + val unsupportedType = "??" + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("iso4217:USD") + .type(unsupportedType) + .amount("100") + .build() + every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws + SepValidationException("unsupported type") + + assertThrows { sep6Service.withdrawExchange(token, 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 } + } + + @Test + fun `test withdraw-exchange with bad amount`() { + val badAmount = "??" + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("iso4217:USD") + .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") + + assertThrows { sep6Service.withdrawExchange(token, 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 } + } + + @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() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + + assertThrows { sep6Service.withdrawExchange(token, 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(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify { eventSession wasNot called } } @Test @@ -333,7 +1353,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( @@ -378,7 +1398,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/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..3d02534883 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt @@ -0,0 +1,644 @@ +package org.stellar.anchor.sep6 + +class Sep6ServiceTestData { + companion object { + val infoJson = + """ + { + "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 = + """ + { + "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 depositResJson = + """ + { + "how": "Check the transaction for more information about how to deposit." + } + """ + .trimIndent() + + val depositTxnJson = + """ + { + "status": "incomplete", + "kind": "deposit", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "100", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "0", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "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" + }, + "amount_out": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + + val depositTxnJsonWithoutAmountOrType = + """ + { + "status": "incomplete", + "kind": "deposit", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "0", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + + """ + .trimIndent() + + val depositTxnEventWithoutAmountOrTypeJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit", + "status": "incomplete", + "amount_expected": { + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .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", + "sep10AccountMemo": "123", + "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", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + + val depositExchangeTxnWithoutQuoteJson = + """ + { + "status": "incomplete", + "kind": "deposit-exchange", + "type": "SWIFT", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "iso4217:USD", + "amountOut": "0", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "0", + "amountFeeAsset": "iso4217:USD", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + """ + .trimIndent() + + val depositExchangeTxnEventWithoutQuoteJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit-exchange", + "status": "incomplete", + "type": "SWIFT", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { "amount": "100", "asset": "iso4217:USD" }, + "amount_out": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { "amount": "0", "asset": "iso4217:USD" }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + + val withdrawResJson = + """ + { + } + """ + .trimIndent() + + val withdrawTxnJson = + """ + { + "status": "incomplete", + "kind": "withdrawal", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "100", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "0", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "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" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "refund_memo": "some text", + "refund_memo_type": "text", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + + val withdrawTxnWithoutAmountOrTypeJson = + """ + { + "status": "incomplete", + "kind": "withdrawal", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "0", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawTxnEventWithoutAmountOrTypeJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal", + "status": "incomplete", + "amount_expected": { + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "refund_memo": "some text", + "refund_memo_type": "text", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .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", + "sep10AccountMemo": "123", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "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", + "refund_memo": "some text", + "refund_memo_type": "text", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteJson = + """ + { + "status": "incomplete", + "kind": "withdrawal-exchange", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "0", + "amountOutAsset": "iso4217:USD", + "amountFee": "0", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "sep10AccountMemo": "123", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal-exchange", + "status": "incomplete", + "type": "bank_account", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { "amount": "0", "asset": "iso4217:USD" }, + "amount_fee": { + "amount": "0", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "refund_memo": "some text", + "refund_memo_type": "text", + "customers": { + "sender": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + }, + "receiver": { + "account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo": "123" + } + } + } + } + """ + .trimIndent() + } +} 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/core/src/test/kotlin/org/stellar/anchor/util/ClientFinderTest.kt b/core/src/test/kotlin/org/stellar/anchor/util/ClientFinderTest.kt new file mode 100644 index 0000000000..d97161d6f9 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/util/ClientFinderTest.kt @@ -0,0 +1,139 @@ +package org.stellar.anchor.util + +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlin.test.assertEquals +import kotlin.test.assertNull +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_MEMO +import org.stellar.anchor.TestHelper +import org.stellar.anchor.api.exception.BadRequestException +import org.stellar.anchor.client.ClientFinder +import org.stellar.anchor.config.ClientsConfig +import org.stellar.anchor.config.Sep10Config +import org.stellar.anchor.sep6.ExchangeAmountsCalculatorTest + +class ClientFinderTest { + companion object { + val token = TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO) + val clientConfig = + ClientsConfig.ClientConfig( + "name", + ClientsConfig.ClientType.CUSTODIAL, + "signing-key", + "domain", + "http://localhost:8000", + false, + emptySet() + ) + } + + @MockK(relaxed = true) lateinit var sep10Config: Sep10Config + @MockK(relaxed = true) lateinit var clientsConfig: ClientsConfig + + private lateinit var clientFinder: ClientFinder + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + every { sep10Config.isClientAttributionRequired } returns true + every { sep10Config.allowedClientDomains } returns listOf(clientConfig.domain) + every { sep10Config.allowedClientNames } returns listOf(clientConfig.name) + every { + clientsConfig.getClientConfigByDomain(ExchangeAmountsCalculatorTest.token.clientDomain) + } returns clientConfig + every { + clientsConfig.getClientConfigBySigningKey(ExchangeAmountsCalculatorTest.token.account) + } returns clientConfig + + clientFinder = ClientFinder(sep10Config, clientsConfig) + } + + @Test + fun `test getClientId with client found by domain`() { + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns clientConfig + val clientId = clientFinder.getClientId(token) + + assertEquals(clientConfig.name, clientId) + } + + @Test + fun `test getClientId with client found by signing key`() { + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns null + val clientId = clientFinder.getClientId(token) + + assertEquals(clientConfig.name, clientId) + } + + @Test + fun `test getClientId with client not found`() { + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns null + every { clientsConfig.getClientConfigBySigningKey(token.account) } returns null + + assertThrows { clientFinder.getClientId(token) } + } + + @Test + fun `test getClientId with client not found by domain`() { + every { sep10Config.allowedClientDomains } returns listOf("nothing") + + assertThrows { clientFinder.getClientId(token) } + } + + @Test + fun `test getClientId with client not found by name`() { + every { sep10Config.allowedClientNames } returns listOf("nothing") + + assertThrows { clientFinder.getClientId(token) } + } + + @Test + fun `test getClientId with all domains allowed`() { + every { sep10Config.allowedClientDomains } returns emptyList() + val clientId = clientFinder.getClientId(token) + + assertEquals(clientConfig.name, clientId) + } + + @Test + fun `test getClientId with all names allowed`() { + every { sep10Config.allowedClientNames } returns emptyList() + val clientId = clientFinder.getClientId(token) + + assertEquals(clientConfig.name, clientId) + } + + @Test + fun `test getClientId with client attribution disabled and missing client`() { + every { sep10Config.isClientAttributionRequired } returns false + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns null + every { clientsConfig.getClientConfigBySigningKey(token.account) } returns null + + val clientId = clientFinder.getClientId(token) + assertNull(clientId) + } + + @Test + fun `test getClientId with client attribution disabled and client found by signing key`() { + every { sep10Config.isClientAttributionRequired } returns false + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns null + every { clientsConfig.getClientConfigBySigningKey(token.account) } returns clientConfig + + val clientId = clientFinder.getClientId(token) + assertEquals(clientConfig.name, clientId) + } + + @Test + fun `test getClientId with client attribution disabled and client found by domain`() { + every { sep10Config.isClientAttributionRequired } returns false + every { clientsConfig.getClientConfigByDomain(token.clientDomain) } returns clientConfig + every { clientsConfig.getClientConfigBySigningKey(token.account) } returns null + + val clientId = clientFinder.getClientId(token) + assertEquals(clientConfig.name, clientId) + } +} 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 +``` diff --git a/docs/diagrams/sep6/withdraw.md b/docs/diagrams/sep6/withdraw.md new file mode 100644 index 0000000000..4180639c36 --- /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. 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 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 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 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 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 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 event callback with type transaction_status_changed + Wallet->>User: Notifies user that withdraw is complete +``` diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index f7c646e96a..f1d00ce0d9 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -48,4 +48,12 @@ dependencies { testImplementation(libs.dotenv) } -tasks { bootJar { enabled = false } } +tasks { + bootJar { enabled = false } + test { + useJUnitPlatform() + // Setting forkEvery to 1 makes Gradle test execution to start a separeate JVM for each integration test classes. + // This is to to avoid the interaction between static states between each integration test classes. + setForkEvery(1) + } +} 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..81d9f08141 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,37 @@ package org.stellar.anchor.platform +import org.stellar.anchor.api.sep.sep6.GetTransactionResponse import org.stellar.anchor.api.sep.sep6.InfoResponse -import org.stellar.anchor.util.Log +import org.stellar.anchor.api.sep.sep6.StartDepositResponse +import org.stellar.anchor.api.sep.sep6.StartWithdrawResponse -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, exchange: Boolean = false): StartDepositResponse { + val baseUrl = if (exchange) "$endpoint/deposit-exchange?" else "$endpoint/deposit?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + val responseBody = httpGet(url, jwt) + return gson.fromJson(responseBody, StartDepositResponse::class.java) + } + + fun withdraw(request: Map, exchange: Boolean = false): StartWithdrawResponse { + val baseUrl = if (exchange) "$endpoint/withdraw-exchange?" else "$endpoint/withdraw?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + val responseBody = httpGet(url, jwt) + return gson.fromJson(responseBody, StartWithdrawResponse::class.java) + } + + fun getTransaction(request: Map): GetTransactionResponse { + val baseUrl = "$endpoint/transaction?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + 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 fe814fb1c9..bec5e363d8 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 @@ -34,6 +34,7 @@ open class AbstractIntegrationTest(private val config: TestConfig) { lateinit var custodyApiTests: CustodyApiTests lateinit var eventProcessingServerTests: EventProcessingServerTests lateinit var sep24E2eTests: Sep24End2EndTests + lateinit var sep6E2eTests: Sep6End2EndTest lateinit var sep24RpcE2eTests: Sep24RpcEnd2EndTests lateinit var sep24CustodyE2eTests: Sep24CustodyEnd2EndTests lateinit var sep24CustodyRpcE2eTests: Sep24CustodyRpcEnd2EndTests @@ -61,7 +62,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) @@ -73,6 +74,7 @@ open class AbstractIntegrationTest(private val config: TestConfig) { stellarObserverTests = StellarObserverTests() custodyApiTests = CustodyApiTests(config, toml, jwt) sep24E2eTests = Sep24End2EndTests(config, jwt) + sep6E2eTests = Sep6End2EndTest(config, jwt) sep24CustodyE2eTests = Sep24CustodyEnd2EndTests(config, jwt) sep24RpcE2eTests = Sep24RpcEnd2EndTests(config, jwt) sep24CustodyRpcE2eTests = Sep24CustodyRpcEnd2EndTests(config, jwt) diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyEnd2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyEnd2EndTest.kt index 11af11c975..b2e49eb590 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyEnd2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyEnd2EndTest.kt @@ -28,4 +28,12 @@ class AnchorPlatformCustodyEnd2EndTest : fun runSep24Test() { singleton.sep24CustodyE2eTests.testAll() } + + @Test + @Order(11) + fun runSep6Test() { + // The SEP-6 reference server implementation only implements RPC, so technically this test + // should be in the RPC test suite. + singleton.sep6E2eTests.testAll() + } } 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 2f9458bf05..8d38b58316 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,12 @@ class AnchorPlatformEnd2EndTest : AbstractIntegrationTest(TestConfig(testProfile fun runSep24Test() { singleton.sep24E2eTests.testAll() } + + @Test + @Order(2) + fun runSep6Test() { + // The SEP-6 reference server implementation only implements RPC, so technically this test + // should be in the RPC test suite. + singleton.sep6E2eTests.testAll() + } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/PlatformApiCustodyTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/PlatformApiCustodyTests.kt index b72618bce8..faa3f0cc50 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/PlatformApiCustodyTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/PlatformApiCustodyTests.kt @@ -62,6 +62,14 @@ class PlatformApiCustodyTests(config: TestConfig, toml: Sep1Helper.TomlContent, `SEP-31 refunded do_stellar_refund`() } + private fun `SEP-6 deposit complete full`() { + // TODO(philip): add this after custody changes are merged + } + + private fun `SEP-6 withdraw full refund`() { + // TODO(philip): add this after custody changes are merged + } + /** * 1. incomplete -> notify_interactive_flow_complete * 2. pending_anchor -> request_offchain_funds @@ -70,7 +78,7 @@ class PlatformApiCustodyTests(config: TestConfig, toml: Sep1Helper.TomlContent, * 5. completed */ private fun `SEP-24 deposit complete full`() { - `test deposit flow`( + `test SEP-24 deposit flow`( SEP_24_DEPOSIT_COMPLETE_FULL_FLOW_ACTION_REQUESTS, SEP_24_DEPOSIT_COMPLETE_FULL_FLOW_ACTION_RESPONSES ) @@ -85,7 +93,7 @@ class PlatformApiCustodyTests(config: TestConfig, toml: Sep1Helper.TomlContent, * 6. refunded */ private fun `SEP-24 withdraw full refund`() { - `test withdraw flow`( + `test SEP-24 withdraw flow`( SEP_24_WITHDRAW_FULL_REFUND_FLOW_ACTION_REQUESTS, SEP_24_WITHDRAW_FULL_REFUND_FLOW_ACTION_RESPONSES ) @@ -104,7 +112,7 @@ class PlatformApiCustodyTests(config: TestConfig, toml: Sep1Helper.TomlContent, ) } - private fun `test deposit flow`(actionRequests: String, actionResponse: String) { + private fun `test SEP-24 deposit flow`(actionRequests: String, actionResponse: String) { val depositRequest = gson.fromJson(SEP_24_DEPOSIT_FLOW_REQUEST, HashMap::class.java) val depositResponse = sep24Client.deposit(depositRequest as HashMap) `test flow`(depositResponse.id, actionRequests, actionResponse) @@ -136,7 +144,7 @@ class PlatformApiCustodyTests(config: TestConfig, toml: Sep1Helper.TomlContent, `test flow`(receiveResponse.id, updatedActionRequests, updatedActionResponses) } - private fun `test withdraw flow`(actionRequests: String, actionResponse: String) { + private fun `test SEP-24 withdraw flow`(actionRequests: String, actionResponse: String) { val withdrawRequest = gson.fromJson(SEP_24_WITHDRAW_FLOW_REQUEST, HashMap::class.java) val withdrawResponse = sep24Client.withdraw(withdrawRequest as HashMap) `test flow`(withdrawResponse.id, actionRequests, actionResponse) diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyEnd2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyEnd2EndTests.kt index 90edb5ab61..43c5d6853f 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyEnd2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyEnd2EndTests.kt @@ -233,8 +233,7 @@ class Sep24CustodyEnd2EndTests(config: TestConfig, val jwt: String) { var retries = 5 var callbacks: List? = null while (retries > 0) { - callbacks = - walletServerClient.getCallbackHistory(txnId, Sep24GetTransactionResponse::class.java) + callbacks = walletServerClient.getCallbacks(txnId, Sep24GetTransactionResponse::class.java) if (callbacks.size == count) { return callbacks } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyRpcEnd2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyRpcEnd2EndTests.kt index 4dd098eefa..55a4966f52 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyRpcEnd2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyRpcEnd2EndTests.kt @@ -232,8 +232,7 @@ class Sep24CustodyRpcEnd2EndTests(config: TestConfig, val jwt: String) { var retries = 5 var callbacks: List? = null while (retries > 0) { - callbacks = - walletServerClient.getCallbackHistory(txnId, Sep24GetTransactionResponse::class.java) + callbacks = walletServerClient.getCallbacks(txnId, Sep24GetTransactionResponse::class.java) if (callbacks.size == count) { return callbacks } 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 f2624e649c..6a4243a95f 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 @@ -15,7 +15,8 @@ import org.springframework.web.util.UriComponentsBuilder import org.stellar.anchor.api.callback.SendEventRequest import org.stellar.anchor.api.callback.SendEventRequestPayload import org.stellar.anchor.api.event.AnchorEvent -import org.stellar.anchor.api.event.AnchorEvent.Type.* +import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_CREATED +import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse import org.stellar.anchor.auth.JwtService @@ -233,9 +234,9 @@ class Sep24End2EndTests(config: TestConfig, val jwt: String) { var callbacks: List? = null while (retries > 0) { callbacks = - walletServerClient - .getCallbackHistory(txnId, Sep24GetTransactionResponse::class.java) - .distinctBy { it.transaction.status } + walletServerClient.getCallbacks(txnId, Sep24GetTransactionResponse::class.java).distinctBy { + it.transaction.status + } if (callbacks.size == count) { return callbacks } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24RpcEnd2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24RpcEnd2EndTests.kt index 781e021d90..e7156fe1cb 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24RpcEnd2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24RpcEnd2EndTests.kt @@ -16,7 +16,8 @@ import org.springframework.web.util.UriComponentsBuilder import org.stellar.anchor.api.callback.SendEventRequest import org.stellar.anchor.api.callback.SendEventRequestPayload import org.stellar.anchor.api.event.AnchorEvent -import org.stellar.anchor.api.event.AnchorEvent.Type.* +import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_CREATED +import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse import org.stellar.anchor.auth.JwtService @@ -232,9 +233,9 @@ class Sep24RpcEnd2EndTests(config: TestConfig, val jwt: String) { var callbacks: List? = null while (retries > 0) { callbacks = - walletServerClient - .getCallbackHistory(txnId, Sep24GetTransactionResponse::class.java) - .distinctBy { it.transaction.status } + walletServerClient.getCallbacks(txnId, Sep24GetTransactionResponse::class.java).distinctBy { + it.transaction.status + } if (callbacks.size == count) { return callbacks } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31CustodyRpcEnd2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31CustodyRpcEnd2EndTests.kt index 66f4faca7a..eafd07db01 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31CustodyRpcEnd2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31CustodyRpcEnd2EndTests.kt @@ -167,8 +167,7 @@ class Sep31CustodyRpcEnd2EndTests( var retries = 5 var callbacks: List? = null while (retries > 0) { - callbacks = - walletServerClient.getCallbackHistory(txnId, Sep31GetTransactionResponse::class.java) + callbacks = walletServerClient.getCallbacks(txnId, Sep31GetTransactionResponse::class.java) if (callbacks.size == count) { return callbacks } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31RpcEnd2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31RpcEnd2EndTests.kt index 4439dabfe3..1d4a504180 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31RpcEnd2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31RpcEnd2EndTests.kt @@ -164,9 +164,9 @@ class Sep31RpcEnd2EndTests(config: TestConfig, val toml: Sep1Helper.TomlContent, var callbacks: List? = null while (retries > 0) { callbacks = - walletServerClient - .getCallbackHistory(txnId, Sep31GetTransactionResponse::class.java) - .distinctBy { it.transaction.status } + walletServerClient.getCallbacks(txnId, Sep31GetTransactionResponse::class.java).distinctBy { + it.transaction.status + } if (callbacks.size == count) { return callbacks } 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 c3fe36b325..5ec471a44c 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/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..033bc8d793 --- /dev/null +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -0,0 +1,227 @@ +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 +import kotlinx.coroutines.runBlocking +import org.stellar.anchor.api.sep.SepTransactionStatus +import org.stellar.anchor.api.sep.SepTransactionStatus.* +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.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 +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 + 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 val anchorReferenceServerClient = + AnchorReferenceServerClient(Url(config.env["reference.server.url"]!!)) + private val walletServerClient = WalletServerClient(Url(config.env["wallet.server.url"]!!)) + + companion object { + private val USDC = + IssuedAssetId("USDC", "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP") + private val basicInfoFields = listOf("first_name", "last_name", "email_address") + private val customerInfo = + mapOf( + "first_name" to "John", + "last_name" to "Doe", + "address" to "123 Bay Street", + "email_address" to "john@email.com", + "id_type" to "drivers_license", + "id_country_code" to "CAN", + "id_issue_date" to "2023-01-01T05:00:00Z", + "id_expiration_date" to "2099-01-01T05:00:00Z", + "id_number" to "1234567890", + "bank_account_number" to "13719713158835300", + "bank_account_type" to "checking", + "bank_number" to "123", + "bank_branch_number" to "121122676" + ) + } + + 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(basicInfoFields.associateWith { customerInfo[it]!! }) + + val deposit = + sep6Client.deposit( + mapOf( + "asset_code" to USDC.code, + "account" to keypair.address, + "amount" to "1", + "type" to "SWIFT" + ) + ) + waitStatus(deposit.id, PENDING_CUSTOMER_INFO_UPDATE, sep6Client) + + // Supply missing KYC info to continue with the transaction + val additionalRequiredFields = + sep6Client.getTransaction(mapOf("id" to deposit.id)).transaction.requiredCustomerInfoUpdates + anchor.customer(token).add(additionalRequiredFields.associateWith { customerInfo[it]!! }) + 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) + + val expectedStatuses = + listOf( + INCOMPLETE, + PENDING_ANCHOR, // update amounts + PENDING_CUSTOMER_INFO_UPDATE, // request KYC + PENDING_USR_TRANSFER_START, // provide deposit instructions + PENDING_ANCHOR, // deposit into user wallet + PENDING_STELLAR, + COMPLETED + ) + assertAnchorReceivedStatuses(deposit.id, expectedStatuses) + assertWalletReceivedStatuses(deposit.id, expectedStatuses) + } + + 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(basicInfoFields.associateWith { customerInfo[it]!! }) + + val withdraw = + sep6Client.withdraw( + mapOf("asset_code" to USDC.code, "amount" to "1", "type" to "bank_account") + ) + waitStatus(withdraw.id, PENDING_CUSTOMER_INFO_UPDATE, sep6Client) + + // Supply missing financial account info to continue with the transaction + val additionalRequiredFields = + sep6Client.getTransaction(mapOf("id" to withdraw.id)).transaction.requiredCustomerInfoUpdates + anchor.customer(token).add(additionalRequiredFields.associateWith { customerInfo[it]!! }) + waitStatus(withdraw.id, PENDING_USR_TRANSFER_START, sep6Client) + + val withdrawTxn = sep6Client.getTransaction(mapOf("id" to withdraw.id)).transaction + + // Transfer the withdrawal amount to the Anchor + val transfer = + wallet + .stellar() + .transaction(keypair, memo = Pair(MemoType.HASH, withdrawTxn.withdrawMemo)) + .transfer(withdrawTxn.withdrawAnchorAccount, USDC, "1") + .build() + transfer.sign(keypair) + wallet.stellar().submitTransaction(transfer) + waitStatus(withdraw.id, COMPLETED, sep6Client) + + val expectedStatuses = + listOf( + INCOMPLETE, + PENDING_ANCHOR, // update amounts + PENDING_CUSTOMER_INFO_UPDATE, // request KYC + PENDING_USR_TRANSFER_START, // wait for onchain user transfer + PENDING_ANCHOR, // funds available for pickup + PENDING_EXTERNAL, + 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.map { it.status }, statuses) + } + + private suspend fun assertWalletReceivedStatuses( + txnId: String, + expected: List + ) { + val callbacks = + walletServerClient.pollCallbacks(txnId, expected.size, GetTransactionResponse::class.java) + val statuses = callbacks.map { it.transaction.status } + assertContentEquals(expected.map { it.status }, statuses) + } + + private suspend fun waitStatus( + id: String, + expectedStatus: SepTransactionStatus, + sep6Client: Sep6Client + ) { + for (i in 0..maxTries) { + val transaction = sep6Client.getTransaction(mapOf("id" to id)) + if (expectedStatus.status != transaction.transaction.status) { + Log.info("Transaction status: ${transaction.transaction.status}") + } else { + Log.info("${GsonUtils.getInstance().toJson(transaction)}") + 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`() + `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 9ee8ea9593..7efba7816b 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,84 +2,200 @@ package org.stellar.anchor.platform.test import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode +import org.stellar.anchor.api.sep.sep38.Sep38Context +import org.stellar.anchor.platform.CLIENT_WALLET_ACCOUNT +import org.stellar.anchor.platform.Sep38Client 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 + private val sep38Client: Sep38Client + + init { + sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) + sep38Client = Sep38Client(toml.getString("ANCHOR_QUOTE_SERVER"), jwt) + } 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": { + "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 } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } + } + """ + .trimIndent() + + private val expectedSep6DepositResponse = + """ + { + "transaction": { + "kind": "deposit", + "to": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + } + """ + .trimIndent() + + private val expectedSep6DepositExchangeResponse = + """ + { + "transaction": { + "kind": "deposit-exchange", + "status": "incomplete", + "amount_in": "1", + "amount_in_asset": "iso4217:USD", + "amount_out": "0", + "amount_out_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amount_fee": "0", + "amount_fee_asset": "iso4217:USD", + "to": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + } + """ + .trimIndent() + + private val expectedSep6DepositExchangeWithQuoteResponse = + """ + { + "transaction": { + "kind": "deposit-exchange", + "status": "incomplete", + "amount_in": "10", + "amount_in_asset": "iso4217:USD", + "amount_out": "8.8235", + "amount_out_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amount_fee": "1.00", + "amount_fee_asset": "iso4217:USD", + "to": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + } + """ + .trimIndent() + + private val expectedSep6WithdrawResponse = + """ + { + "transaction": { + "kind": "withdrawal", + "status": "incomplete", + "from": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" } - }, - "fee": { - "enabled": false, - "description": "Fee endpoint is not supported." - }, - "transactions": { - "enabled": true, - "authentication_required": true - }, + } + """ + .trimIndent() + + private val expectedSep6WithdrawExchangeResponse = + """ + { + "transaction": { + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_in": "1", + "amount_in_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amount_out": "0", + "amount_out_asset": "iso4217:USD", + "amount_fee": "0", + "amount_fee_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "from": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + } + """ + .trimIndent() + + private val expectedSep6WithdrawExchangeWithQuoteResponse = + """ + { "transaction": { - "enabled": true, - "authentication_required": true - }, - "features": { - "account_creation": false, - "claimable_balances": false + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_in": "10", + "amount_in_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amount_out": "8.5714", + "amount_out_asset": "iso4217:USD", + "amount_fee": "1.00", + "amount_fee_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "from": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" } } """ @@ -87,11 +203,155 @@ class Sep6Tests(val toml: TomlContent) { 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`() { + val request = + mapOf( + "asset_code" to "USDC", + "account" to CLIENT_WALLET_ACCOUNT, + "amount" to "1", + "type" to "SWIFT" + ) + 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 + ) + } + + private fun `test sep6 deposit-exchange without quote`() { + val request = + mapOf( + "destination_asset" to "USDC", + "source_asset" to "iso4217:USD", + "amount" to "1", + "account" to CLIENT_WALLET_ACCOUNT, + "type" to "SWIFT" + ) + + val response = sep6Client.deposit(request, exchange = true) + Log.info("GET /deposit-exchange response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedDepositTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6DepositExchangeResponse, + gson.toJson(savedDepositTxn), + JSONCompareMode.LENIENT + ) + } + + private fun `test sep6 deposit-exchange with quote`() { + val quoteId = + postQuote( + "iso4217:USD", + "10", + "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + ) + val request = + mapOf( + "destination_asset" to "USDC", + "source_asset" to "iso4217:USD", + "amount" to "10", + "account" to CLIENT_WALLET_ACCOUNT, + "type" to "SWIFT", + "quote_id" to quoteId + ) + + val response = sep6Client.deposit(request, exchange = true) + Log.info("GET /deposit-exchange response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedDepositTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6DepositExchangeWithQuoteResponse, + gson.toJson(savedDepositTxn), + JSONCompareMode.LENIENT + ) + } + + private fun `test sep6 withdraw`() { + 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()) + + val savedWithdrawTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6WithdrawResponse, + gson.toJson(savedWithdrawTxn), + JSONCompareMode.LENIENT + ) + } + + private fun `test sep6 withdraw-exchange without quote`() { + val request = + mapOf( + "destination_asset" to "iso4217:USD", + "source_asset" to "USDC", + "amount" to "1", + "type" to "bank_account" + ) + + val response = sep6Client.withdraw(request, exchange = true) + Log.info("GET /withdraw-exchange response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedDepositTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6WithdrawExchangeResponse, + gson.toJson(savedDepositTxn), + JSONCompareMode.LENIENT + ) + } + + private fun `test sep6 withdraw-exchange with quote`() { + val quoteId = + postQuote( + "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "10", + "iso4217:USD" + ) + val request = + mapOf( + "destination_asset" to "iso4217:USD", + "source_asset" to "USDC", + "amount" to "10", + "type" to "bank_account", + "quote_id" to quoteId + ) + + val response = sep6Client.withdraw(request, exchange = true) + Log.info("GET /withdraw-exchange response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedDepositTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6WithdrawExchangeWithQuoteResponse, + gson.toJson(savedDepositTxn), + JSONCompareMode.LENIENT + ) + } + + private fun postQuote(sellAsset: String, sellAmount: String, buyAsset: String): String { + return sep38Client.postQuote(sellAsset, sellAmount, buyAsset, Sep38Context.SEP6).id } fun testAll() { Log.info("Performing SEP6 tests") `test Sep6 info endpoint`() + `test sep6 deposit`() + `test sep6 deposit-exchange without quote`() + `test sep6 deposit-exchange with quote`() + `test sep6 withdraw`() + `test sep6 withdraw-exchange without quote`() + `test sep6 withdraw-exchange with quote`() } } 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..10d134dc0e 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,38 @@ 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 +import org.stellar.reference.event.EventConsumer val log = KotlinLogging.logger {} -lateinit var referenceKotlinSever: NettyApplicationEngine +lateinit var referenceKotlinServer: NettyApplicationEngine +lateinit var eventConsumer: EventConsumer 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") + eventConsumer = 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) + if (::eventConsumer.isInitialized) eventConsumer.stop() 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..64a7184556 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) @@ -53,11 +55,18 @@ class CustomerService(private val customerRepository: CustomerRepository) { customer.copy( firstName = request.firstName ?: customer.firstName, lastName = request.lastName ?: customer.lastName, + address = request.address ?: customer.address, emailAddress = request.emailAddress ?: customer.emailAddress, bankAccountNumber = request.bankAccountNumber ?: customer.bankAccountNumber, bankAccountType = request.bankAccountType ?: customer.bankAccountType, - bankRoutingNumber = request.bankNumber ?: customer.bankRoutingNumber, - clabeNumber = request.clabeNumber ?: customer.clabeNumber + bankNumber = request.bankNumber ?: customer.bankNumber, + bankBranchNumber = request.bankBranchNumber ?: customer.bankBranchNumber, + clabeNumber = request.clabeNumber ?: customer.clabeNumber, + idType = request.idType ?: customer.idType, + idCountryCode = request.idCountryCode ?: customer.idCountryCode, + idIssueDate = request.idIssueDate ?: customer.idIssueDate, + idExpirationDate = request.idExpirationDate ?: customer.idExpirationDate, + idNumber = request.idNumber ?: customer.idNumber, ) ) return PutCustomerResponse(customer.id) @@ -71,11 +80,18 @@ class CustomerService(private val customerRepository: CustomerRepository) { memoType = request.memoType, firstName = request.firstName, lastName = request.lastName, + address = request.address, emailAddress = request.emailAddress, bankAccountNumber = request.bankAccountNumber, bankAccountType = request.bankAccountType, - bankRoutingNumber = request.bankNumber, - clabeNumber = request.clabeNumber + bankNumber = request.bankNumber, + bankBranchNumber = request.bankBranchNumber, + clabeNumber = request.clabeNumber, + idType = request.idType, + idCountryCode = request.idCountryCode, + idIssueDate = request.idIssueDate, + idExpirationDate = request.idExpirationDate, + idNumber = request.idNumber, ) ) return PutCustomerResponse(id) @@ -98,33 +114,82 @@ class CustomerService(private val customerRepository: CustomerRepository) { val providedFields = mutableMapOf() val missingFields = mutableMapOf() - val commonFields = + val fields = mapOf( "first_name" to createField(customer.firstName, "string", "The customer's first name"), "last_name" to createField(customer.lastName, "string", "The customer's last name"), + "address" to + createField(customer.address, "string", "The customer's address", optional = true), "email_address" to createField(customer.emailAddress, "string", "The customer's email address"), - ) - val sep31ReceiverFields = - mapOf( "bank_account_number" to - createField(customer.bankAccountNumber, "string", "The customer's bank account number"), + createField( + customer.bankAccountNumber, + "string", + "The customer's bank account number", + optional = type != "sep31-receiver" + ), "bank_account_type" to createField( customer.bankAccountType, "string", "The customer's bank account type", - choices = listOf("checking", "savings") + choices = listOf("checking", "savings"), + optional = type != "sep31-receiver" ), "bank_number" to - createField(customer.bankRoutingNumber, "string", "The customer's bank routing number"), - "clabe_number" to createField(customer.clabeNumber, "string", "The customer's CLABE number") + createField( + customer.bankNumber, + "string", + "The customer's bank routing number", + optional = type != "sep31-receiver" + ), + "bank_branch_number" to + createField( + customer.bankBranchNumber, + "string", + "The customer's bank branch number", + optional = true + ), + "clabe_number" to + createField( + customer.clabeNumber, + "string", + "The customer's CLABE number", + optional = type != "sep31-receiver" + ), + "id_type" to + createField( + customer.idType, + "string", + "The customer's ID type", + optional = true, + choices = listOf("drivers_license", "passport", "national_id") + ), + "id_country_code" to + createField( + customer.idCountryCode, + "string", + "The customer's ID country code", + optional = true + ), + "id_issue_date" to + createField( + customer.idIssueDate, + "string", + "The customer's ID issue date", + optional = true + ), + "id_expiration_date" to + createField( + customer.idExpirationDate, + "string", + "The customer's ID expiration date", + optional = true + ), + "id_number" to + createField(customer.idNumber, "string", "The customer's ID number", optional = true) ) - val fields = - when (type) { - "sep31-receiver" -> commonFields.plus(sep31ReceiverFields) - else -> commonFields - } // Extract fields from customer fields.forEach( @@ -138,7 +203,7 @@ class CustomerService(private val customerRepository: CustomerRepository) { val status = when { - missingFields.isNotEmpty() -> Status.NEEDS_INFO + missingFields.filter { !it.value.optional }.isNotEmpty() -> Status.NEEDS_INFO else -> Status.ACCEPTED }.toString() @@ -152,8 +217,10 @@ class CustomerService(private val customerRepository: CustomerRepository) { sealed class Field { class Provided(val field: ProvidedCustomerField) : Field() + class Missing(val field: CustomerField) : Field() } + private fun createField( value: Any?, type: String, 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/AnchorReferenceServerClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt index 14a5db0b8a..e8e638a035 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/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..45c343486e --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt @@ -0,0 +1,69 @@ +package org.stellar.reference.client + +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) { + 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) + } + + 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/dao/CustomerRepository.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/CustomerRepository.kt index 192cf5979d..3951d6c16c 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/CustomerRepository.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/CustomerRepository.kt @@ -1,5 +1,6 @@ package org.stellar.reference.dao +import java.time.Instant import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNull @@ -8,9 +9,13 @@ import org.stellar.reference.model.Customer interface CustomerRepository { fun get(id: String): Customer? + fun get(stellarAccount: String, memo: String?, memoType: String?): Customer? + fun create(customer: Customer): String? + fun update(customer: Customer) + fun delete(id: String) } @@ -29,12 +34,20 @@ class JdbcCustomerRepository(private val db: Database) : CustomerRepository { memo = it[Customers.memo], memoType = it[Customers.memoType], firstName = it[Customers.firstName], + address = it[Customers.address], lastName = it[Customers.lastName], emailAddress = it[Customers.emailAddress], bankAccountNumber = it[Customers.bankAccountNumber], bankAccountType = it[Customers.bankAccountType], - bankRoutingNumber = it[Customers.bankRoutingNumber], - clabeNumber = it[Customers.clabeNumber] + bankNumber = it[Customers.bankNumber], + bankBranchNumber = it[Customers.bankBranchNumber], + clabeNumber = it[Customers.clabeNumber], + idType = it[Customers.idType], + idCountryCode = it[Customers.idCountryCode], + idIssueDate = it[Customers.idIssueDate]?.let { ts -> Instant.ofEpochMilli(ts) }, + idExpirationDate = + it[Customers.idExpirationDate]?.let { ts -> Instant.ofEpochMilli(ts) }, + idNumber = it[Customers.idNumber] ) } } @@ -64,11 +77,19 @@ class JdbcCustomerRepository(private val db: Database) : CustomerRepository { memoType = it[Customers.memoType], firstName = it[Customers.firstName], lastName = it[Customers.lastName], + address = it[Customers.address], emailAddress = it[Customers.emailAddress], bankAccountNumber = it[Customers.bankAccountNumber], bankAccountType = it[Customers.bankAccountType], - bankRoutingNumber = it[Customers.bankRoutingNumber], - clabeNumber = it[Customers.clabeNumber] + bankNumber = it[Customers.bankNumber], + bankBranchNumber = it[Customers.bankBranchNumber], + clabeNumber = it[Customers.clabeNumber], + idType = it[Customers.idType], + idCountryCode = it[Customers.idCountryCode], + idIssueDate = it[Customers.idIssueDate]?.let { ts -> Instant.ofEpochMilli(ts) }, + idExpirationDate = + it[Customers.idExpirationDate]?.let { ts -> Instant.ofEpochMilli(ts) }, + idNumber = it[Customers.idNumber] ) } } @@ -84,11 +105,18 @@ class JdbcCustomerRepository(private val db: Database) : CustomerRepository { it[memoType] = customer.memoType it[firstName] = customer.firstName it[lastName] = customer.lastName + it[address] = customer.address it[emailAddress] = customer.emailAddress it[bankAccountNumber] = customer.bankAccountNumber it[bankAccountType] = customer.bankAccountType - it[bankRoutingNumber] = customer.bankRoutingNumber + it[bankNumber] = customer.bankNumber + it[bankAccountNumber] = customer.bankAccountNumber it[clabeNumber] = customer.clabeNumber + it[idType] = customer.idType + it[idCountryCode] = customer.idCountryCode + it[idIssueDate] = customer.idIssueDate?.toEpochMilli() + it[idExpirationDate] = customer.idExpirationDate?.toEpochMilli() + it[idNumber] = customer.idNumber } } .resultedValues @@ -103,11 +131,18 @@ class JdbcCustomerRepository(private val db: Database) : CustomerRepository { it[memoType] = customer.memoType it[firstName] = customer.firstName it[lastName] = customer.lastName + it[address] = customer.address it[emailAddress] = customer.emailAddress it[bankAccountNumber] = customer.bankAccountNumber it[bankAccountType] = customer.bankAccountType - it[bankRoutingNumber] = customer.bankRoutingNumber + it[bankNumber] = customer.bankNumber + it[bankBranchNumber] = customer.bankBranchNumber it[clabeNumber] = customer.clabeNumber + it[idType] = customer.idType + it[idCountryCode] = customer.idCountryCode + it[idIssueDate] = customer.idIssueDate?.toEpochMilli() + it[idExpirationDate] = customer.idExpirationDate?.toEpochMilli() + it[idNumber] = customer.idNumber } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/Customers.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/Customers.kt index 55e9e860d7..b50df062b4 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/Customers.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/dao/Customers.kt @@ -10,9 +10,16 @@ object Customers : Table() { val memoType = varchar("memo_type", 255).nullable() val firstName = varchar("first_name", 255).nullable() val lastName = varchar("last_name", 255).nullable() + val address = varchar("address", 255).nullable() val emailAddress = varchar("email_address", 255).nullable() val bankAccountNumber = varchar("bank_account_number", 255).nullable() val bankAccountType = varchar("bank_account_type", 255).nullable() - val bankRoutingNumber = varchar("bank_routing_number", 255).nullable() + val bankNumber = varchar("bank_number", 255).nullable() + val bankBranchNumber = varchar("bank_branch_number", 255).nullable() val clabeNumber = varchar("clabe_number", 255).nullable() + val idType = varchar("id_type", 255).nullable() + val idCountryCode = varchar("id_country_code", 255).nullable() + val idIssueDate = long("id_issue_date").nullable() + val idExpirationDate = long("id_expiration_date").nullable() + val idNumber = varchar("id_number", 255).nullable() } 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 d965d53a00..c1681f59f1 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 ) @@ -26,7 +27,8 @@ data class AppSettings( val distributionWalletMemoType: String, val custodyEnabled: Boolean, val rpcEnabled: Boolean, - val enableTest: Boolean + val enableTest: Boolean, + val secret: String ) data class AuthSettings( @@ -41,3 +43,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/data/Data.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Data.kt index 257c2f966e..4cc6c3f279 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Data.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Data.kt @@ -1,6 +1,5 @@ package org.stellar.reference.data -import io.ktor.server.application.* import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -65,10 +64,11 @@ sealed class RpcActionParamsRequest { data class RequestOffchainFundsRequest( @SerialName("transaction_id") override val transactionId: String, override val message: String, - @SerialName("amount_in") val amountIn: AmountAssetRequest, - @SerialName("amount_out") val amountOut: AmountAssetRequest, - @SerialName("amount_fee") val amountFee: AmountAssetRequest, - @SerialName("amount_expected") val amountExpected: AmountRequest? = null + @SerialName("amount_in") val amountIn: AmountAssetRequest? = null, + @SerialName("amount_out") val amountOut: AmountAssetRequest? = null, + @SerialName("amount_fee") val amountFee: AmountAssetRequest? = null, + @SerialName("amount_expected") val amountExpected: AmountRequest? = null, + @SerialName("instructions") val instructions: Map? = null ) : RpcActionParamsRequest() @Serializable @@ -81,11 +81,20 @@ data class RequestOnchainFundsRequest( @SerialName("amount_expected") val amountExpected: AmountRequest? = null ) : RpcActionParamsRequest() +@Serializable +data class RequestCustomerInfoUpdateHandler( + @SerialName("transaction_id") override val transactionId: String, + override val message: String?, + @SerialName("required_customer_info_message") val requiredCustomerInfoMessage: String? = null, + @SerialName("required_customer_info_updates") + val requiredCustomerInfoUpdates: List? = null +) : RpcActionParamsRequest() + @Serializable data class NotifyOnchainFundsSentRequest( @SerialName("transaction_id") override val transactionId: String, override val message: String? = null, - @SerialName("stellar_transaction_id") val stellarTransactionId: String? = null + @SerialName("stellar_transaction_id") val stellarTransactionId: String ) : RpcActionParamsRequest() @Serializable @@ -99,6 +108,28 @@ data class NotifyOffchainFundsReceivedRequest( @SerialName("amount_fee") val amountFee: AmountAssetRequest? = null ) : RpcActionParamsRequest() +@Serializable +data class NotifyOffchainFundsAvailableRequest( + @SerialName("transaction_id") override val transactionId: String, + override val message: String? = null, + @SerialName("external_transaction_id") val externalTransactionId: String? = null +) : RpcActionParamsRequest() + +@Serializable +data class NotifyOffchainFundsPendingRequest( + @SerialName("transaction_id") override val transactionId: String, + override val message: String? = null, + @SerialName("external_transaction_id") val externalTransactionId: String? = null +) : RpcActionParamsRequest() + +@Serializable +data class NotifyAmountsUpdatedRequest( + @SerialName("transaction_id") override val transactionId: String, + override val message: String? = null, + @SerialName("amount_out") val amountOut: AmountAssetRequest, + @SerialName("amount_fee") val amountFee: AmountAssetRequest +) : RpcActionParamsRequest() + @Serializable data class NotifyOffchainFundsSentRequest( @SerialName("transaction_id") override val transactionId: String, @@ -125,6 +156,8 @@ data class NotifyTransactionErrorRequest( @Serializable data class Amount(val amount: String? = null, val asset: String? = null) +@Serializable data class InstructionField(val value: String, val description: String) + class JwtToken( val transactionId: String, var expiration: Long, // Expiration Time 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/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..c116f4809c --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt @@ -0,0 +1,37 @@ +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.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 sep6EventProcessor = + Sep6EventProcessor( + config, + ServiceContainer.horizon, + ServiceContainer.platform, + ServiceContainer.customerService, + ServiceContainer.sepHelper + ) + 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..e1be7323b2 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt @@ -0,0 +1,106 @@ +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.plugins.testSep31 +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.sepHelper, + 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.appSettings.enableTest) { + testSep24( + ServiceContainer.sepHelper, + ServiceContainer.depositService, + ServiceContainer.withdrawalService, + config.sep24.interactiveJwtKey + ) + testSep31(ServiceContainer.receiveService) + } + 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..767ce050ed --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt @@ -0,0 +1,53 @@ +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.WithdrawalService +import org.stellar.reference.service.SepHelper +import org.stellar.reference.service.sep31.ReceiveService +import org.stellar.sdk.Server + +object ServiceContainer { + private val config = ConfigContainer.getInstance().config + val eventService = EventService() + val sepHelper = SepHelper(config) + val depositService = DepositService(config) + val withdrawalService = WithdrawalService(config) + val receiveService = ReceiveService(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..9b5c81af94 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventConsumer.kt @@ -0,0 +1,40 @@ +package org.stellar.reference.event + +import java.time.Duration +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.apache.kafka.common.errors.WakeupException +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(): EventConsumer { + try { + 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) + ) + } + } + } + } catch (e: WakeupException) { + // ignore for shutdown + } finally { + consumer.close() + } + + return this + } + + fun stop() { + consumer.wakeup() + } +} 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 a7b1ab256d..a85641f1e2 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 @@ -28,7 +28,7 @@ class EventService { if (txnId != null) { // filter events with txnId return receivedEvents.filter { - it.type != AnchorEvent.Type.QUOTE_CREATED.type && it.payload.transaction.id == txnId + it.type != AnchorEvent.Type.QUOTE_CREATED.type && it.payload.transaction?.id == txnId } } // return all events 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..1a3ccf0c80 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt @@ -0,0 +1,353 @@ +package org.stellar.reference.event.processor + +import java.time.Instant +import java.util.* +import kotlinx.coroutines.runBlocking +import org.stellar.anchor.api.callback.GetCustomerRequest +import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.anchor.api.platform.* +import org.stellar.anchor.api.platform.PatchTransactionsRequest +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind +import org.stellar.anchor.api.sep.SepTransactionStatus.* +import org.stellar.reference.callbacks.customer.CustomerService +import org.stellar.reference.client.PlatformClient +import org.stellar.reference.data.* +import org.stellar.reference.log +import org.stellar.reference.service.SepHelper +import org.stellar.sdk.* + +class Sep6EventProcessor( + private val config: Config, + private val server: Server, + private val platformClient: PlatformClient, + private val customerService: CustomerService, + private val sepHelper: SepHelper, + /** Map of transaction ID to Stellar transaction ID. */ + private val onchainPayments: MutableMap = mutableMapOf(), + /** Map of transaction ID to external transaction ID. */ + private val offchainPayments: MutableMap = mutableMapOf() +) : SepAnchorEventProcessor { + companion object { + val requiredKyc = + listOf("id_type", "id_country_code", "id_issue_date", "id_expiration_date", "id_number") + val depositRequiredKyc = listOf("address") + val withdrawRequiredKyc = + listOf("bank_account_number", "bank_account_type", "bank_number", "bank_branch_number") + } + + override fun onQuoteCreated(event: AnchorEvent) { + TODO("Not yet implemented") + } + + override fun onTransactionCreated(event: AnchorEvent) { + when (val kind = event.transaction.kind) { + Kind.DEPOSIT, + Kind.WITHDRAWAL -> updateAmounts(event) + else -> { + log.warn("Received transaction created event with unsupported kind: $kind") + } + } + } + + override fun onTransactionError(event: AnchorEvent) { + log.warn("Received transaction error event: $event") + } + + override fun onTransactionStatusChanged(event: AnchorEvent) { + when (val kind = event.transaction.kind) { + Kind.DEPOSIT -> onDepositTransactionStatusChanged(event) + 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) { + PENDING_ANCHOR -> { + val customer = transaction.customers.sender + if (verifyKyc(customer.account, customer.memo, Kind.DEPOSIT).isNotEmpty()) { + requestKyc(event) + return + } + runBlocking { + val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) + val txnId = + submitStellarTransaction( + keypair.accountId, + transaction.destinationAccount, + Asset.create(transaction.amountExpected.asset.toAssetId()), + transaction.amountExpected.amount + ) + onchainPayments[transaction.id] = txnId + // TODO: manually submit the transaction until custody service is implemented + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .status(PENDING_STELLAR) + .updatedAt(Instant.now()) + .build() + ) + } + } + PENDING_USR_TRANSFER_START -> + runBlocking { + sepHelper.rpcAction( + "notify_offchain_funds_received", + NotifyOffchainFundsReceivedRequest( + transactionId = transaction.id, + message = "Funds received from user", + ) + ) + } + PENDING_STELLAR -> + runBlocking { + sepHelper.rpcAction( + "notify_onchain_funds_sent", + NotifyOnchainFundsSentRequest( + transactionId = transaction.id, + message = "Funds sent to user", + stellarTransactionId = onchainPayments[transaction.id]!! + ) + ) + } + COMPLETED -> { + log.info("Transaction ${transaction.id} completed") + } + else -> { + log.warn("Received transaction status changed event with unsupported status: $status") + } + } + } + + private fun onWithdrawTransactionStatusChanged(event: AnchorEvent) { + val transaction = event.transaction + when (val status = transaction.status) { + PENDING_ANCHOR -> { + val customer = transaction.customers.sender + if (verifyKyc(customer.account, customer.memo, Kind.WITHDRAWAL).isNotEmpty()) { + requestKyc(event) + return + } + runBlocking { + if (offchainPayments[transaction.id] == null) { + val externalTxnId = UUID.randomUUID() + offchainPayments[transaction.id] = externalTxnId.toString() + sepHelper.rpcAction( + "notify_offchain_funds_pending", + NotifyOffchainFundsPendingRequest( + transactionId = transaction.id, + message = "Funds sent to user", + externalTransactionId = externalTxnId.toString() + ) + ) + } else { + sepHelper.rpcAction( + "notify_offchain_funds_available", + NotifyOffchainFundsAvailableRequest( + transactionId = transaction.id, + message = "Funds available for withdrawal", + externalTransactionId = offchainPayments[transaction.id]!! + ) + ) + } + } + } + PENDING_EXTERNAL -> + runBlocking { + sepHelper.rpcAction( + "notify_offchain_funds_sent", + NotifyOffchainFundsSentRequest( + transactionId = transaction.id, + message = "Funds sent to user", + ) + ) + } + COMPLETED -> { + log.info("Transaction ${transaction.id} completed") + } + else -> { + log.warn("Received transaction status changed event with unsupported status: $status") + } + } + } + + override fun onCustomerUpdated(event: AnchorEvent) { + runBlocking { + platformClient + .getTransactions( + GetTransactionsRequest.builder() + .sep(TransactionsSeps.SEP_6) + .orderBy(TransactionsOrderBy.CREATED_AT) + .order(TransactionsOrder.ASC) + .statuses(listOf(PENDING_CUSTOMER_INFO_UPDATE)) + .build() + ) + .records + } + .forEach { transaction -> + val customer = transaction.customers.sender + when (transaction.kind) { + Kind.DEPOSIT -> { + if (verifyKyc(customer.account, customer.memo, Kind.DEPOSIT).isNotEmpty()) { + return + } + runBlocking { + sepHelper.rpcAction( + "request_offchain_funds", + RequestOffchainFundsRequest( + transactionId = transaction.id, + message = "Please deposit the amount to the following bank account", + amountIn = + AmountAssetRequest( + asset = "iso4217:USD", + amount = transaction.amountExpected.amount + ), + amountOut = + AmountAssetRequest( + asset = transaction.amountOut.asset, + amount = transaction.amountOut.amount + ), + amountFee = AmountAssetRequest(asset = "iso4217:USD", amount = "0"), + instructions = + mapOf( + "organization.bank_number" to + InstructionField( + value = "121122676", + description = "US Bank routing number" + ), + "organization.bank_account_number" to + InstructionField( + value = "13719713158835300", + description = "US Bank account number" + ), + ) + ) + ) + } + } + Kind.WITHDRAWAL -> { + if (verifyKyc(customer.account, customer.memo, Kind.WITHDRAWAL).isNotEmpty()) { + return + } + runBlocking { + sepHelper.rpcAction( + "request_onchain_funds", + RequestOnchainFundsRequest( + transactionId = transaction.id, + message = "Please deposit the amount to the following address", + amountIn = + AmountAssetRequest( + asset = transaction.amountExpected.asset, + amount = transaction.amountExpected.amount + ), + amountOut = + AmountAssetRequest( + asset = "iso4217:USD", + amount = transaction.amountExpected.amount + ), + amountFee = + AmountAssetRequest(asset = transaction.amountExpected.asset, amount = "0") + ) + ) + } + } + else -> { + log.warn( + "Received transaction created event with unsupported kind: ${transaction.kind}" + ) + } + } + } + } + + private fun verifyKyc(sep10Account: String, sep10AccountMemo: String?, kind: Kind): List { + val customer = + customerService.getCustomer( + GetCustomerRequest.builder().account(sep10Account).memo(sep10AccountMemo).build() + ) + val providedFields = customer.providedFields.keys + return requiredKyc + .plus(if (kind == Kind.DEPOSIT) depositRequiredKyc else withdrawRequiredKyc) + .filter { !providedFields.contains(it) } + } + + private fun updateAmounts(event: AnchorEvent) { + val asset = + when (event.transaction.kind) { + Kind.DEPOSIT -> event.transaction.amountExpected.asset + Kind.WITHDRAWAL -> "iso4217:USD" + else -> throw RuntimeException("Unsupported kind: ${event.transaction.kind}") + } + runBlocking { + sepHelper.rpcAction( + "notify_amounts_updated", + NotifyAmountsUpdatedRequest( + transactionId = event.transaction.id, + amountOut = AmountAssetRequest(asset, amount = event.transaction.amountExpected.amount), + amountFee = AmountAssetRequest(asset, amount = "0") + ) + ) + } + } + + private fun requestKyc(event: AnchorEvent) { + val kind = event.transaction.kind + val customer = event.transaction.customers.sender + val missingFields = verifyKyc(customer.account, customer.memo, kind) + runBlocking { + if (missingFields.isNotEmpty()) { + sepHelper.rpcAction( + "request_customer_info_update", + RequestCustomerInfoUpdateHandler( + transactionId = event.transaction.id, + message = "Please update your info", + requiredCustomerInfoUpdates = missingFields + ) + ) + } + } + } + + 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 + ): String { + // 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}") + } + + return txnResponse.hash + } + + 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/model/Customer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/model/Customer.kt index 5ca6878309..901b6170d3 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/model/Customer.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/model/Customer.kt @@ -1,5 +1,7 @@ package org.stellar.reference.model +import java.time.Instant +import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,11 +13,18 @@ data class Customer( val memoType: String? = null, @SerialName("first_name") val firstName: String? = null, @SerialName("last_name") val lastName: String? = null, + val address: String? = null, @SerialName("email_address") val emailAddress: String? = null, @SerialName("bank_account_number") val bankAccountNumber: String? = null, @SerialName("bank_account_type") val bankAccountType: String? = null, - @SerialName("bank_routing_number") val bankRoutingNumber: String? = null, + @SerialName("bank_number") val bankNumber: String? = null, + @SerialName("bank_branch_number") val bankBranchNumber: String? = null, @SerialName("clabe_number") val clabeNumber: String? = null, + @SerialName("id_type") val idType: String? = null, + @SerialName("id_country_code") val idCountryCode: String? = null, + @SerialName("id_issue_date") @Contextual val idIssueDate: Instant? = null, + @SerialName("id_expiration_date") @Contextual val idExpirationDate: Instant? = null, + @SerialName("id_number") val idNumber: String? = null, ) enum class Status { 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 index b6be1df31b..8b13789179 100644 --- 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 @@ -1,66 +1 @@ -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.WithdrawalService -import org.stellar.reference.sep24.sep24 -import org.stellar.reference.sep24.testSep24 -import org.stellar.reference.service.SepHelper -import org.stellar.reference.service.sep31.ReceiveService - -fun Application.configureRouting(cfg: Config) = routing { - val helper = SepHelper(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) - val receiveService = ReceiveService(cfg) - - sep24(helper, depositService, withdrawalService, cfg.sep24.interactiveJwtKey) - event(eventService) - customer(customerService) - fee(feeService) - rate(rateService) - uniqueAddress(uniqueAddressService) - sep24Interactive() - - if (cfg.appSettings.enableTest) { - testSep24(helper, depositService, withdrawalService, cfg.sep24.interactiveJwtKey) - testSep31(receiveService) - } - if (cfg.appSettings.isTest) { - testCustomer(customerService) - } - 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 b5379ce760..bc01bdf1ee 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 # When true, enables test endpoints enableTest: true # Indicates, that custody integration is enabled @@ -39,6 +40,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/eventprocessor/EventProcessorBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/eventprocessor/EventProcessorBeans.java index d85029d523..d273f2e00f 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 @@ -12,6 +12,7 @@ import org.stellar.anchor.sep24.MoreInfoUrlConstructor; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; @Configuration public class EventProcessorBeans { @@ -24,6 +25,7 @@ EventProcessorManager eventProcessorManager( PropertyClientsConfig clientsConfig, EventService eventService, AssetService assetService, + Sep6TransactionStore sep6TransactionStore, Sep24TransactionStore sep24TransactionStore, Sep31TransactionStore sep31TransactionStore, MoreInfoUrlConstructor moreInfoUrlConstructor) { @@ -34,6 +36,7 @@ EventProcessorManager eventProcessorManager( clientsConfig, eventService, assetService, + sep6TransactionStore, sep24TransactionStore, sep31TransactionStore, moreInfoUrlConstructor); 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 9c7b5f5c27..c559ab2ac3 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 @@ -14,6 +14,7 @@ import org.stellar.anchor.platform.config.RpcConfig; 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; @@ -88,9 +89,14 @@ public StellarPaymentObserver stellarPaymentObserver( public PaymentOperationToEventListener paymentOperationToEventListener( JdbcSep31TransactionStore sep31TransactionStore, JdbcSep24TransactionStore sep24TransactionStore, + JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig) { return new PaymentOperationToEventListener( - sep31TransactionStore, sep24TransactionStore, platformApiClient, rpcConfig); + sep31TransactionStore, + sep24TransactionStore, + sep6TransactionStore, + platformApiClient, + rpcConfig); } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/platform/PlatformServerBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/platform/PlatformServerBeans.java index 3c87df8549..4824ea6770 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/platform/PlatformServerBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/platform/PlatformServerBeans.java @@ -10,6 +10,7 @@ import org.stellar.anchor.auth.JwtService; import org.stellar.anchor.config.CustodyConfig; import org.stellar.anchor.config.Sep24Config; +import org.stellar.anchor.config.Sep6Config; import org.stellar.anchor.custody.CustodyService; import org.stellar.anchor.event.EventService; import org.stellar.anchor.filter.ApiKeyFilter; @@ -22,14 +23,12 @@ import org.stellar.anchor.platform.data.JdbcTransactionPendingTrustRepo; import org.stellar.anchor.platform.job.TrustlineCheckJob; import org.stellar.anchor.platform.rpc.NotifyTrustSetHandler; -import org.stellar.anchor.platform.service.Sep24DepositInfoCustodyGenerator; -import org.stellar.anchor.platform.service.Sep24DepositInfoNoneGenerator; -import org.stellar.anchor.platform.service.Sep24DepositInfoSelfGenerator; -import org.stellar.anchor.platform.service.TransactionService; +import org.stellar.anchor.platform.service.*; import org.stellar.anchor.sep24.Sep24DepositInfoGenerator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; import org.stellar.anchor.sep38.Sep38QuoteStore; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; import org.stellar.anchor.sep6.Sep6TransactionStore; @Configuration @@ -83,6 +82,25 @@ Sep24DepositInfoGenerator sep24DepositInfoGenerator( } } + @Bean + Sep6DepositInfoGenerator sep6DepositInfoGenerator( + Sep6Config sep6Config, AssetService assetService, Optional custodyApiClient) + throws InvalidConfigException { + switch (sep6Config.getDepositInfoGeneratorType()) { + case SELF: + return new Sep6DepositInfoSelfGenerator(assetService); + case CUSTODY: + return new Sep6DepositInfoCustodyGenerator( + custodyApiClient.orElseThrow( + () -> + new InvalidConfigException("Integration with custody service is not enabled"))); + case NONE: + return new Sep6DepositInfoNoneGenerator(); + default: + throw new RuntimeException("Not supported"); + } + } + @Bean TransactionService transactionService( Sep6TransactionStore txn6Store, @@ -91,6 +109,7 @@ TransactionService transactionService( Sep38QuoteStore quoteStore, AssetService assetService, EventService eventService, + Sep6DepositInfoGenerator sep6DepositInfoGenerator, Sep24DepositInfoGenerator sep24DepositInfoGenerator, CustodyService custodyService, CustodyConfig custodyConfig) { @@ -101,6 +120,7 @@ TransactionService transactionService( quoteStore, assetService, eventService, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, custodyService, custodyConfig); diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/platform/RpcActionBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/platform/RpcActionBeans.java index 572f17b289..2d2b632be2 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/platform/RpcActionBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/platform/RpcActionBeans.java @@ -39,6 +39,8 @@ import org.stellar.anchor.sep24.Sep24DepositInfoGenerator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6TransactionStore; @Configuration public class RpcActionBeans { @@ -50,6 +52,7 @@ RpcService rpcService(List> rpcMethodHandlers, RpcConfig rpc @Bean DoStellarPaymentHandler doStellarPaymentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -61,6 +64,7 @@ DoStellarPaymentHandler doStellarPaymentHandler( MetricsService metricsService, JdbcTransactionPendingTrustRepo transactionPendingTrustRepo) { return new DoStellarPaymentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -75,6 +79,7 @@ DoStellarPaymentHandler doStellarPaymentHandler( @Bean DoStellarRefundHandler doStellarRefundHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -84,6 +89,7 @@ DoStellarRefundHandler doStellarRefundHandler( EventService eventService, MetricsService metricsService) { return new DoStellarRefundHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -96,6 +102,7 @@ DoStellarRefundHandler doStellarRefundHandler( @Bean NotifyAmountsUpdatedHandler notifyAmountsUpdatedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -103,11 +110,18 @@ NotifyAmountsUpdatedHandler notifyAmountsUpdatedHandler( EventService eventService, MetricsService metricsService) { return new NotifyAmountsUpdatedHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyInteractiveFlowCompletedHandler notifyInteractiveFlowCompletedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -115,11 +129,18 @@ NotifyInteractiveFlowCompletedHandler notifyInteractiveFlowCompletedHandler( EventService eventService, MetricsService metricsService) { return new NotifyInteractiveFlowCompletedHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyOffchainFundsAvailableHandler notifyOffchainFundsAvailableHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -127,11 +148,18 @@ NotifyOffchainFundsAvailableHandler notifyOffchainFundsAvailableHandler( EventService eventService, MetricsService metricsService) { return new NotifyOffchainFundsAvailableHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyOffchainFundsPendingHandler notifyOffchainFundsPendingHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -139,11 +167,18 @@ NotifyOffchainFundsPendingHandler notifyOffchainFundsPendingHandler( EventService eventService, MetricsService metricsService) { return new NotifyOffchainFundsPendingHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyOffchainFundsReceivedHandler notifyOffchainFundsReceivedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -153,6 +188,7 @@ NotifyOffchainFundsReceivedHandler notifyOffchainFundsReceivedHandler( EventService eventService, MetricsService metricsService) { return new NotifyOffchainFundsReceivedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -165,6 +201,7 @@ NotifyOffchainFundsReceivedHandler notifyOffchainFundsReceivedHandler( @Bean NotifyOffchainFundsSentHandler notifyOffchainFundsSentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -172,11 +209,18 @@ NotifyOffchainFundsSentHandler notifyOffchainFundsSentHandler( EventService eventService, MetricsService metricsService) { return new NotifyOffchainFundsSentHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyOnchainFundsReceivedHandler notifyOnchainFundsReceivedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -185,6 +229,7 @@ NotifyOnchainFundsReceivedHandler notifyOnchainFundsReceivedHandler( EventService eventService, MetricsService metricsService) { return new NotifyOnchainFundsReceivedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -196,6 +241,7 @@ NotifyOnchainFundsReceivedHandler notifyOnchainFundsReceivedHandler( @Bean NotifyOnchainFundsSentHandler notifyOnchainFundsSentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -204,6 +250,7 @@ NotifyOnchainFundsSentHandler notifyOnchainFundsSentHandler( EventService eventService, MetricsService metricsService) { return new NotifyOnchainFundsSentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -215,6 +262,7 @@ NotifyOnchainFundsSentHandler notifyOnchainFundsSentHandler( @Bean NotifyRefundPendingHandler notifyRefundPendingHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -222,11 +270,18 @@ NotifyRefundPendingHandler notifyRefundPendingHandler( EventService eventService, MetricsService metricsService) { return new NotifyRefundPendingHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyRefundSentHandler notifyRefundSentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -234,11 +289,18 @@ NotifyRefundSentHandler notifyRefundSentHandler( EventService eventService, MetricsService metricsService) { return new NotifyRefundSentHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyTransactionErrorHandler notifyTransactionErrorHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -247,6 +309,7 @@ NotifyTransactionErrorHandler notifyTransactionErrorHandler( MetricsService metricsService, JdbcTransactionPendingTrustRepo transactionPendingTrustRepo) { return new NotifyTransactionErrorHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -258,6 +321,7 @@ NotifyTransactionErrorHandler notifyTransactionErrorHandler( @Bean NotifyTransactionExpiredHandler notifyTransactionExpiredHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -266,6 +330,7 @@ NotifyTransactionExpiredHandler notifyTransactionExpiredHandler( MetricsService metricsService, JdbcTransactionPendingTrustRepo transactionPendingTrustRepo) { return new NotifyTransactionExpiredHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -277,6 +342,7 @@ NotifyTransactionExpiredHandler notifyTransactionExpiredHandler( @Bean NotifyTransactionRecoveryHandler notifyTransactionRecoveryHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -284,11 +350,18 @@ NotifyTransactionRecoveryHandler notifyTransactionRecoveryHandler( EventService eventService, MetricsService metricsService) { return new NotifyTransactionRecoveryHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyTrustSetHandler notifyTrustSetHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -298,6 +371,7 @@ NotifyTrustSetHandler notifyTrustSetHandler( PropertyCustodyConfig custodyConfig, CustodyService custodyService) { return new NotifyTrustSetHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -310,6 +384,7 @@ NotifyTrustSetHandler notifyTrustSetHandler( @Bean RequestCustomerInfoUpdateHandler requestCustomerInfoUpdateHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -317,11 +392,18 @@ RequestCustomerInfoUpdateHandler requestCustomerInfoUpdateHandler( EventService eventService, MetricsService metricsService) { return new RequestCustomerInfoUpdateHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean NotifyCustomerInfoUpdatedHandler notifyCustomerInfoUpdatedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -329,11 +411,18 @@ NotifyCustomerInfoUpdatedHandler notifyCustomerInfoUpdatedHandler( EventService eventService, MetricsService metricsService) { return new NotifyCustomerInfoUpdatedHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean RequestOffchainFundsHandler requestOffchainFundsHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -341,27 +430,37 @@ RequestOffchainFundsHandler requestOffchainFundsHandler( EventService eventService, MetricsService metricsService) { return new RequestOffchainFundsHandler( - txn24Store, txn31Store, requestValidator, assetService, eventService, metricsService); + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + eventService, + metricsService); } @Bean RequestOnchainFundsHandler requestOnchainFundsHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, AssetService assetService, CustodyService custodyService, CustodyConfig custodyConfig, + Sep6DepositInfoGenerator sep6DepositInfoGenerator, Sep24DepositInfoGenerator sep24DepositInfoGenerator, EventService eventService, MetricsService metricsService) { return new RequestOnchainFundsHandler( + txn6Store, txn24Store, txn31Store, requestValidator, assetService, custodyService, custodyConfig, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, eventService, metricsService); @@ -369,6 +468,7 @@ RequestOnchainFundsHandler requestOnchainFundsHandler( @Bean RequestTrustlineHandler requestTrustHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -377,6 +477,7 @@ RequestTrustlineHandler requestTrustHandler( EventService eventService, MetricsService metricsService) { return new RequestTrustlineHandler( + txn6Store, txn24Store, txn31Store, requestValidator, 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 0e2ae42b3b..9a1ea1ba9d 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 @@ -14,6 +14,7 @@ import org.stellar.anchor.api.exception.InvalidConfigException; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.auth.JwtService; +import org.stellar.anchor.client.ClientFinder; import org.stellar.anchor.config.*; import org.stellar.anchor.custody.CustodyService; import org.stellar.anchor.event.EventService; @@ -39,6 +40,8 @@ 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.RequestValidator; import org.stellar.anchor.sep6.Sep6Service; import org.stellar.anchor.sep6.Sep6TransactionStore; @@ -55,12 +58,6 @@ Sep1Config sep1Config() { return new PropertySep1Config(); } - @Bean - @ConfigurationProperties(prefix = "sep6") - Sep6Config sep6Config() { - return new PropertySep6Config(); - } - @Bean @ConfigurationProperties(prefix = "sep10") Sep10Config sep10Config( @@ -96,6 +93,10 @@ public FilterRegistrationBean sep10TokenFilter( JwtService jwtService, Sep38Config sep38Config) { 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"); registrationBean.addUrlPatterns("/sep6/transactions*"); registrationBean.addUrlPatterns("/sep6/transactions/*"); @@ -121,11 +122,30 @@ Sep1Service sep1Service(Sep1Config sep1Config) throws IOException, InvalidConfig return new Sep1Service(sep1Config); } + @Bean + @ConditionalOnAllSepsEnabled(seps = {"sep6", "sep24"}) + ClientFinder clientFinder(Sep10Config sep10Config, ClientsConfig clientsConfig) { + return new ClientFinder(sep10Config, clientsConfig); + } + @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, + Sep38QuoteStore sep38QuoteStore) { + RequestValidator requestValidator = new RequestValidator(assetService); + ExchangeAmountsCalculator exchangeAmountsCalculator = + new ExchangeAmountsCalculator(sep38QuoteStore); + return new Sep6Service( + sep6Config, + assetService, + requestValidator, + txnStore, + exchangeAmountsCalculator, + eventService); } @Bean @@ -141,8 +161,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/component/share/UtilityBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/share/UtilityBeans.java index b55e5850cc..16696fb538 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/share/UtilityBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/share/UtilityBeans.java @@ -9,16 +9,10 @@ import org.springframework.context.annotation.DependsOn; import org.stellar.anchor.api.exception.NotSupportedException; import org.stellar.anchor.auth.JwtService; -import org.stellar.anchor.config.AppConfig; -import org.stellar.anchor.config.CustodyConfig; -import org.stellar.anchor.config.CustodySecretConfig; -import org.stellar.anchor.config.SecretConfig; +import org.stellar.anchor.config.*; import org.stellar.anchor.healthcheck.HealthCheckable; import org.stellar.anchor.horizon.Horizon; -import org.stellar.anchor.platform.config.PropertyAppConfig; -import org.stellar.anchor.platform.config.PropertyClientsConfig; -import org.stellar.anchor.platform.config.PropertySecretConfig; -import org.stellar.anchor.platform.config.PropertySep24Config; +import org.stellar.anchor.platform.config.*; import org.stellar.anchor.platform.service.HealthCheckService; import org.stellar.anchor.platform.service.SimpleMoreInfoUrlConstructor; import org.stellar.anchor.platform.validator.RequestValidator; @@ -57,6 +51,12 @@ PropertySep24Config sep24Config(SecretConfig secretConfig, CustodyConfig custody return new PropertySep24Config(secretConfig, custodyConfig); } + @Bean + @ConfigurationProperties(prefix = "sep6") + PropertySep6Config sep6Config(CustodyConfig custodyConfig) { + return new PropertySep6Config(custodyConfig); + } + /********************************** * Secret configurations */ diff --git a/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep6Config.java b/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep6Config.java index d195969cb8..ea2db86a95 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep6Config.java +++ b/platform/src/main/java/org/stellar/anchor/platform/config/PropertySep6Config.java @@ -1,17 +1,23 @@ package org.stellar.anchor.platform.config; +import static org.stellar.anchor.config.Sep6Config.DepositInfoGeneratorType.CUSTODY; + import lombok.*; import org.springframework.validation.Errors; import org.springframework.validation.Validator; +import org.stellar.anchor.config.CustodyConfig; import org.stellar.anchor.config.Sep6Config; -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor +@Data public class PropertySep6Config implements Sep6Config, Validator { boolean enabled; Features features; + DepositInfoGeneratorType depositInfoGeneratorType; + CustodyConfig custodyConfig; + + public PropertySep6Config(CustodyConfig custodyConfig) { + this.custodyConfig = custodyConfig; + } @Override public boolean supports(@NonNull Class clazz) { @@ -34,6 +40,24 @@ public void validate(@NonNull Object target, @NonNull Errors errors) { "sep6-features-claimable-balances-invalid", "sep6.features.claimable_balances: claimable balances are not supported"); } + validateDepositInfoGeneratorType(errors); + } + } + + void validateDepositInfoGeneratorType(Errors errors) { + if (custodyConfig.isCustodyIntegrationEnabled() && CUSTODY != depositInfoGeneratorType) { + errors.rejectValue( + "depositInfoGeneratorType", + "sep6-deposit-info-generator-type", + String.format( + "[%s] deposit info generator type is not supported when custody integration is enabled", + depositInfoGeneratorType.toString().toLowerCase())); + } else if (!custodyConfig.isCustodyIntegrationEnabled() + && CUSTODY == depositInfoGeneratorType) { + errors.rejectValue( + "depositInfoGeneratorType", + "sep6-deposit-info-generator-type", + "[custody] deposit info generator type is not supported when custody integration is disabled"); } } } 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 554a478fcf..fe037fd19a 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 @@ -14,6 +14,7 @@ import org.stellar.anchor.api.exception.custody.CustodyNotFoundException; import org.stellar.anchor.api.exception.custody.CustodyServiceUnavailableException; import org.stellar.anchor.api.exception.custody.CustodyTooManyRequestsException; +import org.stellar.anchor.api.sep.CustomerInfoNeededResponse; import org.stellar.anchor.api.sep.SepExceptionResponse; public abstract class AbstractControllerExceptionHandler { @@ -46,6 +47,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) { 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 2124a4d2f4..00e2672b1a 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 @@ -18,9 +18,9 @@ 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.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.custody.CustodyService; import org.stellar.anchor.platform.service.TransactionService; import org.stellar.anchor.util.TransactionsParams; 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..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 @@ -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,142 @@ public InfoResponse getInfo() { return sep6Service.getInfo(); } + @CrossOrigin(origins = "*") + @RequestMapping( + value = "/deposit", + method = {RequestMethod.GET}) + public StartDepositResponse 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", 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", required = false) 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); + StartDepositRequest startDepositRequest = + StartDepositRequest.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, 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", + method = {RequestMethod.GET}) + public StartWithdrawResponse withdraw( + HttpServletRequest request, + @RequestParam(value = "asset_code") String assetCode, + @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) + throws AnchorException { + debugF("GET /withdraw"); + Sep10Jwt token = getSep10Token(request); + StartWithdrawRequest startWithdrawRequest = + StartWithdrawRequest.builder() + .assetCode(assetCode) + .type(type) + .amount(amount) + .countryCode(countryCode) + .refundMemo(refundMemo) + .refundMemoType(refundMemoType) + .build(); + 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 = "*") @RequestMapping( value = "/transactions", @@ -38,6 +175,7 @@ public InfoResponse getInfo() { 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, @@ -52,10 +190,11 @@ public GetTransactionsResponse getTransactions( pagingId, noOlderThan, lang); - Sep10Jwt token = Sep10Helper.getSep10Token(request); + Sep10Jwt token = getSep10Token(request); GetTransactionsRequest getTransactionsRequest = GetTransactionsRequest.builder() .assetCode(assetCode) + .account(account) .kind(kind) .limit(limit) .pagingId(pagingId) @@ -83,7 +222,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/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 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..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 @@ -1,17 +1,27 @@ 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 { - Sep6Transaction findOneByTransactionId(String transactionId); - Sep6Transaction findOneByStellarTransactionId(String stellarTransactionId); + @NotNull + Optional findById(@NonNull String id); - Sep6Transaction findOneByExternalTransactionId(String externalTransactionId); + JdbcSep6Transaction findOneByTransactionId(String transactionId); + + JdbcSep6Transaction findOneByStellarTransactionId(String stellarTransactionId); + + JdbcSep6Transaction findOneByExternalTransactionId(String externalTransactionId); + + 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 69b3c17aa5..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 @@ -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,13 +99,26 @@ 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 public List findTransactions(TransactionsParams params) { return transactionRepo.findAllTransactions(params, JdbcSep6Transaction.class); } + + @Override + 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/event/ClientStatusCallbackHandler.java b/platform/src/main/java/org/stellar/anchor/platform/event/ClientStatusCallbackHandler.java index be5a082e2f..0f0b15412e 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 @@ -34,6 +34,9 @@ import org.stellar.anchor.sep31.RefundPayment; import org.stellar.anchor.sep31.Sep31Refunds; import org.stellar.anchor.sep31.Sep31Transaction; +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 { @@ -46,18 +49,21 @@ public class ClientStatusCallbackHandler extends EventHandler { .build(); private final SecretConfig secretConfig; private final ClientConfig clientConfig; + private final Sep6TransactionStore sep6TransactionStore; private final AssetService assetService; private final MoreInfoUrlConstructor moreInfoUrlConstructor; public ClientStatusCallbackHandler( SecretConfig secretConfig, ClientConfig clientConfig, + Sep6TransactionStore sep6TransactionStore, AssetService assetService, MoreInfoUrlConstructor moreInfoUrlConstructor) { super(); this.secretConfig = secretConfig; this.clientConfig = clientConfig; this.assetService = assetService; + this.sep6TransactionStore = sep6TransactionStore; this.moreInfoUrlConstructor = moreInfoUrlConstructor; } @@ -105,6 +111,14 @@ public static Request buildHttpRequest(KeyPair signer, String payload, String ur private String getPayload(AnchorEvent event) throws AnchorException, MalformedURLException, URISyntaxException { switch (event.getTransaction().getSep()) { + case SEP_6: + // TODO: remove dependence on the transaction store + Sep6Transaction sep6Txn = + sep6TransactionStore.findByTransactionId(event.getTransaction().getId()); + org.stellar.anchor.api.sep.sep6.GetTransactionResponse sep6TxnRes = + new org.stellar.anchor.api.sep.sep6.GetTransactionResponse( + Sep6TransactionUtils.fromTxn(sep6Txn)); + return json(sep6TxnRes); case SEP_24: Sep24Transaction sep24Txn = fromSep24Txn(event.getTransaction()); Sep24GetTransactionResponse txn24Response = @@ -114,7 +128,8 @@ private String getPayload(AnchorEvent event) Sep31Transaction sep31Txn = fromSep31Txn(event.getTransaction()); return json(sep31Txn.toSep31GetTransactionResponse()); default: - throw new SepException("Only SEP-24 and SEP-31 are supported"); + throw new SepException( + String.format("Unsupported SEP: %s", event.getTransaction().getSep())); } } 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 213d05d9fd..1d06a21c84 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 @@ -32,6 +32,7 @@ import org.stellar.anchor.sep24.MoreInfoUrlConstructor; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.ExponentialBackoffTimer; import org.stellar.anchor.util.Log; @@ -45,6 +46,7 @@ public class EventProcessorManager { private final PropertyClientsConfig clientsConfig; private final EventService eventService; private final AssetService assetService; + private final Sep6TransactionStore sep6TransactionStore; private final Sep24TransactionStore sep24TransactionStore; private final Sep31TransactionStore sep31TransactionStore; private final MoreInfoUrlConstructor moreInfoUrlConstructor; @@ -58,6 +60,7 @@ public EventProcessorManager( PropertyClientsConfig clientsConfig, EventService eventService, AssetService assetService, + Sep6TransactionStore sep6TransactionStore, Sep24TransactionStore sep24TransactionStore, Sep31TransactionStore sep31TransactionStore, MoreInfoUrlConstructor moreInfoUrlConstructor) { @@ -67,6 +70,7 @@ public EventProcessorManager( this.clientsConfig = clientsConfig; this.eventService = eventService; this.assetService = assetService; + this.sep6TransactionStore = sep6TransactionStore; this.sep24TransactionStore = sep24TransactionStore; this.sep31TransactionStore = sep31TransactionStore; this.moreInfoUrlConstructor = moreInfoUrlConstructor; @@ -114,7 +118,11 @@ public void start() { processorName, EventQueue.TRANSACTION, new ClientStatusCallbackHandler( - secretConfig, clientConfig, assetService, moreInfoUrlConstructor), + secretConfig, + clientConfig, + sep6TransactionStore, + assetService, + moreInfoUrlConstructor), eventService)); } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarPaymentHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarPaymentHandler.java index 946ac0ae25..cfcbabf348 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarPaymentHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarPaymentHandler.java @@ -2,12 +2,15 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.DO_STELLAR_PAYMENT; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_STELLAR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_TRUST; +import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.time.Instant; import java.util.Set; @@ -26,13 +29,11 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.horizon.Horizon; import org.stellar.anchor.metrics.MetricsService; -import org.stellar.anchor.platform.data.JdbcSep24Transaction; -import org.stellar.anchor.platform.data.JdbcSepTransaction; -import org.stellar.anchor.platform.data.JdbcTransactionPendingTrust; -import org.stellar.anchor.platform.data.JdbcTransactionPendingTrustRepo; +import org.stellar.anchor.platform.data.*; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class DoStellarPaymentHandler extends RpcMethodHandler { @@ -42,6 +43,7 @@ public class DoStellarPaymentHandler extends RpcMethodHandler getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - if (areFundsReceived(txn24)) { - return Set.of(PENDING_ANCHOR); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + if (areFundsReceived(txn6)) { + return Set.of(PENDING_ANCHOR); + } } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + if (areFundsReceived(txn24)) { + return Set.of(PENDING_ANCHOR); + } + } + break; + default: + break; } return emptySet(); } @@ -118,26 +144,54 @@ protected Set getSupportedStatuses(JdbcSepTransaction txn) @Override protected void updateTransactionWithRpcRequest( JdbcSepTransaction txn, DoStellarPaymentRequest request) throws AnchorException { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - boolean trustlineConfigured; - try { - trustlineConfigured = - horizon.isTrustlineConfigured(txn24.getToAccount(), txn24.getAmountOutAsset()); - } catch (IOException ex) { - trustlineConfigured = false; - } + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; - if (trustlineConfigured) { - custodyService.createTransactionPayment(txn24.getId(), null); - } else { - transactionPendingTrustRepo.save( - JdbcTransactionPendingTrust.builder() - .id(txn24.getId()) - .createdAt(Instant.now()) - .asset(txn24.getAmountOutAsset()) - .account(txn24.getToAccount()) - .build()); + try { + trustlineConfigured = + horizon.isTrustlineConfigured(txn6.getToAccount(), txn6.getAmountOutAsset()); + } catch (IOException ex) { + trustlineConfigured = false; + } + + if (trustlineConfigured) { + custodyService.createTransactionPayment(txn6.getId(), null); + } else { + transactionPendingTrustRepo.save( + JdbcTransactionPendingTrust.builder() + .id(txn6.getId()) + .createdAt(Instant.now()) + .asset(txn6.getAmountOutAsset()) + .account(txn6.getToAccount()) + .build()); + } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + + try { + trustlineConfigured = + horizon.isTrustlineConfigured(txn24.getToAccount(), txn24.getAmountOutAsset()); + } catch (IOException ex) { + trustlineConfigured = false; + } + + if (trustlineConfigured) { + custodyService.createTransactionPayment(txn24.getId(), null); + } else { + transactionPendingTrustRepo.save( + JdbcTransactionPendingTrust.builder() + .id(txn24.getId()) + .createdAt(Instant.now()) + .asset(txn24.getAmountOutAsset()) + .account(txn24.getToAccount()) + .build()); + } + break; + default: + break; } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarRefundHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarRefundHandler.java index 54e7526974..6b019b6020 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarRefundHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarRefundHandler.java @@ -1,8 +1,7 @@ package org.stellar.anchor.platform.rpc; import static java.util.Collections.emptySet; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.*; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31; import static org.stellar.anchor.api.rpc.method.RpcMethod.DO_STELLAR_REFUND; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; @@ -12,6 +11,7 @@ import static org.stellar.anchor.util.MathHelper.decimal; import static org.stellar.anchor.util.MathHelper.sum; +import com.google.common.collect.ImmutableSet; import java.math.BigDecimal; import java.util.Set; import org.stellar.anchor.api.exception.AnchorException; @@ -25,6 +25,7 @@ import org.stellar.anchor.api.rpc.method.RpcMethod; import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.sep.SepTransactionStatus; +import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.config.CustodyConfig; import org.stellar.anchor.custody.CustodyService; @@ -32,6 +33,7 @@ import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; import org.stellar.anchor.platform.data.JdbcSep31Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.utils.AssetValidationUtils; import org.stellar.anchor.platform.validator.RequestValidator; @@ -39,6 +41,7 @@ import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31Refunds; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class DoStellarRefundHandler extends RpcMethodHandler { @@ -46,6 +49,7 @@ public class DoStellarRefundHandler extends RpcMethodHandler getSupportedStatuses(JdbcSepTransaction txn) { switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + if (areFundsReceived(txn6)) { + return Set.of(PENDING_ANCHOR); + } + } + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; if (WITHDRAWAL == Kind.from(txn24.getKind())) { @@ -176,6 +199,11 @@ protected Set getSupportedStatuses(JdbcSepTransaction txn) protected void updateTransactionWithRpcRequest( JdbcSepTransaction txn, DoStellarRefundRequest request) throws AnchorException { switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + custodyService.createTransactionRefund( + request, txn6.getRefundMemo(), txn6.getRefundMemoType()); + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; custodyService.createTransactionRefund( diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedHandler.java index 3e97f2eece..447ee9b383 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedHandler.java @@ -2,9 +2,8 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_AMOUNTS_UPDATED; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; +import static org.stellar.anchor.api.sep.SepTransactionStatus.*; import java.util.Set; import org.stellar.anchor.api.exception.BadRequestException; @@ -25,10 +24,12 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyAmountsUpdatedHandler extends RpcMethodHandler { public NotifyAmountsUpdatedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -36,6 +37,7 @@ public NotifyAmountsUpdatedHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -80,15 +82,20 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (WITHDRAWAL == Kind.from(txn24.getKind())) { - if (areFundsReceived(txn24)) { - return Set.of(PENDING_ANCHOR); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + return Set.of(INCOMPLETE, PENDING_ANCHOR, PENDING_CUSTOMER_INFO_UPDATE); + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (WITHDRAWAL == Kind.from(txn24.getKind())) { + if (areFundsReceived(txn24)) { + return Set.of(PENDING_ANCHOR); + } } - } + return emptySet(); + default: + return emptySet(); } - return emptySet(); } @Override diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandler.java index 23c150f6b7..1b206d6018 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandler.java @@ -21,11 +21,13 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyCustomerInfoUpdatedHandler extends RpcMethodHandler { public NotifyCustomerInfoUpdatedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -33,6 +35,7 @@ public NotifyCustomerInfoUpdatedHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandler.java index 324c523891..8f93606d56 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandler.java @@ -25,11 +25,13 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyInteractiveFlowCompletedHandler extends RpcMethodHandler { public NotifyInteractiveFlowCompletedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -37,6 +39,7 @@ public NotifyInteractiveFlowCompletedHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandler.java index 79611d1d76..894e123a6f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandler.java @@ -2,11 +2,14 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_OFFCHAIN_FUNDS_AVAILABLE; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_COMPLETE; +import com.google.common.collect.ImmutableSet; import java.util.Set; import org.stellar.anchor.api.exception.rpc.InvalidRequestException; import org.stellar.anchor.api.platform.PlatformTransactionData.Kind; @@ -18,15 +21,18 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyOffchainFundsAvailableHandler extends RpcMethodHandler { public NotifyOffchainFundsAvailableHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -34,6 +40,7 @@ public NotifyOffchainFundsAvailableHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -57,13 +64,22 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (WITHDRAWAL == Kind.from(txn24.getKind())) { - if (areFundsReceived(txn24)) { + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(Kind.from(txn6.getKind())) + && areFundsReceived(txn6)) { return Set.of(PENDING_ANCHOR); } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (WITHDRAWAL == Kind.from(txn24.getKind()) && areFundsReceived(txn24)) { + return Set.of(PENDING_ANCHOR); + } + break; + default: + break; } return emptySet(); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandler.java index 22ac1f05c7..4c9e2977c1 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandler.java @@ -1,11 +1,11 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL_EXCHANGE; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_OFFCHAIN_FUNDS_PENDING; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_EXTERNAL; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_RECEIVER; +import static org.stellar.anchor.api.sep.SepTransactionStatus.*; +import com.google.common.collect.ImmutableSet; import java.util.HashSet; import java.util.Set; import org.stellar.anchor.api.exception.rpc.InvalidRequestException; @@ -18,15 +18,18 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyOffchainFundsPendingHandler extends RpcMethodHandler { public NotifyOffchainFundsPendingHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -34,6 +37,7 @@ public NotifyOffchainFundsPendingHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -59,12 +63,17 @@ protected SepTransactionStatus getNextStatus( protected Set getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(Kind.from((txn6).getKind())) + && areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_ANCHOR); + } + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (WITHDRAWAL == Kind.from(txn24.getKind())) { - if (areFundsReceived(txn)) { - return Set.of(PENDING_ANCHOR); - } + if (WITHDRAWAL == Kind.from(txn24.getKind()) && areFundsReceived(txn24)) { + return Set.of(PENDING_ANCHOR); } break; case SEP_31: diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandler.java index 811e8a4f9d..205b1ae27f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandler.java @@ -1,12 +1,15 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_OFFCHAIN_FUNDS_RECEIVED; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_EXTERNAL; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_START; +import com.google.common.collect.ImmutableSet; import java.time.Instant; import java.util.HashSet; import java.util.Set; @@ -26,11 +29,13 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.utils.AssetValidationUtils; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyOffchainFundsReceivedHandler extends RpcMethodHandler { @@ -39,6 +44,7 @@ public class NotifyOffchainFundsReceivedHandler private final CustodyConfig custodyConfig; public NotifyOffchainFundsReceivedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -48,6 +54,7 @@ public NotifyOffchainFundsReceivedHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -122,14 +129,27 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - supportedStatuses.add(PENDING_USR_TRANSFER_START); - if (areFundsReceived(txn24)) { - supportedStatuses.add(PENDING_EXTERNAL); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + supportedStatuses.add(PENDING_USR_TRANSFER_START); + if (areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_EXTERNAL); + } } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + supportedStatuses.add(PENDING_USR_TRANSFER_START); + if (areFundsReceived(txn24)) { + supportedStatuses.add(PENDING_EXTERNAL); + } + } + break; + default: + break; } return supportedStatuses; } @@ -157,9 +177,21 @@ protected void updateTransactionWithRpcRequest( txn.setAmountFee(request.getAmountFee().getAmount()); } - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (custodyConfig.isCustodyIntegrationEnabled()) { - custodyService.createTransaction(txn24); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (custodyConfig.isCustodyIntegrationEnabled()) { + custodyService.createTransaction(txn6); + } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (custodyConfig.isCustodyIntegrationEnabled()) { + custodyService.createTransaction(txn24); + } + break; + default: + break; } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandler.java index 5866d0b416..ff05ab6786 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandler.java @@ -1,7 +1,9 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_OFFCHAIN_FUNDS_SENT; import static org.stellar.anchor.api.sep.SepTransactionStatus.COMPLETED; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; @@ -10,6 +12,7 @@ import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_COMPLETE; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_START; +import com.google.common.collect.ImmutableSet; import java.time.Instant; import java.util.HashSet; import java.util.Set; @@ -23,15 +26,18 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyOffchainFundsSentHandler extends RpcMethodHandler { public NotifyOffchainFundsSentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -39,6 +45,7 @@ public NotifyOffchainFundsSentHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -58,6 +65,21 @@ protected SepTransactionStatus getNextStatus( JdbcSepTransaction txn, NotifyOffchainFundsSentRequest request) throws InvalidRequestException { switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + switch (Kind.from(txn6.getKind())) { + case DEPOSIT: + case DEPOSIT_EXCHANGE: + return PENDING_EXTERNAL; + case WITHDRAWAL: + case WITHDRAWAL_EXCHANGE: + return COMPLETED; + default: + throw new InvalidRequestException( + String.format( + "Kind[%s] is not supported for protocol[%s] and action[%s]", + txn6.getKind(), txn6.getProtocol(), getRpcMethod())); + } case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; switch (Kind.from(txn24.getKind())) { @@ -85,6 +107,23 @@ protected SepTransactionStatus getNextStatus( protected Set getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + switch (Kind.from(txn6.getKind())) { + case DEPOSIT: + case DEPOSIT_EXCHANGE: + supportedStatuses.add(PENDING_USR_TRANSFER_START); + break; + case WITHDRAWAL: + case WITHDRAWAL_EXCHANGE: + if (areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_ANCHOR); + } + supportedStatuses.add(PENDING_USR_TRANSFER_COMPLETE); + supportedStatuses.add(PENDING_EXTERNAL); + break; + } + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; switch (Kind.from(txn24.getKind())) { @@ -113,15 +152,27 @@ protected void updateTransactionWithRpcRequest( JdbcSepTransaction txn, NotifyOffchainFundsSentRequest request) { if (request.getExternalTransactionId() != null) { txn.setExternalTransactionId(request.getExternalTransactionId()); - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - if (request.getFundsSentAt() != null) { - txn24.setTransferReceivedAt(request.getFundsSentAt()); - } else { - txn24.setTransferReceivedAt(Instant.now()); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + if (request.getFundsSentAt() != null) { + txn6.setTransferReceivedAt(request.getFundsSentAt()); + } else { + txn6.setTransferReceivedAt(Instant.now()); + } } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + if (request.getFundsSentAt() != null) { + txn24.setTransferReceivedAt(request.getFundsSentAt()); + } else { + txn24.setTransferReceivedAt(Instant.now()); + } + } + break; } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandler.java index 3aa8f88db8..44ef318639 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandler.java @@ -1,6 +1,7 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL_EXCHANGE; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_ONCHAIN_FUNDS_RECEIVED; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_RECEIVER; @@ -9,6 +10,7 @@ import static org.stellar.anchor.platform.utils.PaymentsUtil.addStellarTransaction; import static org.stellar.anchor.util.Log.errorEx; +import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.util.HashSet; import java.util.List; @@ -29,11 +31,13 @@ import org.stellar.anchor.horizon.Horizon; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.utils.AssetValidationUtils; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.sdk.responses.operations.OperationResponse; public class NotifyOnchainFundsReceivedHandler @@ -42,6 +46,7 @@ public class NotifyOnchainFundsReceivedHandler private final Horizon horizon; public NotifyOnchainFundsReceivedHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -50,6 +55,7 @@ public NotifyOnchainFundsReceivedHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -118,6 +124,7 @@ protected SepTransactionStatus getNextStatus( JdbcSepTransaction txn, NotifyOnchainFundsReceivedRequest request) throws InvalidRequestException { switch (Sep.from(txn.getProtocol())) { + case SEP_6: case SEP_24: return PENDING_ANCHOR; case SEP_31: @@ -134,6 +141,12 @@ protected SepTransactionStatus getNextStatus( protected Set getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + supportedStatuses.add(PENDING_USR_TRANSFER_START); + } + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; if (WITHDRAWAL == Kind.from(txn24.getKind())) { diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandler.java index eab389edbe..4d3a644de9 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandler.java @@ -1,7 +1,9 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_ONCHAIN_FUNDS_SENT; import static org.stellar.anchor.api.sep.SepTransactionStatus.COMPLETED; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; @@ -9,6 +11,7 @@ import static org.stellar.anchor.platform.utils.PaymentsUtil.addStellarTransaction; import static org.stellar.anchor.util.Log.errorEx; +import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.time.Instant; import java.util.HashSet; @@ -26,10 +29,12 @@ import org.stellar.anchor.horizon.Horizon; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.sdk.responses.operations.OperationResponse; public class NotifyOnchainFundsSentHandler extends RpcMethodHandler { @@ -37,6 +42,7 @@ public class NotifyOnchainFundsSentHandler extends RpcMethodHandler getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - supportedStatuses.add(PENDING_STELLAR); - if (areFundsReceived(txn24)) { - supportedStatuses.add(PENDING_ANCHOR); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + supportedStatuses.add(PENDING_STELLAR); + if (areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_ANCHOR); + } } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + supportedStatuses.add(PENDING_STELLAR); + if (areFundsReceived(txn24)) { + supportedStatuses.add(PENDING_ANCHOR); + } + } + break; + default: + break; } return supportedStatuses; } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandler.java index 32d2eb0b53..fe74cb33d2 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandler.java @@ -36,10 +36,12 @@ import org.stellar.anchor.sep24.Sep24Refunds; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyRefundPendingHandler extends RpcMethodHandler { public NotifyRefundPendingHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -47,6 +49,7 @@ public NotifyRefundPendingHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundSentHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundSentHandler.java index 7a30add8ea..e2e744ee30 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundSentHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundSentHandler.java @@ -43,10 +43,12 @@ import org.stellar.anchor.sep31.RefundPayment; import org.stellar.anchor.sep31.Sep31Refunds; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyRefundSentHandler extends RpcMethodHandler { public NotifyRefundSentHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -54,6 +56,7 @@ public NotifyRefundSentHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandler.java index 5dbbc78bb4..36ec8b22fc 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandler.java @@ -4,6 +4,7 @@ import static java.util.stream.Collectors.toSet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_TRANSACTION_ERROR; import static org.stellar.anchor.api.sep.SepTransactionStatus.ERROR; @@ -21,12 +22,14 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyTransactionErrorHandler extends RpcMethodHandler { private final JdbcTransactionPendingTrustRepo transactionPendingTrustRepo; public NotifyTransactionErrorHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -35,6 +38,7 @@ public NotifyTransactionErrorHandler( MetricsService metricsService, JdbcTransactionPendingTrustRepo transactionPendingTrustRepo) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -58,7 +62,7 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (Set.of(SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { + if (Set.of(SEP_6, SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { return Arrays.stream(SepTransactionStatus.values()) .filter(s -> !isErrorStatus(s) && !isFinalStatus(s)) .collect(toSet()); diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandler.java index 4dddeb0f39..79406be528 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandler.java @@ -4,6 +4,7 @@ import static java.util.stream.Collectors.toSet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_TRANSACTION_EXPIRED; import static org.stellar.anchor.api.sep.SepTransactionStatus.EXPIRED; @@ -21,6 +22,7 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyTransactionExpiredHandler extends RpcMethodHandler { @@ -28,6 +30,7 @@ public class NotifyTransactionExpiredHandler private final JdbcTransactionPendingTrustRepo transactionPendingTrustRepo; public NotifyTransactionExpiredHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -36,6 +39,7 @@ public NotifyTransactionExpiredHandler( MetricsService metricsService, JdbcTransactionPendingTrustRepo transactionPendingTrustRepo) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -59,7 +63,7 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (Set.of(SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { + if (Set.of(SEP_6, SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { if (!areFundsReceived(txn)) { return Arrays.stream(SepTransactionStatus.values()) .filter(s -> !isErrorStatus(s) && !isFinalStatus(s)) diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandler.java index c11d59ca28..77531feeae 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandler.java @@ -1,8 +1,7 @@ package org.stellar.anchor.platform.rpc; import static java.util.Collections.emptySet; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; -import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.*; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_TRANSACTION_RECOVERY; import static org.stellar.anchor.api.sep.SepTransactionStatus.ERROR; import static org.stellar.anchor.api.sep.SepTransactionStatus.EXPIRED; @@ -23,11 +22,13 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyTransactionRecoveryHandler extends RpcMethodHandler { public NotifyTransactionRecoveryHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -35,6 +36,7 @@ public NotifyTransactionRecoveryHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -54,6 +56,7 @@ protected SepTransactionStatus getNextStatus( JdbcSepTransaction txn, NotifyTransactionRecoveryRequest request) throws InvalidRequestException { switch (Sep.from(txn.getProtocol())) { + case SEP_6: case SEP_24: return PENDING_ANCHOR; case SEP_31: @@ -68,7 +71,7 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (Set.of(SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { + if (Set.of(SEP_6, SEP_24, SEP_31).contains(Sep.from(txn.getProtocol()))) { if (areFundsReceived(txn)) { return Set.of(ERROR, EXPIRED); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTrustSetHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTrustSetHandler.java index 29fcab86c4..836b9aae55 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTrustSetHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTrustSetHandler.java @@ -2,12 +2,14 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; import static org.stellar.anchor.api.rpc.method.RpcMethod.NOTIFY_TRUST_SET; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_STELLAR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_TRUST; +import com.google.common.collect.ImmutableSet; import java.util.Set; import org.stellar.anchor.api.exception.AnchorException; import org.stellar.anchor.api.exception.BadRequestException; @@ -24,10 +26,12 @@ import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.config.PropertyCustodyConfig; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class NotifyTrustSetHandler extends RpcMethodHandler { @@ -35,6 +39,7 @@ public class NotifyTrustSetHandler extends RpcMethodHandler getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - return Set.of(PENDING_TRUST); - } + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + return Set.of(PENDING_TRUST); + } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + return Set.of(PENDING_TRUST); + } + break; + default: + break; } return emptySet(); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandler.java index 2602b8088a..f97144c938 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandler.java @@ -2,9 +2,9 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.REQUEST_CUSTOMER_INFO_UPDATE; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_RECEIVER; +import static org.stellar.anchor.api.sep.SepTransactionStatus.*; import java.util.Set; import org.stellar.anchor.api.exception.BadRequestException; @@ -21,11 +21,14 @@ import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6Transaction; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class RequestCustomerInfoUpdateHandler extends RpcMethodHandler { public RequestCustomerInfoUpdateHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -33,6 +36,7 @@ public RequestCustomerInfoUpdateHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -61,13 +65,29 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_31 == Sep.from(txn.getProtocol())) { - return Set.of(PENDING_RECEIVER); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + return Set.of(INCOMPLETE, PENDING_ANCHOR, PENDING_CUSTOMER_INFO_UPDATE); + case SEP_31: + return Set.of(PENDING_RECEIVER); + default: + return emptySet(); } - return emptySet(); } @Override protected void updateTransactionWithRpcRequest( - JdbcSepTransaction txn, RequestCustomerInfoUpdateRequest request) {} + JdbcSepTransaction txn, RequestCustomerInfoUpdateRequest request) { + if (Sep.from(txn.getProtocol()) == SEP_6) { + Sep6Transaction txn6 = (Sep6Transaction) txn; + + if (request.getRequiredCustomerInfoMessage() != null) { + txn6.setRequiredCustomerInfoMessage(request.getRequiredCustomerInfoMessage()); + } + + if (request.getRequiredCustomerInfoUpdates() != null) { + txn6.setRequiredCustomerInfoUpdates(request.getRequiredCustomerInfoUpdates()); + } + } + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandler.java index 1bebd12886..80abb7c382 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandler.java @@ -1,12 +1,13 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.REQUEST_OFFCHAIN_FUNDS; -import static org.stellar.anchor.api.sep.SepTransactionStatus.INCOMPLETE; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_START; +import static org.stellar.anchor.api.sep.SepTransactionStatus.*; +import com.google.common.collect.ImmutableSet; import java.util.HashSet; import java.util.Set; import org.stellar.anchor.api.exception.BadRequestException; @@ -22,15 +23,18 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.utils.AssetValidationUtils; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class RequestOffchainFundsHandler extends RpcMethodHandler { public RequestOffchainFundsHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -38,6 +42,7 @@ public RequestOffchainFundsHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -116,14 +121,28 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - supportedStatuses.add(INCOMPLETE); - if (!areFundsReceived(txn24)) { - supportedStatuses.add(PENDING_ANCHOR); + switch (Sep.from((txn.getProtocol()))) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + supportedStatuses.add(INCOMPLETE); + if (!areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_ANCHOR); + supportedStatuses.add(PENDING_CUSTOMER_INFO_UPDATE); + } } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind())) { + supportedStatuses.add(INCOMPLETE); + if (!areFundsReceived(txn24)) { + supportedStatuses.add(PENDING_ANCHOR); + } + } + break; + default: + break; } return supportedStatuses; } @@ -143,12 +162,28 @@ protected void updateTransactionWithRpcRequest( txn.setAmountFee(request.getAmountFee().getAmount()); txn.setAmountFeeAsset(request.getAmountFee().getAsset()); } - - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (request.getAmountExpected() != null) { - txn24.setAmountExpected(request.getAmountExpected().getAmount()); - } else if (request.getAmountIn() != null) { - txn24.setAmountExpected(request.getAmountIn().getAmount()); + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (request.getAmountExpected() != null) { + txn6.setAmountExpected(request.getAmountExpected().getAmount()); + } else if (request.getAmountIn() != null) { + txn6.setAmountExpected(request.getAmountIn().getAmount()); + } + if (request.getInstructions() != null) { + txn6.setInstructions(request.getInstructions()); + } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (request.getAmountExpected() != null) { + txn24.setAmountExpected(request.getAmountExpected().getAmount()); + } else if (request.getAmountIn() != null) { + txn24.setAmountExpected(request.getAmountIn().getAmount()); + } + break; + default: + break; } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java index 7e77249aff..e43ea94a6b 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java @@ -1,15 +1,17 @@ package org.stellar.anchor.platform.rpc; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6; import static org.stellar.anchor.api.rpc.method.RpcMethod.REQUEST_ONCHAIN_FUNDS; -import static org.stellar.anchor.api.sep.SepTransactionStatus.INCOMPLETE; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; -import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_USR_TRANSFER_START; +import static org.stellar.anchor.api.sep.SepTransactionStatus.*; import static org.stellar.anchor.util.MemoHelper.makeMemo; import static org.stellar.anchor.util.MemoHelper.memoType; import static org.stellar.anchor.util.SepHelper.memoTypeString; +import com.google.common.collect.ImmutableSet; +import java.util.Collections; import java.util.HashSet; import java.util.Set; import org.stellar.anchor.api.exception.AnchorException; @@ -30,13 +32,17 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.service.Sep24DepositInfoNoneGenerator; +import org.stellar.anchor.platform.service.Sep6DepositInfoNoneGenerator; import org.stellar.anchor.platform.utils.AssetValidationUtils; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24DepositInfoGenerator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.CustodyUtils; import org.stellar.sdk.Memo; @@ -44,19 +50,23 @@ public class RequestOnchainFundsHandler extends RpcMethodHandler getSupportedStatuses(JdbcSepTransaction txn) { Set supportedStatuses = new HashSet<>(); + if (SEP_6 == Sep.from(txn.getProtocol())) { + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(WITHDRAWAL, WITHDRAWAL_EXCHANGE).contains(Kind.from(txn6.getKind()))) { + supportedStatuses.add(INCOMPLETE); + supportedStatuses.add(PENDING_CUSTOMER_INFO_UPDATE); + if (!areFundsReceived(txn6)) { + supportedStatuses.add(PENDING_ANCHOR); + } + } + return supportedStatuses; + } if (SEP_24 == Sep.from(txn.getProtocol())) { JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; if (WITHDRAWAL == Kind.from(txn24.getKind())) { @@ -168,8 +196,9 @@ protected Set getSupportedStatuses(JdbcSepTransaction txn) supportedStatuses.add(PENDING_ANCHOR); } } + return supportedStatuses; } - return supportedStatuses; + return Collections.emptySet(); } @Override @@ -188,39 +217,79 @@ protected void updateTransactionWithRpcRequest( txn.setAmountFeeAsset(request.getAmountFee().getAsset()); } - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; - if (request.getAmountExpected() != null) { - txn24.setAmountExpected(request.getAmountExpected().getAmount()); - } else if (request.getAmountIn() != null) { - txn24.setAmountExpected(request.getAmountIn().getAmount()); - } + if (request.getAmountExpected() != null) { + txn6.setAmountExpected(request.getAmountExpected().getAmount()); + } else if (request.getAmountIn() != null) { + txn6.setAmountExpected(request.getAmountIn().getAmount()); + } - if (sep24DepositInfoGenerator instanceof Sep24DepositInfoNoneGenerator) { - Memo memo = makeMemo(request.getMemo(), request.getMemoType()); - if (memo != null) { - txn24.setMemo(memo.toString()); - txn24.setMemoType(memoTypeString(memoType(memo))); - } - txn24.setWithdrawAnchorAccount(request.getDestinationAccount()); - txn24.setToAccount(request.getDestinationAccount()); - } else { - SepDepositInfo sep24DepositInfo = sep24DepositInfoGenerator.generate(txn24); - txn24.setToAccount(sep24DepositInfo.getStellarAddress()); - txn24.setWithdrawAnchorAccount(sep24DepositInfo.getStellarAddress()); - txn24.setMemo(sep24DepositInfo.getMemo()); - txn24.setMemoType(sep24DepositInfo.getMemoType()); - } + if (sep6DepositInfoGenerator instanceof Sep6DepositInfoNoneGenerator) { + Memo memo = makeMemo(request.getMemo(), request.getMemoType()); + if (memo != null) { + txn6.setMemo(memo.toString()); + txn6.setMemoType(memoTypeString(memoType(memo))); + } + txn6.setWithdrawAnchorAccount(request.getDestinationAccount()); + } else { + SepDepositInfo sep6DepositInfo = sep6DepositInfoGenerator.generate(txn6); + txn6.setWithdrawAnchorAccount(sep6DepositInfo.getStellarAddress()); + txn6.setMemo(sep6DepositInfo.getMemo()); + txn6.setMemoType(sep6DepositInfo.getMemoType()); + } - if (!CustodyUtils.isMemoTypeSupported(custodyConfig.getType(), txn24.getMemoType())) { - throw new InvalidParamsException( - String.format( - "Memo type[%s] is not supported for custody type[%s]", - txn24.getMemoType(), custodyConfig.getType())); - } + if (!CustodyUtils.isMemoTypeSupported(custodyConfig.getType(), txn6.getMemoType())) { + throw new InvalidParamsException( + String.format( + "Memo type[%s] is not supported for custody type[%s]", + txn6.getMemoType(), custodyConfig.getType())); + } + + if (custodyConfig.isCustodyIntegrationEnabled()) { + custodyService.createTransaction(txn6); + } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + + if (request.getAmountExpected() != null) { + txn24.setAmountExpected(request.getAmountExpected().getAmount()); + } else if (request.getAmountIn() != null) { + txn24.setAmountExpected(request.getAmountIn().getAmount()); + } - if (custodyConfig.isCustodyIntegrationEnabled()) { - custodyService.createTransaction(txn24); + if (sep24DepositInfoGenerator instanceof Sep24DepositInfoNoneGenerator) { + Memo memo = makeMemo(request.getMemo(), request.getMemoType()); + if (memo != null) { + txn24.setMemo(memo.toString()); + txn24.setMemoType(memoTypeString(memoType(memo))); + } + txn24.setWithdrawAnchorAccount(request.getDestinationAccount()); + txn24.setToAccount(request.getDestinationAccount()); + } else { + SepDepositInfo sep24DepositInfo = sep24DepositInfoGenerator.generate(txn24); + txn24.setToAccount(sep24DepositInfo.getStellarAddress()); + txn24.setWithdrawAnchorAccount(sep24DepositInfo.getStellarAddress()); + txn24.setMemo(sep24DepositInfo.getMemo()); + txn24.setMemoType(sep24DepositInfo.getMemoType()); + } + + if (!CustodyUtils.isMemoTypeSupported(custodyConfig.getType(), txn24.getMemoType())) { + throw new InvalidParamsException( + String.format( + "Memo type[%s] is not supported for custody type[%s]", + txn24.getMemoType(), custodyConfig.getType())); + } + + if (custodyConfig.isCustodyIntegrationEnabled()) { + custodyService.createTransaction(txn24); + } + break; + default: + break; } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestTrustlineHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestTrustlineHandler.java index 670e1c5307..64e3cba6e3 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestTrustlineHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestTrustlineHandler.java @@ -2,11 +2,13 @@ import static java.util.Collections.emptySet; import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT; +import static org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT_EXCHANGE; import static org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24; import static org.stellar.anchor.api.rpc.method.RpcMethod.REQUEST_TRUST; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR; import static org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_TRUST; +import com.google.common.collect.ImmutableSet; import java.util.Set; import org.stellar.anchor.api.exception.BadRequestException; import org.stellar.anchor.api.exception.rpc.InvalidParamsException; @@ -21,16 +23,19 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; public class RequestTrustlineHandler extends RpcMethodHandler { private final CustodyConfig custodyConfig; public RequestTrustlineHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -39,6 +44,7 @@ public RequestTrustlineHandler( EventService eventService, MetricsService metricsService) { super( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -73,13 +79,22 @@ protected SepTransactionStatus getNextStatus( @Override protected Set getSupportedStatuses(JdbcSepTransaction txn) { - if (SEP_24 == Sep.from(txn.getProtocol())) { - JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; - if (DEPOSIT == Kind.from(txn24.getKind())) { - if (areFundsReceived(txn24)) { + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (ImmutableSet.of(DEPOSIT, DEPOSIT_EXCHANGE).contains(Kind.from(txn6.getKind())) + && areFundsReceived(txn6)) { return Set.of(PENDING_ANCHOR); } - } + break; + case SEP_24: + JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; + if (DEPOSIT == Kind.from(txn24.getKind()) && areFundsReceived(txn24)) { + return Set.of(PENDING_ANCHOR); + } + break; + default: + break; } return emptySet(); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcMethodHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcMethodHandler.java index 65d576346e..02a25e50f2 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcMethodHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcMethodHandler.java @@ -15,7 +15,6 @@ import java.time.Instant; import java.util.Set; import java.util.UUID; -import org.springframework.transaction.annotation.Transactional; import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.AnchorException; import org.stellar.anchor.api.exception.BadRequestException; @@ -32,17 +31,21 @@ import org.stellar.anchor.metrics.MetricsService; import org.stellar.anchor.platform.data.JdbcSep24Transaction; import org.stellar.anchor.platform.data.JdbcSep31Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.validator.RequestValidator; +import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep24.Sep24TransactionStore; import org.stellar.anchor.sep31.Sep31Transaction; import org.stellar.anchor.sep31.Sep31TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.GsonUtils; public abstract class RpcMethodHandler { private static final Gson gson = GsonUtils.getInstance(); + protected final Sep6TransactionStore txn6Store; protected final Sep24TransactionStore txn24Store; protected final Sep31TransactionStore txn31Store; protected final AssetService assetService; @@ -52,6 +55,7 @@ public abstract class RpcMethodHandler { private final Session eventSession; public RpcMethodHandler( + Sep6TransactionStore txn6Store, Sep24TransactionStore txn24Store, Sep31TransactionStore txn31Store, RequestValidator requestValidator, @@ -59,6 +63,7 @@ public RpcMethodHandler( EventService eventService, MetricsService metricsService, Class requestType) { + this.txn6Store = txn6Store; this.txn24Store = txn24Store; this.txn31Store = txn31Store; this.requestValidator = requestValidator; @@ -68,7 +73,6 @@ public RpcMethodHandler( this.eventSession = eventService.createSession(this.getClass().getName(), TRANSACTION); } - @Transactional public GetTransactionResponse handle(Object requestParams) throws AnchorException { T request = gson.fromJson(gson.toJson(requestParams), requestType); JdbcSepTransaction txn = getTransaction(request.getTransactionId()); @@ -81,6 +85,9 @@ public GetTransactionResponse handle(Object requestParams) throws AnchorExceptio if (!getSupportedStatuses(txn).contains(SepTransactionStatus.from(txn.getStatus()))) { String kind; switch (Sep.from(txn.getProtocol())) { + case SEP_6: + kind = ((JdbcSep6Transaction) txn).getKind(); + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; kind = txn24.getKind(); @@ -127,7 +134,11 @@ protected JdbcSepTransaction getTransaction(String transactionId) throws AnchorE if (txn31 != null) { return (JdbcSep31Transaction) txn31; } - return (JdbcSep24Transaction) txn24Store.findByTransactionId(transactionId); + Sep24Transaction txn24 = txn24Store.findByTransactionId(transactionId); + if (txn24 != null) { + return (JdbcSep24Transaction) txn24; + } + return (JdbcSep6Transaction) txn6Store.findByTransactionId(transactionId); } protected void validate(JdbcSepTransaction txn, T request) @@ -157,6 +168,15 @@ private void updateTransaction(JdbcSepTransaction txn, T request) throws AnchorE } switch (Sep.from(txn.getProtocol())) { + case SEP_6: + JdbcSep6Transaction txn6 = (JdbcSep6Transaction) txn; + if (request.getMessage() != null) { + txn6.setMessage(request.getMessage()); + } else if (shouldClearMessageStatus) { + txn6.setMessage(null); + } + txn6Store.save(txn6); + break; case SEP_24: JdbcSep24Transaction txn24 = (JdbcSep24Transaction) txn; if (request.getMessage() != null) { @@ -194,6 +214,9 @@ protected boolean isFinalStatus(SepTransactionStatus status) { private void updateMetrics(JdbcSepTransaction txn) { switch (Sep.from(txn.getProtocol())) { + case SEP_6: + metricsService.counter(PLATFORM_RPC_TRANSACTION, SEP, TV_SEP6).increment(); + break; case SEP_24: metricsService.counter(PLATFORM_RPC_TRANSACTION, SEP, TV_SEP24).increment(); break; diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/CustodyServiceImpl.java b/platform/src/main/java/org/stellar/anchor/platform/service/CustodyServiceImpl.java index 9cdfe76f3d..52a3109f20 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/CustodyServiceImpl.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/CustodyServiceImpl.java @@ -20,6 +20,7 @@ import org.stellar.anchor.platform.apiclient.CustodyApiClient; import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep31.Sep31Transaction; +import org.stellar.anchor.sep6.Sep6Transaction; public class CustodyServiceImpl implements CustodyService { @@ -29,6 +30,11 @@ public CustodyServiceImpl(Optional custodyApiClient) { this.custodyApiClient = custodyApiClient; } + @Override + public void createTransaction(Sep6Transaction txn) throws AnchorException { + create(toCustodyTransaction(txn)); + } + @Override public void createTransaction(Sep24Transaction txn) throws AnchorException { create(toCustodyTransaction(txn)); 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 8b5f562f32..2606c2a5cf 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 @@ -9,13 +9,11 @@ import io.micrometer.core.instrument.Metrics; import java.io.IOException; import java.math.BigDecimal; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.List; import java.util.Objects; import org.apache.commons.codec.DecoderException; import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.apiclient.PlatformApiClient; import org.stellar.anchor.platform.config.RpcConfig; @@ -30,16 +28,19 @@ public class PaymentOperationToEventListener implements PaymentListener { final JdbcSep31TransactionStore sep31TransactionStore; final JdbcSep24TransactionStore sep24TransactionStore; + final JdbcSep6TransactionStore sep6TransactionStore; private final PlatformApiClient platformApiClient; private final RpcConfig rpcConfig; public PaymentOperationToEventListener( JdbcSep31TransactionStore sep31TransactionStore, JdbcSep24TransactionStore sep24TransactionStore, + JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig) { this.sep31TransactionStore = sep31TransactionStore; this.sep24TransactionStore = sep24TransactionStore; + this.sep6TransactionStore = sep6TransactionStore; this.platformApiClient = platformApiClient; this.rpcConfig = rpcConfig; } @@ -111,6 +112,25 @@ public void onReceived(ObservedPayment payment) throws IOException { errorEx(aex); } } + + // Find a transaction matching the memo, assumes transactions are unique to account+memo + JdbcSep6Transaction sep6Txn; + try { + sep6Txn = + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus( + 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 @@ -162,13 +182,14 @@ 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.makeSep11AssetName(payment.getAssetCode(), payment.getAssetIssuer()); + if (!payment.getAssetName().equals(assetName)) { warnF( "Payment asset {} does not match the expected asset {}.", payment.getAssetCode(), - txn.getAmountInAsset()); + assetName); + return; } // Check if the payment contains the expected amount (or greater) @@ -208,13 +229,34 @@ void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) .increment(Double.parseDouble(payment.getAmount())); } - public static Instant parsePaymentTime(String paymentTimeStr) { - try { - return DateTimeFormatter.ISO_INSTANT.parse(paymentTimeStr, Instant::from); - } catch (DateTimeParseException | NullPointerException ex) { - Log.errorF("Error parsing paymentTimeStr {}.", paymentTimeStr); - ex.printStackTrace(); - return null; + void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) + throws AnchorException, IOException { + String assetName = + AssetInfo.makeSep11AssetName(payment.getAssetCode(), payment.getAssetIssuer()); + if (!payment.getAssetName().equals(assetName)) { + warnF( + "Payment asset {} does not match the expected asset {}.", + payment.getAssetCode(), + assetName); + return; + } + + BigDecimal amountExpected = decimal(txn.getAmountExpected()); + BigDecimal gotAmount = decimal(payment.getAmount()); + if (gotAmount.compareTo(amountExpected) >= 0) { + Log.infoF("Incoming payment for SEP-6 transaction {}.", txn.getId()); + } else { + Log.warnF( + "The incoming payment amount for SEP-6 transaction {} was insufficient! Expected: \"{}\", Received: \"{}\"", + txn.getId(), + formatAmount(amountExpected), + formatAmount(gotAmount)); } + + platformApiClient.notifyOnchainFundsReceived( + txn.getId(), + payment.getTransactionHash(), + payment.getAmount(), + rpcConfig.getCustomMessages().getIncomingPaymentReceived()); } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGenerator.java b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGenerator.java new file mode 100644 index 0000000000..37d852b93f --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGenerator.java @@ -0,0 +1,23 @@ +package org.stellar.anchor.platform.service; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.stellar.anchor.api.custody.GenerateDepositAddressResponse; +import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.shared.SepDepositInfo; +import org.stellar.anchor.platform.apiclient.CustodyApiClient; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6Transaction; + +@RequiredArgsConstructor +public class Sep6DepositInfoCustodyGenerator implements Sep6DepositInfoGenerator { + @NonNull private final CustodyApiClient custodyApiClient; + + @Override + public SepDepositInfo generate(Sep6Transaction txn) throws AnchorException { + GenerateDepositAddressResponse depositAddress = + custodyApiClient.generateDepositAddress(txn.getAmountInAsset()); + return new SepDepositInfo( + depositAddress.getAddress(), depositAddress.getMemo(), depositAddress.getMemoType()); + } +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoNoneGenerator.java b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoNoneGenerator.java new file mode 100644 index 0000000000..69090796cb --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoNoneGenerator.java @@ -0,0 +1,14 @@ +package org.stellar.anchor.platform.service; + +import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.exception.BadRequestException; +import org.stellar.anchor.api.shared.SepDepositInfo; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6Transaction; + +public class Sep6DepositInfoNoneGenerator implements Sep6DepositInfoGenerator { + @Override + public SepDepositInfo generate(Sep6Transaction txn) throws AnchorException { + throw new BadRequestException("SEP-6 deposit info generation is disabled"); + } +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGenerator.java b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGenerator.java new file mode 100644 index 0000000000..7aa6c9b1bd --- /dev/null +++ b/platform/src/main/java/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGenerator.java @@ -0,0 +1,32 @@ +package org.stellar.anchor.platform.service; + +import static org.stellar.anchor.util.MemoHelper.memoTypeAsString; +import static org.stellar.sdk.xdr.MemoType.MEMO_HASH; + +import java.util.Base64; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.sep.AssetInfo; +import org.stellar.anchor.api.shared.SepDepositInfo; +import org.stellar.anchor.asset.AssetService; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6Transaction; + +@RequiredArgsConstructor +public class Sep6DepositInfoSelfGenerator implements Sep6DepositInfoGenerator { + @NonNull private final AssetService assetService; + + @Override + public SepDepositInfo generate(Sep6Transaction txn) throws AnchorException { + AssetInfo assetInfo = + assetService.getAsset(txn.getRequestAssetCode(), txn.getRequestAssetIssuer()); + + String memo = StringUtils.truncate(txn.getId(), 32); + memo = StringUtils.leftPad(memo, 32, '0'); + memo = new String(Base64.getEncoder().encode(memo.getBytes())); + return new SepDepositInfo( + assetInfo.getDistributionAccount(), memo, memoTypeAsString(MEMO_HASH)); + } +} diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructor.java b/platform/src/main/java/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructor.java index 4fdfa5e46e..21bc5e3421 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructor.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructor.java @@ -136,7 +136,7 @@ public static Map extractRequiredJwtFieldsFromRequest( } } - fields.put("asset", asset.getAssetName()); + fields.put("asset", asset.getSep38AssetName()); if (homeDomain != null) { fields.put(HOME_DOMAIN, homeDomain); 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 4c37a4e256..c8fafecd94 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 @@ -15,6 +15,7 @@ import static org.stellar.anchor.util.MemoHelper.makeMemo; import static org.stellar.anchor.util.MetricConstants.*; +import com.google.common.collect.ImmutableSet; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Metrics; import java.time.Instant; @@ -29,6 +30,7 @@ import org.stellar.anchor.api.exception.BadRequestException; 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.GetTransactionResponse; import org.stellar.anchor.api.platform.GetTransactionsResponse; import org.stellar.anchor.api.platform.PatchTransactionRequest; @@ -36,11 +38,11 @@ import org.stellar.anchor.api.platform.PatchTransactionsResponse; import org.stellar.anchor.api.platform.PlatformTransactionData; import org.stellar.anchor.api.platform.PlatformTransactionData.Kind; +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.api.shared.SepDepositInfo; -import org.stellar.anchor.apiclient.TransactionsSeps; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.config.CustodyConfig; import org.stellar.anchor.custody.CustodyService; @@ -48,6 +50,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.Sep24DepositInfoGenerator; import org.stellar.anchor.sep24.Sep24Refunds; @@ -57,6 +60,8 @@ import org.stellar.anchor.sep31.Sep31TransactionStore; import org.stellar.anchor.sep38.Sep38Quote; import org.stellar.anchor.sep38.Sep38QuoteStore; +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator; +import org.stellar.anchor.sep6.Sep6Transaction; import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.*; import org.stellar.anchor.util.Log; @@ -75,6 +80,8 @@ public class TransactionService { private final List assets; private final Session eventSession; private final AssetService assetService; + + private final Sep6DepositInfoGenerator sep6DepositInfoGenerator; private final Sep24DepositInfoGenerator sep24DepositInfoGenerator; private final CustodyService custodyService; private final CustodyConfig custodyConfig; @@ -115,6 +122,7 @@ public TransactionService( Sep38QuoteStore quoteStore, AssetService assetService, EventService eventService, + Sep6DepositInfoGenerator sep6DepositInfoGenerator, Sep24DepositInfoGenerator sep24DepositInfoGenerator, CustodyService custodyService, CustodyConfig custodyConfig) { @@ -125,6 +133,7 @@ public TransactionService( this.assets = assetService.listAllAssets(); this.eventSession = eventService.createSession(this.getClass().getName(), TRANSACTION); this.assetService = assetService; + this.sep6DepositInfoGenerator = sep6DepositInfoGenerator; this.sep24DepositInfoGenerator = sep24DepositInfoGenerator; this.custodyService = custodyService; this.custodyConfig = custodyConfig; @@ -202,6 +211,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); } @@ -247,6 +260,45 @@ private GetTransactionResponse patchTransaction(PatchTransactionRequest patch) String lastStatus = txn.getStatus(); updateSepTransaction(patch.getTransaction(), txn); switch (txn.getProtocol()) { + case "6": + JdbcSep6Transaction sep6Transaction = (JdbcSep6Transaction) txn; + Log.infoF( + "Updating SEP-6 transaction: {}", GsonUtils.getInstance().toJson(sep6Transaction)); + + boolean shouldCreateDepositTxn = + ImmutableSet.of(Kind.DEPOSIT, Kind.DEPOSIT_EXCHANGE) + .contains(Kind.from(sep6Transaction.getKind())) + // TODO: check if this is correct + && txn.getStatus().equals(PENDING_ANCHOR.toString()); + boolean shouldCreateWithdrawTxn = + ImmutableSet.of(Kind.WITHDRAWAL, Kind.WITHDRAWAL_EXCHANGE) + .contains(Kind.from(sep6Transaction.getKind())) + && txn.getStatus().equals(PENDING_USR_TRANSFER_START.toString()); + + if (sep6Transaction.getMemo() == null && shouldCreateWithdrawTxn) { + SepDepositInfo sep6DepositInfo = sep6DepositInfoGenerator.generate(sep6Transaction); + sep6Transaction.setWithdrawAnchorAccount(sep6DepositInfo.getStellarAddress()); + sep6Transaction.setMemo(sep6DepositInfo.getMemo()); + sep6Transaction.setMemoType(sep6DepositInfo.getMemoType()); + } + + if (custodyConfig.isCustodyIntegrationEnabled() + && !lastStatus.equals(sep6Transaction.getStatus()) + && (shouldCreateDepositTxn || shouldCreateWithdrawTxn)) { + custodyService.createTransaction(sep6Transaction); + } + + txn6Store.save(sep6Transaction); + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(TRANSACTION_STATUS_CHANGED) + .transaction( + TransactionHelper.toGetTransactionResponse(sep6Transaction, assetService)) + .build()); + patchSep6TransactionCounter.increment(); + break; case "24": JdbcSep24Transaction sep24Txn = (JdbcSep24Transaction) txn; @@ -322,6 +374,14 @@ 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); + break; case "24": JdbcSep24Transaction sep24Txn = (JdbcSep24Transaction) txn; @@ -427,14 +487,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/main/java/org/stellar/anchor/platform/utils/AssetValidationUtils.java b/platform/src/main/java/org/stellar/anchor/platform/utils/AssetValidationUtils.java index d00d8d1e7a..b6724117ad 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/AssetValidationUtils.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/AssetValidationUtils.java @@ -49,7 +49,7 @@ public static void validateAsset( List allAssets = assetService.listAllAssets().stream() - .filter(assetInfo -> assetInfo.getAssetName().equals(amount.getAsset())) + .filter(assetInfo -> assetInfo.getSep38AssetName().equals(amount.getAsset())) .collect(toList()); // asset name needs to be supported diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentsUtil.java b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentsUtil.java index 9b7c26364e..ae8bd7a7a8 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentsUtil.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentsUtil.java @@ -2,10 +2,11 @@ import static java.util.Comparator.comparing; import static java.util.stream.Collectors.toList; -import static org.stellar.anchor.platform.service.PaymentOperationToEventListener.parsePaymentTime; import static org.stellar.anchor.util.Log.error; import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -18,6 +19,7 @@ import org.stellar.anchor.platform.data.JdbcSep31Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.platform.observer.ObservedPayment; +import org.stellar.anchor.util.Log; import org.stellar.sdk.responses.operations.OperationResponse; import org.stellar.sdk.responses.operations.PathPaymentBaseOperationResponse; import org.stellar.sdk.responses.operations.PaymentOperationResponse; @@ -108,4 +110,14 @@ private static List getObservedPayments(List .sorted(comparing(ObservedPayment::getCreatedAt)) .collect(toList()); } + + private static Instant parsePaymentTime(String paymentTimeStr) { + try { + return DateTimeFormatter.ISO_INSTANT.parse(paymentTimeStr, Instant::from); + } catch (DateTimeParseException | NullPointerException ex) { + Log.errorF("Error parsing paymentTimeStr {}.", paymentTimeStr); + ex.printStackTrace(); + return null; + } + } } 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/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/main/resources/config/anchor-config-default-values.yaml b/platform/src/main/resources/config/anchor-config-default-values.yaml index 91e25f78aa..e54b31a581 100644 --- a/platform/src/main/resources/config/anchor-config-default-values.yaml +++ b/platform/src/main/resources/config/anchor-config-default-values.yaml @@ -301,6 +301,16 @@ sep6: features: account_creation: false claimable_balances: false + ## @param: deposit_info_generator_type + ## @default: self + ## Used to choose how the SEP-6 deposit information will be generated, which includes the + ## deposit address, memo and memo type. + ## @supported_values: + ## self: the memo and memo type are generated in the local code, and the distribution account is used for the deposit address. + ## custody: the memo and memo type are generated through Custody API, for example Fireblocks, as well as the deposit address. + ## none: deposit address, memo and memo type should be provided by the business in PATCH/RPC request. + # + deposit_info_generator_type: self ###################### # SEP-10 Configuration diff --git a/platform/src/main/resources/config/anchor-config-schema-v1.yaml b/platform/src/main/resources/config/anchor-config-schema-v1.yaml index 136de05c91..950763cda0 100644 --- a/platform/src/main/resources/config/anchor-config-schema-v1.yaml +++ b/platform/src/main/resources/config/anchor-config-schema-v1.yaml @@ -95,6 +95,7 @@ sep31.enabled: sep31.payment_type: sep38.enabled: sep38.sep10_enforced: +sep6.deposit_info_generator_type: sep6.enabled: sep6.features.account_creation: sep6.features.claimable_balances: diff --git a/platform/src/main/resources/db/migration/V10__sep6_field_updates.sql b/platform/src/main/resources/db/migration/V10__sep6_field_updates.sql new file mode 100644 index 0000000000..9256e2cdc7 --- /dev/null +++ b/platform/src/main/resources/db/migration/V10__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/config/Sep6ConfigTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/config/Sep6ConfigTest.kt index f046eb8441..cddcab78eb 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/config/Sep6ConfigTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/config/Sep6ConfigTest.kt @@ -1,21 +1,33 @@ package org.stellar.anchor.platform.config +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.springframework.validation.BindException import org.springframework.validation.Errors +import org.stellar.anchor.config.CustodyConfig import org.stellar.anchor.config.Sep6Config class Sep6ConfigTest { + @MockK(relaxed = true) lateinit var custodyConfig: CustodyConfig lateinit var config: PropertySep6Config lateinit var errors: Errors @BeforeEach fun setUp() { - config = PropertySep6Config() - config.enabled = true - config.features = Sep6Config.Features(false, false) + MockKAnnotations.init(this, relaxUnitFun = true) + every { custodyConfig.isCustodyIntegrationEnabled } returns true + config = + PropertySep6Config(custodyConfig).apply { + enabled = true + features = Sep6Config.Features(false, false) + depositInfoGeneratorType = Sep6Config.DepositInfoGeneratorType.CUSTODY + } errors = BindException(config, "config") } @@ -58,4 +70,22 @@ class Sep6ConfigTest { config.validate(config, errors) Assertions.assertEquals("sep6-features-claimable-balances-invalid", errors.allErrors[0].code) } + + @CsvSource(value = ["NONE", "SELF"]) + @ParameterizedTest + fun `test validation rejecting custody enabled and non-custodial deposit info generator`( + type: String + ) { + config.depositInfoGeneratorType = Sep6Config.DepositInfoGeneratorType.valueOf(type) + config.validate(config, errors) + Assertions.assertEquals("sep6-deposit-info-generator-type", errors.allErrors[0].code) + } + + @Test + fun `test validation rejecting custody disabled and custodial deposit generator`() { + every { custodyConfig.isCustodyIntegrationEnabled } returns false + config.depositInfoGeneratorType = Sep6Config.DepositInfoGeneratorType.CUSTODY + config.validate(config, errors) + Assertions.assertEquals("sep6-deposit-info-generator-type", errors.allErrors[0].code) + } } 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 2e9babb427..8db2b2ee43 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 @@ -8,23 +8,29 @@ import java.util.concurrent.TimeUnit import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.stellar.anchor.LockAndMockStatic +import org.stellar.anchor.LockAndMockTest 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.config.ClientsConfig.* -import org.stellar.anchor.config.ClientsConfig.ClientType.* -import org.stellar.anchor.lockAndMockStatic +import org.stellar.anchor.config.ClientsConfig.ClientConfig +import org.stellar.anchor.config.ClientsConfig.ClientType.CUSTODIAL import org.stellar.anchor.platform.config.PropertySecretConfig 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.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionUtils import org.stellar.anchor.util.StringHelper.json import org.stellar.sdk.KeyPair +@ExtendWith(LockAndMockTest::class) class ClientStatusCallbackHandlerTest { private lateinit var handler: ClientStatusCallbackHandler private lateinit var secretConfig: PropertySecretConfig @@ -33,6 +39,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 sep31TransactionStore: Sep31TransactionStore @MockK(relaxed = true) private lateinit var assetService: AssetService @@ -45,6 +52,9 @@ class ClientStatusCallbackHandlerTest { clientConfig.signingKey = "GBI2IWJGR4UQPBIKPP6WG76X5PHSD2QTEBGIP6AZ3ZXWV46ZUSGNEGN2" clientConfig.callbackUrl = "https://callback.circle.com/api/v1/anchor/callback" + sep6TransactionStore = mockk() + every { sep6TransactionStore.findByTransactionId(any()) } returns null + sep24TransactionStore = mockk() sep31TransactionStore = mockk() every { sep24TransactionStore.findByTransactionId(any()) } returns null @@ -63,30 +73,36 @@ class ClientStatusCallbackHandlerTest { event.transaction.sep = PlatformTransactionData.Sep.SEP_24 handler = - ClientStatusCallbackHandler(secretConfig, clientConfig, assetService, moreInfoUrlConstructor) + ClientStatusCallbackHandler( + secretConfig, + clientConfig, + sep6TransactionStore, + assetService, + moreInfoUrlConstructor + ) } @Test + @LockAndMockStatic([Sep24Helper::class, Sep6TransactionUtils::class]) fun `test verify request signature`() { - lockAndMockStatic(Sep24Helper::class) { - // header example: "X-Stellar-Signature": "t=....., s=......" - // Get the signature from request + // header example: "X-Stellar-Signature": "t=....., s=......" + // Get the signature from request - every { fromTxn(any(), any(), any()) } returns mockk() + every { Sep6TransactionUtils.fromTxn(any()) } returns mockk() + every { fromTxn(any(), any(), any()) } returns mockk() - val payload = json(event) - val request = - ClientStatusCallbackHandler.buildHttpRequest(signer, payload, clientConfig.callbackUrl) - val requestHeader = request.headers["Signature"] - val parsedSignature = requestHeader?.split(", ")?.get(1)?.substring(2) - val decodedSignature = Base64.getDecoder().decode(parsedSignature) + val payload = json(event) + val request = + ClientStatusCallbackHandler.buildHttpRequest(signer, payload, clientConfig.callbackUrl) + val requestHeader = request.headers["Signature"] + val parsedSignature = requestHeader?.split(", ")?.get(1)?.substring(2) + val decodedSignature = Base64.getDecoder().decode(parsedSignature) - // re-compose the signature from request info for verify - val tsInRequest = requestHeader?.split(", ")?.get(0)?.substring(2) - val payloadToVerify = tsInRequest + "." + request.url.host + "." + payload - val signatureToVerify = signer.sign(payloadToVerify.toByteArray()) + // re-compose the signature from request info for verify + val tsInRequest = requestHeader?.split(", ")?.get(0)?.substring(2) + val payloadToVerify = tsInRequest + "." + request.url.host + "." + payload + val signatureToVerify = signer.sign(payloadToVerify.toByteArray()) - Assertions.assertArrayEquals(decodedSignature, signatureToVerify) - } + Assertions.assertArrayEquals(decodedSignature, signatureToVerify) } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarPaymentHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarPaymentHandlerTest.kt index cdf634d831..d5551214cf 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarPaymentHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarPaymentHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,12 +18,14 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.DoStellarPaymentRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.config.CustodyConfig import org.stellar.anchor.custody.CustodyService @@ -31,12 +35,14 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.horizon.Horizon import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.data.JdbcTransactionPendingTrust import org.stellar.anchor.platform.data.JdbcTransactionPendingTrustRepo import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class DoStellarPaymentHandlerTest { @@ -49,6 +55,8 @@ class DoStellarPaymentHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -82,6 +90,7 @@ class DoStellarPaymentHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = DoStellarPaymentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -104,6 +113,7 @@ class DoStellarPaymentHandlerTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -115,99 +125,108 @@ class DoStellarPaymentHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_TRUST.toString() + txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[do_stellar_payment] is not supported. Status[pending_trust], kind[deposit], protocol[24], funds received[true]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_handle_custodyIntegrationDisabled() { + fun test_handle_sep24_unsupportedStatus() { val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = PENDING_TRUST.toString() txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { custodyConfig.isCustodyIntegrationEnabled } returns true val ex = assertThrows { handler.handle(request) } - assertEquals("RPC method[do_stellar_payment] requires disabled custody integration", ex.message) + assertEquals( + "RPC method[do_stellar_payment] is not supported. Status[pending_trust], kind[deposit], protocol[24], funds received[true]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferNotReceived() { + fun test_handle_sep24_custodyIntegrationDisabled() { val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { custodyConfig.isCustodyIntegrationEnabled } returns false val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[do_stellar_payment] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[false]", - ex.message - ) + assertEquals("RPC method[do_stellar_payment] requires enabled custody integration", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_transferNotReceived() { val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind - txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) + every { custodyConfig.isCustodyIntegrationEnabled } returns true - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_payment] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_trustlineConfigured() { + fun test_handle_sep24_ok_trustlineConfigured() { val transferReceivedAt = Instant.now() val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() @@ -220,6 +239,7 @@ class DoStellarPaymentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -233,6 +253,7 @@ class DoStellarPaymentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { transactionPendingTrustRepo.save(any()) } verify(exactly = 1) { custodyService.createTransactionPayment(TX_ID, null) } @@ -287,7 +308,7 @@ class DoStellarPaymentHandlerTest { } @Test - fun test_handle_ok_trustlineNotConfigured() { + fun test_handle_sep24_ok_trustlineNotConfigured() { val transferReceivedAt = Instant.now() val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() @@ -301,12 +322,14 @@ class DoStellarPaymentHandlerTest { val txnPendingTrustCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null every { custodyConfig.isCustodyIntegrationEnabled } returns true every { horizon.isTrustlineConfigured(TO_ACCOUNT, AMOUNT_OUT_ASSET) } returns false - every { transactionPendingTrustRepo.save(capture(txnPendingTrustCapture)) } returns null + every { transactionPendingTrustRepo.save(capture(txnPendingTrustCapture)) } returns + JdbcTransactionPendingTrust() every { eventSession.publish(capture(anchorEventCapture)) } just Runs every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns sepTransactionCounter @@ -315,6 +338,7 @@ class DoStellarPaymentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { custodyService.createTransactionPayment(any(), any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -380,4 +404,264 @@ class DoStellarPaymentHandlerTest { assertTrue(txnPendingTrustCapture.captured.createdAt >= startDate) assertTrue(txnPendingTrustCapture.captured.createdAt <= endDate) } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_payment] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_custodyIntegrationDisabled(kind: String) { + val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + + val ex = assertThrows { handler.handle(request) } + assertEquals("RPC method[do_stellar_payment] requires enabled custody integration", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferNotReceived(kind: String) { + val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(TX_ID) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_payment] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_trustlineConfigured(kind: String) { + val transferReceivedAt = Instant.now() + val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.id = TX_ID + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + txn6.toAccount = TO_ACCOUNT + txn6.amountOutAsset = AMOUNT_OUT_ASSET + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { horizon.isTrustlineConfigured(TO_ACCOUNT, AMOUNT_OUT_ASSET) } returns true + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { transactionPendingTrustRepo.save(any()) } + verify(exactly = 1) { custodyService.createTransactionPayment(TX_ID, null) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.id = TX_ID + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_STELLAR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.toAccount = TO_ACCOUNT + expectedSep6Txn.amountOutAsset = AMOUNT_OUT_ASSET + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.id = TX_ID + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_STELLAR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.destinationAccount = TO_ACCOUNT + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_trustlineNotConfigured(kind: String) { + val transferReceivedAt = Instant.now() + val request = DoStellarPaymentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.id = TX_ID + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + txn6.toAccount = TO_ACCOUNT + txn6.amountOutAsset = AMOUNT_OUT_ASSET + val sep6TxnCapture = slot() + val txnPendingTrustCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { horizon.isTrustlineConfigured(TO_ACCOUNT, AMOUNT_OUT_ASSET) } returns false + every { transactionPendingTrustRepo.save(capture(txnPendingTrustCapture)) } returns + JdbcTransactionPendingTrust() + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { custodyService.createTransactionPayment(any(), any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.id = TX_ID + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_TRUST.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.toAccount = TO_ACCOUNT + expectedSep6Txn.amountOutAsset = AMOUNT_OUT_ASSET + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.id = TX_ID + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_TRUST + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.destinationAccount = TO_ACCOUNT + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedTxnPendingTrust = JdbcTransactionPendingTrust() + expectedTxnPendingTrust.id = TX_ID + expectedTxnPendingTrust.asset = AMOUNT_OUT_ASSET + expectedTxnPendingTrust.account = TO_ACCOUNT + expectedTxnPendingTrust.createdAt = txnPendingTrustCapture.captured.createdAt + + JSONAssert.assertEquals( + gson.toJson(expectedTxnPendingTrust), + gson.toJson(txnPendingTrustCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(txnPendingTrustCapture.captured.createdAt >= startDate) + assertTrue(txnPendingTrustCapture.captured.createdAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarRefundHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarRefundHandlerTest.kt index 27718d47b3..ae3b3567b7 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarRefundHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarRefundHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -17,12 +19,14 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_6 import org.stellar.anchor.api.rpc.method.AmountAssetRequest import org.stellar.anchor.api.rpc.method.DoStellarRefundRequest import org.stellar.anchor.api.sep.SepTransactionStatus @@ -31,6 +35,8 @@ import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_RECEIVER import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.RefundPayment +import org.stellar.anchor.api.shared.Refunds import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService @@ -46,10 +52,12 @@ import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31RefundPayment import org.stellar.anchor.platform.data.JdbcSep31Refunds import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class DoStellarRefundHandlerTest { @@ -66,6 +74,8 @@ class DoStellarRefundHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -95,6 +105,7 @@ class DoStellarRefundHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = DoStellarRefundHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -113,6 +124,7 @@ class DoStellarRefundHandlerTest { txn24.status = PENDING_ANCHOR.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -123,48 +135,7 @@ class DoStellarRefundHandlerTest { ex.message ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - - @Test - fun test_handle_unsupportedKind() { - val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", - ex.message - ) - - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - - @Test - fun test_handle_unsupportedStatus() { - val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", - ex.message - ) - + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -178,6 +149,7 @@ class DoStellarRefundHandlerTest { txn24.transferReceivedAt = Instant.now() txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -186,30 +158,7 @@ class DoStellarRefundHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - - @Test - fun test_handle_disabledCustodyIntegration() { - val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.requestAssetCode = FIAT_USD_CODE - txn24.amountOutAsset = STELLAR_USDC - txn24.amountFeeAsset = FIAT_USD - txn24.transferReceivedAt = Instant.now() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() - - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - - val ex = assertThrows { handler.handle(request) } - assertEquals("RPC method[do_stellar_refund] requires enabled custody integration", ex.message) - + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -237,6 +186,7 @@ class DoStellarRefundHandlerTest { txn24.kind = WITHDRAWAL.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -251,6 +201,7 @@ class DoStellarRefundHandlerTest { ex = assertThrows { handler.handle(request) } assertEquals("refund.amountFee.amount should be non-negative", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -278,6 +229,7 @@ class DoStellarRefundHandlerTest { txn24.kind = WITHDRAWAL.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -295,13 +247,140 @@ class DoStellarRefundHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_sep24() { + fun test_handle_sep24_unsupportedKind() { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_unsupportedStatus() { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_disabledCustodyIntegration() { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountOutAsset = STELLAR_USDC + txn24.amountFeeAsset = FIAT_USD + txn24.transferReceivedAt = Instant.now() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals("RPC method[do_stellar_refund] requires enabled custody integration", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_sent_more_then_amount_in() { + val transferReceivedAt = Instant.now() + val request = + DoStellarRefundRequest.builder() + .transactionId(TX_ID) + .refund( + DoStellarRefundRequest.Refund.builder() + .amount(AmountAssetRequest("1", STELLAR_USDC)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .build() + ) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = WITHDRAWAL.kind + txn24.transferReceivedAt = transferReceivedAt + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountInAsset = STELLAR_USDC + txn24.amountIn = "1.1" + txn24.amountOutAsset = FIAT_USD + txn24.amountOut = "1" + txn24.amountFeeAsset = STELLAR_USDC + txn24.amountFee = "0.1" + txn24.refundMemo = MEMO + txn24.refundMemoType = MEMO_TYPE + + val payment = JdbcSep24RefundPayment() + payment.id = "1" + payment.amount = "0.1" + payment.fee = "0" + val refunds = JdbcSep24Refunds() + refunds.amountRefunded = "1" + refunds.amountFee = "0.1" + refunds.payments = listOf(payment) + txn24.refunds = refunds + + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + val ex = assertThrows { handler.handle(request) } + assertEquals("Refund amount exceeds amount_in", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_ok() { val transferReceivedAt = Instant.now() val request = DoStellarRefundRequest.builder() @@ -329,6 +408,7 @@ class DoStellarRefundHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -341,6 +421,7 @@ class DoStellarRefundHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -426,7 +507,8 @@ class DoStellarRefundHandlerTest { val sep31TxnCapture = slot() val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns null + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null every { custodyConfig.isCustodyIntegrationEnabled } returns true @@ -465,7 +547,7 @@ class DoStellarRefundHandlerTest { expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt expectedResponse.transferReceivedAt = transferReceivedAt - expectedResponse.customers = Customers(StellarId(null, null), StellarId(null, null)) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -491,58 +573,6 @@ class DoStellarRefundHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_sep24_sent_more_then_amount_in() { - val transferReceivedAt = Instant.now() - val request = - DoStellarRefundRequest.builder() - .transactionId(TX_ID) - .refund( - DoStellarRefundRequest.Refund.builder() - .amount(AmountAssetRequest("1", STELLAR_USDC)) - .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) - .build() - ) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = transferReceivedAt - txn24.requestAssetCode = FIAT_USD_CODE - txn24.amountInAsset = STELLAR_USDC - txn24.amountIn = "1.1" - txn24.amountOutAsset = FIAT_USD - txn24.amountOut = "1" - txn24.amountFeeAsset = STELLAR_USDC - txn24.amountFee = "0.1" - txn24.refundMemo = MEMO - txn24.refundMemoType = MEMO_TYPE - - val payment = JdbcSep24RefundPayment() - payment.id = "1" - payment.amount = "0.1" - payment.fee = "0" - val refunds = JdbcSep24Refunds() - refunds.amountRefunded = "1" - refunds.amountFee = "0.1" - refunds.payments = listOf(payment) - txn24.refunds = refunds - - val sep24TxnCapture = slot() - - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(TX_ID) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns true - - val ex = assertThrows { handler.handle(request) } - assertEquals("Refund amount exceeds amount_in", ex.message) - - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - @Test fun test_handle_sep31_sent_more_then_amount_in() { val transferReceivedAt = Instant.now() @@ -567,7 +597,8 @@ class DoStellarRefundHandlerTest { txn31.amountFee = "0.1" val sep31TxnCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns null + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null every { custodyConfig.isCustodyIntegrationEnabled } returns true @@ -575,6 +606,7 @@ class DoStellarRefundHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals("Refund amount exceeds amount_in", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -604,14 +636,16 @@ class DoStellarRefundHandlerTest { txn31.amountFee = "0.1" val sep31TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns null - every { txn31Store.findByTransactionId(TX_ID) } returns txn31 + every { txn31Store.findByTransactionId(any()) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null every { custodyConfig.isCustodyIntegrationEnabled } returns true val ex = assertThrows { handler.handle(request) } assertEquals("Refund amount is less than amount_in", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -650,7 +684,8 @@ class DoStellarRefundHandlerTest { txn31.refunds = refunds val sep31TxnCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns null + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null every { custodyConfig.isCustodyIntegrationEnabled } returns true @@ -661,8 +696,246 @@ class DoStellarRefundHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[do_stellar_refund] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_disabledCustodyIntegration(kind: String) { + val request = DoStellarRefundRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountOutAsset = STELLAR_USDC + txn6.amountFeeAsset = FIAT_USD + txn6.transferReceivedAt = Instant.now() + txn6.kind = kind + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + + val ex = assertThrows { handler.handle(request) } + assertEquals("RPC method[do_stellar_refund] requires enabled custody integration", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_sent_more_then_amount_in(kind: String) { + val transferReceivedAt = Instant.now() + val request = + DoStellarRefundRequest.builder() + .transactionId(TX_ID) + .refund( + DoStellarRefundRequest.Refund.builder() + .amount(AmountAssetRequest("1", STELLAR_USDC)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .build() + ) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = STELLAR_USDC + txn6.amountIn = "1.1" + txn6.amountOutAsset = FIAT_USD + txn6.amountOut = "1" + txn6.amountFeeAsset = STELLAR_USDC + txn6.amountFee = "0.1" + txn6.refundMemo = MEMO + txn6.refundMemoType = MEMO_TYPE + + val payment = RefundPayment() + payment.id = "1" + payment.amount = Amount("0.1", STELLAR_USDC) + payment.fee = Amount("0", STELLAR_USDC) + val refunds = Refunds() + refunds.amountRefunded = Amount("1", STELLAR_USDC) + refunds.amountFee = Amount("0.1", STELLAR_USDC) + refunds.payments = arrayOf(payment) + txn6.refunds = refunds + + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + val ex = assertThrows { handler.handle(request) } + assertEquals("Refund amount exceeds amount_in", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok(kind: String) { + val transferReceivedAt = Instant.now() + val request = + DoStellarRefundRequest.builder() + .transactionId(TX_ID) + .refund( + DoStellarRefundRequest.Refund.builder() + .amount(AmountAssetRequest("1", STELLAR_USDC)) + .amountFee(AmountAssetRequest("0.1", FIAT_USD)) + .build() + ) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = STELLAR_USDC + txn6.amountIn = "1.1" + txn6.amountOutAsset = STELLAR_USDC + txn6.amountOut = "1" + txn6.amountFeeAsset = FIAT_USD + txn6.amountFee = "0.1" + txn6.refundMemo = MEMO + txn6.refundMemoType = MEMO_TYPE + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = SepTransactionStatus.PENDING_STELLAR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountInAsset = STELLAR_USDC + expectedSep6Txn.amountIn = "1.1" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "1" + expectedSep6Txn.amountFeeAsset = FIAT_USD + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.refundMemo = MEMO + expectedSep6Txn.refundMemoType = MEMO_TYPE + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = SepTransactionStatus.PENDING_STELLAR + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.amountIn = Amount("1.1", STELLAR_USDC) + expectedResponse.amountOut = Amount("1", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", FIAT_USD) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.refundMemo = MEMO + expectedResponse.refundMemoType = MEMO_TYPE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedTest.kt index c947b8d76f..14800814b7 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -17,15 +19,17 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountRequest import org.stellar.anchor.api.rpc.method.NotifyAmountsUpdatedRequest import org.stellar.anchor.api.sep.SepTransactionStatus.INCOMPLETE import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.event.EventService @@ -33,10 +37,12 @@ import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyAmountsUpdatedTest { @@ -51,6 +57,8 @@ class NotifyAmountsUpdatedTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -76,6 +84,7 @@ class NotifyAmountsUpdatedTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyAmountsUpdatedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -94,6 +103,7 @@ class NotifyAmountsUpdatedTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -104,27 +114,28 @@ class NotifyAmountsUpdatedTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyAmountsUpdatedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() + txn24.status = PENDING_ANCHOR.toString() txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_amounts_updated] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[true]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } @@ -132,63 +143,83 @@ class NotifyAmountsUpdatedTest { } @Test - fun test_handle_unsupportedKind() { - val request = NotifyAmountsUpdatedRequest.builder().transactionId(TX_ID).build() + fun test_handle_invalidAmounts() { + val request = + NotifyAmountsUpdatedRequest.builder() + .transactionId(TX_ID) + .amountOut(AmountRequest("1")) + .amountFee(AmountRequest("1")) + .build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() - txn24.kind = DEPOSIT.kind + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountOutAsset = STELLAR_USDC + txn24.amountFeeAsset = STELLAR_USDC txn24.transferReceivedAt = Instant.now() + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_amounts_updated] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", - ex.message - ) + request.amountOut.amount = "-1" + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.amount should be positive", ex.message) + request.amountOut.amount = "1" + request.amountFee.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.amount should be non-negative", ex.message) + request.amountFee.amount = "1" + + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferNotReceived() { + fun test_handle_sep24_unsupportedStatus() { val request = NotifyAmountsUpdatedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind - txn24.status = PENDING_ANCHOR.toString() + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_amounts_updated] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_amounts_updated] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[true]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyAmountsUpdatedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind + txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_amounts_updated] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", + ex.message + ) verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } @@ -196,35 +227,20 @@ class NotifyAmountsUpdatedTest { } @Test - fun test_handle_invalidAmounts() { - val request = - NotifyAmountsUpdatedRequest.builder() - .transactionId(TX_ID) - .amountOut(AmountRequest("1")) - .amountFee(AmountRequest("1")) - .build() + fun test_handle_sep24_transferNotReceived() { + val request = NotifyAmountsUpdatedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = FIAT_USD_CODE - txn24.amountOutAsset = STELLAR_USDC - txn24.amountFeeAsset = STELLAR_USDC - txn24.transferReceivedAt = Instant.now() - val sep24TxnCapture = slot() + txn24.status = PENDING_ANCHOR.toString() every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - - request.amountOut.amount = "-1" - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.amount should be positive", ex.message) - request.amountOut.amount = "1" - request.amountFee.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.amount should be non-negative", ex.message) - request.amountFee.amount = "1" + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_amounts_updated] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } @@ -232,7 +248,7 @@ class NotifyAmountsUpdatedTest { } @Test - fun test_handle_ok() { + fun test_handle_sep24_ok() { val transferReceivedAt = Instant.now() val request = NotifyAmountsUpdatedRequest.builder() @@ -315,4 +331,106 @@ class NotifyAmountsUpdatedTest { assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } + + @ParameterizedTest + @CsvSource( + value = + [ + "deposit, incomplete", + "deposit, pending_anchor", + "deposit, pending_customer_info_update", + "deposit-exchange, incomplete", + "deposit-exchange, pending_anchor", + "deposit-exchange, pending_customer_info_update", + "withdrawal, incomplete", + "withdrawal, pending_anchor", + "withdrawal, pending_customer_info_update", + "withdrawal-exchange, incomplete", + "withdrawal-exchange, pending_anchor", + "withdrawal-exchange, pending_customer_info_update" + ] + ) + fun test_handle_sep6_ok(kind: String, status: String) { + val request = + NotifyAmountsUpdatedRequest.builder() + .transactionId(TX_ID) + .amountOut(AmountRequest("0.9")) + .amountFee(AmountRequest("0.1")) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = status + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountOutAsset = STELLAR_USDC + txn6.amountOut = "1.8" + txn6.amountFeeAsset = STELLAR_USDC + txn6.amountFee = "0.2" + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandlerTest.kt index c3c165cbea..afd3a323b2 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandlerTest.kt @@ -32,6 +32,7 @@ import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTIO import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyCustomerInfoUpdatedHandlerTest { @@ -42,6 +43,8 @@ class NotifyCustomerInfoUpdatedHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -67,6 +70,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { eventSession this.handler = NotifyCustomerInfoUpdatedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -83,6 +87,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { txn31.status = PENDING_CUSTOMER_INFO_UPDATE.toString() val spyTxn31 = spyk(txn31) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns spyTxn31 every { spyTxn31.protocol } returns SEP_38.sep.toString() @@ -93,6 +98,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -104,6 +110,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { val txn31 = JdbcSep31Transaction() txn31.status = INCOMPLETE.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 @@ -113,6 +120,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -124,6 +132,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { val txn31 = JdbcSep31Transaction() txn31.status = PENDING_CUSTOMER_INFO_UPDATE.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { requestValidator.validate(request) } throws @@ -132,6 +141,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -145,6 +155,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { val sep31TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -156,6 +167,7 @@ class NotifyCustomerInfoUpdatedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandlerTest.kt index 49b031672b..ffde49f22f 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandlerTest.kt @@ -38,6 +38,7 @@ import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTIO import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyInteractiveFlowCompletedHandlerTest { @@ -52,6 +53,8 @@ class NotifyInteractiveFlowCompletedHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -77,6 +80,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyInteractiveFlowCompletedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -94,6 +98,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { txn24.status = INCOMPLETE.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -104,6 +109,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -116,6 +122,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { txn24.kind = DEPOSIT.kind txn24.status = PENDING_ANCHOR.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -125,6 +132,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -136,6 +144,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -144,6 +153,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -166,6 +176,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -177,6 +188,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -249,6 +261,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -260,6 +273,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -332,6 +346,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { txn24.requestAssetCode = FIAT_USD_CODE val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -355,6 +370,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { ex = assertThrows { handler.handle(request) } assertEquals("amount_expected.amount should be positive", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -376,6 +392,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { txn24.requestAssetCode = FIAT_USD_CODE val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -395,6 +412,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { assertEquals("amount_fee.asset should be non-stellar asset", ex.message) request.amountFee.asset = FIAT_USD + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -416,6 +434,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { txn24.requestAssetCode = FIAT_USD_CODE val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -435,6 +454,7 @@ class NotifyInteractiveFlowCompletedHandlerTest { assertEquals("amount_fee.asset should be stellar asset", ex.message) request.amountFee.asset = STELLAR_USDC + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandlerTest.kt index 8eee35b113..78d390fb71 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,23 +18,27 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyOffchainFundsAvailableRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.event.EventService import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyOffchainFundsAvailableHandlerTest { @@ -44,6 +50,8 @@ class NotifyOffchainFundsAvailableHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -68,6 +76,7 @@ class NotifyOffchainFundsAvailableHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyOffchainFundsAvailableHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -86,6 +95,7 @@ class NotifyOffchainFundsAvailableHandlerTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -96,99 +106,108 @@ class NotifyOffchainFundsAvailableHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_TRUST.toString() + txn24.status = PENDING_ANCHOR.toString() txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_offchain_funds_available] is not supported. Status[pending_trust], kind[withdrawal], protocol[24], funds received[true]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { + fun test_handle_sep24_unsupportedStatus() { val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.kind = DEPOSIT.kind + txn24.status = PENDING_TRUST.toString() + txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_trust], kind[withdrawal], protocol[24], funds received[true]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferNotReceived() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind + txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_transferNotReceived() { val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_deposit_withExternalTxId() { + fun test_handle_sep24_ok_deposit_withExternalTxId() { val transferReceivedAt = Instant.now() val request = NotifyOffchainFundsAvailableRequest.builder() @@ -202,6 +221,7 @@ class NotifyOffchainFundsAvailableHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -213,6 +233,7 @@ class NotifyOffchainFundsAvailableHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -262,7 +283,7 @@ class NotifyOffchainFundsAvailableHandlerTest { } @Test - fun test_handle_ok_deposit_withoutExternalTxId() { + fun test_handle_sep24_ok_deposit_withoutExternalTxId() { val transferReceivedAt = Instant.now() val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() @@ -272,6 +293,7 @@ class NotifyOffchainFundsAvailableHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -283,6 +305,7 @@ class NotifyOffchainFundsAvailableHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -328,4 +351,230 @@ class NotifyOffchainFundsAvailableHandlerTest { assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferNotReceived(kind: String) { + val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_available] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_deposit_withExternalTxId(kind: String) { + val transferReceivedAt = Instant.now() + val request = + NotifyOffchainFundsAvailableRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_COMPLETE.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_COMPLETE + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_deposit_withoutExternalTxId(kind: String) { + val transferReceivedAt = Instant.now() + val request = NotifyOffchainFundsAvailableRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_COMPLETE.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_COMPLETE + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandlerTest.kt index f7986dbee2..43fb422d97 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,6 +18,7 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyOffchainFundsPendingRequest @@ -30,10 +33,12 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyOffchainFundsPendingHandlerTest { @@ -45,6 +50,8 @@ class NotifyOffchainFundsPendingHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -69,6 +76,7 @@ class NotifyOffchainFundsPendingHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyOffchainFundsPendingHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -79,7 +87,7 @@ class NotifyOffchainFundsPendingHandlerTest { } @Test - fun test_handle_sep24_unsupportedProtocol() { + fun test_handle_unsupportedProtocol() { val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() @@ -87,6 +95,7 @@ class NotifyOffchainFundsPendingHandlerTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -97,6 +106,7 @@ class NotifyOffchainFundsPendingHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -110,6 +120,7 @@ class NotifyOffchainFundsPendingHandlerTest { txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -119,6 +130,7 @@ class NotifyOffchainFundsPendingHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -132,6 +144,7 @@ class NotifyOffchainFundsPendingHandlerTest { txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -141,6 +154,7 @@ class NotifyOffchainFundsPendingHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -153,6 +167,7 @@ class NotifyOffchainFundsPendingHandlerTest { txn24.status = PENDING_ANCHOR.toString() txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -162,6 +177,7 @@ class NotifyOffchainFundsPendingHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -175,6 +191,7 @@ class NotifyOffchainFundsPendingHandlerTest { txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -183,6 +200,7 @@ class NotifyOffchainFundsPendingHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -203,6 +221,7 @@ class NotifyOffchainFundsPendingHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -214,6 +233,7 @@ class NotifyOffchainFundsPendingHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -262,6 +282,76 @@ class NotifyOffchainFundsPendingHandlerTest { assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } + @Test + fun test_handle_ok_sep24_deposit_withoutExternalTxId() { + val transferReceivedAt = Instant.now() + val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = WITHDRAWAL.kind + txn24.transferReceivedAt = transferReceivedAt + val sep24TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep24Txn = JdbcSep24Transaction() + expectedSep24Txn.kind = WITHDRAWAL.kind + expectedSep24Txn.status = PENDING_EXTERNAL.toString() + expectedSep24Txn.updatedAt = sep24TxnCapture.captured.updatedAt + expectedSep24Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep24Txn), + gson.toJson(sep24TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_24 + expectedResponse.kind = WITHDRAWAL + expectedResponse.status = PENDING_EXTERNAL + expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt + expectedResponse.amountExpected = Amount(null, "") + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_24.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) + } + @Test fun test_handle_ok_sep31_deposit_withExternalTxId() { val request = @@ -274,6 +364,7 @@ class NotifyOffchainFundsPendingHandlerTest { val sep31TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -285,6 +376,7 @@ class NotifyOffchainFundsPendingHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -335,49 +427,207 @@ class NotifyOffchainFundsPendingHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_ok_sep24_deposit_withoutExternalTxId() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_pending] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_pending] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferNotReceived(kind: String) { + val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_pending] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_deposit_withExternalTxId(kind: String) { + val transferReceivedAt = Instant.now() + val request = + NotifyOffchainFundsPendingRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_EXTERNAL.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) + expectedResponse.status = PENDING_EXTERNAL + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_deposit_withoutExternalTxId(kind: String) { val transferReceivedAt = Instant.now() val request = NotifyOffchainFundsPendingRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = transferReceivedAt - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null every { eventSession.publish(capture(anchorEventCapture)) } just Runs - every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns sepTransactionCounter val startDate = Instant.now() val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } - val expectedSep24Txn = JdbcSep24Transaction() - expectedSep24Txn.kind = WITHDRAWAL.kind - expectedSep24Txn.status = PENDING_EXTERNAL.toString() - expectedSep24Txn.updatedAt = sep24TxnCapture.captured.updatedAt - expectedSep24Txn.transferReceivedAt = transferReceivedAt + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_EXTERNAL.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt JSONAssert.assertEquals( - gson.toJson(expectedSep24Txn), - gson.toJson(sep24TxnCapture.captured), + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), JSONCompareMode.STRICT ) val expectedResponse = GetTransactionResponse() - expectedResponse.sep = SEP_24 - expectedResponse.kind = WITHDRAWAL + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) expectedResponse.status = PENDING_EXTERNAL - expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -388,7 +638,7 @@ class NotifyOffchainFundsPendingHandlerTest { val expectedEvent = AnchorEvent.builder() .id(anchorEventCapture.captured.id) - .sep(SEP_24.sep.toString()) + .sep(SEP_6.sep.toString()) .type(TRANSACTION_STATUS_CHANGED) .transaction(expectedResponse) .build() @@ -399,7 +649,7 @@ class NotifyOffchainFundsPendingHandlerTest { JSONCompareMode.STRICT ) - assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) - assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandlerTest.kt index 84f94718f4..8d06824153 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -17,14 +19,16 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountRequest import org.stellar.anchor.api.rpc.method.NotifyOffchainFundsReceivedRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.config.CustodyConfig @@ -34,11 +38,13 @@ import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24Transaction import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyOffchainFundsReceivedHandlerTest { @@ -54,6 +60,8 @@ class NotifyOffchainFundsReceivedHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -83,6 +91,7 @@ class NotifyOffchainFundsReceivedHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyOffchainFundsReceivedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -102,6 +111,7 @@ class NotifyOffchainFundsReceivedHandlerTest { txn24.status = PENDING_USR_TRANSFER_START.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -112,98 +122,181 @@ class NotifyOffchainFundsReceivedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() + txn24.status = PENDING_USR_TRANSFER_START.toString() txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_notAllAmounts() { + val request = + NotifyOffchainFundsReceivedRequest.builder() + .amountIn(AmountRequest("1")) + .amountOut(AmountRequest("1")) + .transactionId(TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_USR_TRANSFER_START.toString() + txn24.kind = DEPOSIT.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_received] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", + "Invalid amounts combination provided: all, none or only amount_in should be set", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { - val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + fun test_handle_invalidAmounts() { + val request = + NotifyOffchainFundsReceivedRequest.builder() + .amountIn(AmountRequest("1")) + .amountOut(AmountRequest("1")) + .amountFee(AmountRequest("1")) + .transactionId(TX_ID) + .build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = WITHDRAWAL.kind + txn24.kind = DEPOSIT.kind + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountInAsset = FIAT_USD + txn24.amountOutAsset = STELLAR_USDC + txn24.amountFeeAsset = STELLAR_USDC + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + request.amountIn.amount = "-1" + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.amount should be positive", ex.message) + request.amountIn.amount = "1" + + request.amountOut.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.amount should be positive", ex.message) + request.amountOut.amount = "1" + + request.amountFee.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.amount should be non-negative", ex.message) + request.amountFee.amount = "1" + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_unsupportedStatus() { + val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + + every { txn6Store.findByTransactionId(TX_ID) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_offchain_funds_received] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferReceived() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_EXTERNAL.toString() + txn24.status = PENDING_USR_TRANSFER_START.toString() txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(TX_ID) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_received] is not supported. Status[pending_external], kind[withdrawal], protocol[24], funds received[true]", + "RPC method[notify_offchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[withdrawal], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_transferReceived() { val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = DEPOSIT.kind + txn24.status = PENDING_EXTERNAL.toString() + txn24.kind = WITHDRAWAL.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_received] is not supported. Status[pending_external], kind[withdrawal], protocol[24], funds received[true]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_withAmountsAndExternalTxIdAndFundsReceivedAt() { + fun test_handle_sep24_ok_withAmountsAndExternalTxIdAndFundsReceivedAt() { val transferReceivedAt = Instant.now() val request = NotifyOffchainFundsReceivedRequest.builder() @@ -224,6 +317,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -236,6 +330,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -296,7 +391,7 @@ class NotifyOffchainFundsReceivedHandlerTest { } @Test - fun test_handle_ok_onlyWithAmountIn() { + fun test_handle_sep24_ok_onlyWithAmountIn() { val request = NotifyOffchainFundsReceivedRequest.builder() .transactionId(TX_ID) @@ -310,6 +405,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -322,6 +418,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -374,7 +471,7 @@ class NotifyOffchainFundsReceivedHandlerTest { } @Test - fun test_handle_ok_withExternalTxIdAndWithoutFundsReceivedAt_custodyIntegrationEnabled() { + fun test_handle_sep24_ok_withExternalTxIdAndWithoutFundsReceivedAt_custodyIntegrationEnabled() { val request = NotifyOffchainFundsReceivedRequest.builder() .transactionId(TX_ID) @@ -388,6 +485,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val sep24CustodyTxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -401,6 +499,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -460,7 +559,7 @@ class NotifyOffchainFundsReceivedHandlerTest { } @Test - fun test_handle_ok_withoutAmountAndExternalTxIdAndFundsReceivedAt() { + fun test_handle_sep24_ok_withoutAmountAndExternalTxIdAndFundsReceivedAt() { val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_USR_TRANSFER_START.toString() @@ -469,6 +568,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -480,6 +580,7 @@ class NotifyOffchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -529,73 +630,398 @@ class NotifyOffchainFundsReceivedHandlerTest { assertTrue(sep24TxnCapture.captured.transferReceivedAt <= endDate) } - @Test - fun test_handle_notAllAmounts() { - val request = - NotifyOffchainFundsReceivedRequest.builder() - .amountIn(AmountRequest("1")) - .amountOut(AmountRequest("1")) - .transactionId(TX_ID) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = DEPOSIT.kind - val sep24TxnCapture = slot() + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } + val ex = assertThrows { handler.handle(request) } assertEquals( - "Invalid amounts combination provided: all, none or only amount_in should be set", + "RPC method[notify_offchain_funds_received] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_invalidAmounts() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_withAmountsAndExternalTxIdAndFundsReceivedAt(kind: String) { + val transferReceivedAt = Instant.now() val request = NotifyOffchainFundsReceivedRequest.builder() + .transactionId(TX_ID) .amountIn(AmountRequest("1")) - .amountOut(AmountRequest("1")) - .amountFee(AmountRequest("1")) + .amountOut(AmountRequest("0.9")) + .amountFee(AmountRequest("0.1")) + .externalTransactionId(EXTERNAL_TX_ID) + .fundsReceivedAt(transferReceivedAt) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = FIAT_USD + txn6.amountOutAsset = STELLAR_USDC + txn6.amountFeeAsset = STELLAR_USDC + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_onlyWithAmountIn(kind: String) { + val request = + NotifyOffchainFundsReceivedRequest.builder() .transactionId(TX_ID) + .amountIn(AmountRequest("1")) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = DEPOSIT.kind - txn24.requestAssetCode = FIAT_USD_CODE - txn24.amountInAsset = FIAT_USD - txn24.amountOutAsset = STELLAR_USDC - txn24.amountFeeAsset = STELLAR_USDC - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = FIAT_USD + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - request.amountIn.amount = "-1" - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.amount should be positive", ex.message) - request.amountIn.amount = "1" + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() - request.amountOut.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.amount should be positive", ex.message) - request.amountOut.amount = "1" + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } - request.amountFee.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.amount should be non-negative", ex.message) - request.amountFee.amount = "1" + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_withExternalTxIdAndWithoutFundsReceivedAt_custodyIntegrationEnabled( + kind: String + ) { + val request = + NotifyOffchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + val sep6CustodyTxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { custodyService.createTransaction(capture(sep6CustodyTxnCapture)) } just Runs + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep6Txn.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6CustodyTxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt >= startDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_withoutAmountAndExternalTxIdAndFundsReceivedAt(kind: String) { + val request = NotifyOffchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt >= startDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt <= endDate) } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandlerTest.kt index 46ba3cf5ee..460ffb5c9c 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,6 +18,7 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyOffchainFundsSentRequest @@ -30,10 +33,12 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyOffchainFundsSentHandlerTest { @@ -45,6 +50,8 @@ class NotifyOffchainFundsSentHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -69,6 +76,7 @@ class NotifyOffchainFundsSentHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyOffchainFundsSentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -79,13 +87,14 @@ class NotifyOffchainFundsSentHandlerTest { } @Test - fun test_handle_sep24_unsupportedProtocol() { + fun test_handle_unsupportedProtocol() { val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() txn24.kind = DEPOSIT.kind val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -96,26 +105,28 @@ class NotifyOffchainFundsSentHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_sep24_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_EXTERNAL.toString() + txn24.status = PENDING_USR_TRANSFER_START.toString() txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_offchain_funds_sent] is not supported. Status[pending_external], kind[deposit], protocol[24], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } @@ -123,42 +134,46 @@ class NotifyOffchainFundsSentHandlerTest { } @Test - fun test_handle_sep24_transferNotReceived() { + fun test_handle_sep24_unsupportedStatus() { val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind + txn24.status = PENDING_EXTERNAL.toString() + txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_offchain_funds_sent] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_offchain_funds_sent] is not supported. Status[pending_external], kind[deposit], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_sep24_invalidRequest() { + fun test_handle_sep24_transferNotReceived() { val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = DEPOSIT.kind - txn24.transferReceivedAt = Instant.now() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_sent] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -179,6 +194,7 @@ class NotifyOffchainFundsSentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -190,6 +206,7 @@ class NotifyOffchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -238,88 +255,6 @@ class NotifyOffchainFundsSentHandlerTest { assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_ok_sep31_deposit_withExternalTxIdAndDate() { - val transferReceivedAt = Instant.now() - val request = - NotifyOffchainFundsSentRequest.builder() - .transactionId(TX_ID) - .externalTransactionId(EXTERNAL_TX_ID) - .fundsSentAt(transferReceivedAt.minusSeconds(100)) - .build() - val txn31 = JdbcSep31Transaction() - txn31.status = PENDING_RECEIVER.toString() - txn31.transferReceivedAt = transferReceivedAt - val sep31TxnCapture = slot() - val anchorEventCapture = slot() - - every { txn24Store.findByTransactionId(any()) } returns null - every { txn31Store.findByTransactionId(TX_ID) } returns txn31 - every { txn31Store.save(capture(sep31TxnCapture)) } returns null - every { eventSession.publish(capture(anchorEventCapture)) } just Runs - every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep31") } returns - sepTransactionCounter - - val startDate = Instant.now() - val response = handler.handle(request) - val endDate = Instant.now() - - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 1) { sepTransactionCounter.increment() } - - val expectedSep31Txn = JdbcSep31Transaction() - expectedSep31Txn.status = COMPLETED.toString() - expectedSep31Txn.updatedAt = sep31TxnCapture.captured.updatedAt - expectedSep31Txn.externalTransactionId = EXTERNAL_TX_ID - expectedSep31Txn.transferReceivedAt = transferReceivedAt - expectedSep31Txn.completedAt = sep31TxnCapture.captured.completedAt - - JSONAssert.assertEquals( - gson.toJson(expectedSep31Txn), - gson.toJson(sep31TxnCapture.captured), - JSONCompareMode.STRICT - ) - - val expectedResponse = GetTransactionResponse() - expectedResponse.sep = SEP_31 - expectedResponse.kind = RECEIVE - expectedResponse.status = COMPLETED - expectedResponse.externalTransactionId = EXTERNAL_TX_ID - expectedResponse.transferReceivedAt = transferReceivedAt - expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt - expectedResponse.completedAt = sep31TxnCapture.captured.completedAt - expectedResponse.amountIn = Amount() - expectedResponse.amountOut = Amount() - expectedResponse.amountFee = Amount() - expectedResponse.amountExpected = Amount() - expectedResponse.customers = Customers(StellarId(), StellarId()) - - JSONAssert.assertEquals( - gson.toJson(expectedResponse), - gson.toJson(response), - JSONCompareMode.STRICT - ) - - val expectedEvent = - AnchorEvent.builder() - .id(anchorEventCapture.captured.id) - .sep(SEP_31.sep.toString()) - .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) - .transaction(expectedResponse) - .build() - - JSONAssert.assertEquals( - gson.toJson(expectedEvent), - gson.toJson(anchorEventCapture.captured), - JSONCompareMode.STRICT - ) - - assertTrue(sep31TxnCapture.captured.updatedAt >= startDate) - assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) - assertTrue(sep31TxnCapture.captured.completedAt >= startDate) - assertTrue(sep31TxnCapture.captured.completedAt <= endDate) - } - @Test fun test_handle_ok_sep24_deposit_withExternalTxIdAndWithoutDate() { val request = @@ -333,6 +268,7 @@ class NotifyOffchainFundsSentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -344,6 +280,7 @@ class NotifyOffchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -403,6 +340,7 @@ class NotifyOffchainFundsSentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -414,6 +352,7 @@ class NotifyOffchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -476,6 +415,7 @@ class NotifyOffchainFundsSentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -487,6 +427,7 @@ class NotifyOffchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -550,6 +491,7 @@ class NotifyOffchainFundsSentHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -561,6 +503,7 @@ class NotifyOffchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -610,4 +553,525 @@ class NotifyOffchainFundsSentHandlerTest { assertTrue(sep24TxnCapture.captured.completedAt >= startDate) assertTrue(sep24TxnCapture.captured.completedAt <= endDate) } + + @Test + fun test_handle_ok_sep31_deposit_withExternalTxIdAndDate() { + val transferReceivedAt = Instant.now() + val request = + NotifyOffchainFundsSentRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .fundsSentAt(transferReceivedAt.minusSeconds(100)) + .build() + val txn31 = JdbcSep31Transaction() + txn31.status = PENDING_RECEIVER.toString() + txn31.transferReceivedAt = transferReceivedAt + val sep31TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(TX_ID) } returns txn31 + every { txn31Store.save(capture(sep31TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep31") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep31Txn = JdbcSep31Transaction() + expectedSep31Txn.status = COMPLETED.toString() + expectedSep31Txn.updatedAt = sep31TxnCapture.captured.updatedAt + expectedSep31Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep31Txn.transferReceivedAt = transferReceivedAt + expectedSep31Txn.completedAt = sep31TxnCapture.captured.completedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep31Txn), + gson.toJson(sep31TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_31 + expectedResponse.kind = RECEIVE + expectedResponse.status = COMPLETED + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt + expectedResponse.completedAt = sep31TxnCapture.captured.completedAt + expectedResponse.amountIn = Amount() + expectedResponse.amountOut = Amount() + expectedResponse.amountFee = Amount() + expectedResponse.amountExpected = Amount() + expectedResponse.customers = Customers(StellarId(), StellarId()) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_31.sep.toString()) + .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep31TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep31TxnCapture.captured.completedAt >= startDate) + assertTrue(sep31TxnCapture.captured.completedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange", "withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_sent] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_withdrawal_transferNotReceived(kind: String) { + val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_offchain_funds_sent] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_deposit_withExternalTxIdAndDate(kind: String) { + val transferReceivedAt = Instant.now() + val request = + NotifyOffchainFundsSentRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .fundsSentAt(transferReceivedAt) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_EXTERNAL.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_EXTERNAL + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_deposit_withExternalTxIdAndWithoutDate(kind: String) { + val request = + NotifyOffchainFundsSentRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_EXTERNAL.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep6Txn.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_EXTERNAL + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt >= startDate) + assertTrue(sep6TxnCapture.captured.transferReceivedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_deposit_withoutExternalTxIdAndDate(kind: String) { + val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_EXTERNAL.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = sep6TxnCapture.captured.transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_EXTERNAL + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withdrawal_withExternalTxIdAndDate(kind: String) { + val transferReceivedAt = Instant.now() + val request = + NotifyOffchainFundsSentRequest.builder() + .transactionId(TX_ID) + .externalTransactionId(EXTERNAL_TX_ID) + .fundsSentAt(transferReceivedAt.minusSeconds(100)) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = COMPLETED.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.completedAt = sep6TxnCapture.captured.completedAt + expectedSep6Txn.externalTransactionId = EXTERNAL_TX_ID + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = COMPLETED + expectedResponse.externalTransactionId = EXTERNAL_TX_ID + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.completedAt = sep6TxnCapture.captured.completedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.completedAt >= startDate) + assertTrue(sep6TxnCapture.captured.completedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withdrawal_withoutExternalTxId(kind: String) { + val transferReceivedAt = Instant.now() + val request = NotifyOffchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = COMPLETED.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.completedAt = sep6TxnCapture.captured.completedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = COMPLETED + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.completedAt = sep6TxnCapture.captured.completedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.completedAt >= startDate) + assertTrue(sep6TxnCapture.captured.completedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt index 47600d011c..fdd53e0dff 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt @@ -11,6 +11,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -20,6 +22,7 @@ import org.stellar.anchor.api.exception.rpc.InternalErrorException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountRequest @@ -38,10 +41,12 @@ import org.stellar.anchor.horizon.Horizon import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils import org.stellar.sdk.responses.operations.OperationResponse import org.stellar.sdk.responses.operations.PaymentOperationResponse @@ -60,6 +65,8 @@ class NotifyOnchainFundsReceivedHandlerTest { private const val STELLAR_PAYMENT_DATE = "2023-05-10T10:18:20Z" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -87,6 +94,7 @@ class NotifyOnchainFundsReceivedHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyOnchainFundsReceivedHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -98,13 +106,14 @@ class NotifyOnchainFundsReceivedHandlerTest { } @Test - fun test_handle_sep24_unsupportedProtocol() { + fun test_handle_unsupportedProtocol() { val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_USR_TRANSFER_START.toString() txn24.kind = WITHDRAWAL.kind val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -115,69 +124,150 @@ class NotifyOnchainFundsReceivedHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_sep24_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() + txn24.status = PENDING_USR_TRANSFER_START.toString() txn24.kind = WITHDRAWAL.kind + txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_notAllAmounts() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .amountIn(AmountRequest("1")) + .amountOut(AmountRequest("1")) + .transactionId(TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_USR_TRANSFER_START.toString() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_onchain_funds_received] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", + "Invalid amounts combination provided: all, none or only amount_in should be set", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_sep24_unsupportedKind() { - val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + fun test_handle_invalidAmounts() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .amountIn(AmountRequest("1")) + .amountOut(AmountRequest("1")) + .amountFee(AmountRequest("1")) + .transactionId(TX_ID) + .build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = DEPOSIT.kind + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountInAsset = FIAT_USD + txn24.amountOutAsset = STELLAR_USDC + txn24.amountFeeAsset = STELLAR_USDC + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + request.amountIn.amount = "-1" + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.amount should be positive", ex.message) + request.amountIn.amount = "1" + + request.amountOut.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.amount should be positive", ex.message) + request.amountOut.amount = "1" + + request.amountFee.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.amount should be non-negative", ex.message) + request.amountFee.amount = "1" + + request.amountIn.amount = "0" + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_unsupportedStatus() { + val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_onchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[deposit], protocol[24], funds received[false]", + "RPC method[notify_onchain_funds_received] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_sep24_invalidRequest() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = Instant.now() + txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[deposit], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -212,6 +302,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val stellarTransactions: List = gson.fromJson(stellarTransactions, stellarTransactionsToken) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -224,6 +315,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -308,6 +400,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val stellarTransactions: List = gson.fromJson(stellarTransactions, stellarTransactionsToken) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -320,6 +413,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -396,6 +490,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val stellarTransactions: List = gson.fromJson(stellarTransactions, stellarTransactionsToken) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -408,6 +503,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -458,6 +554,35 @@ class NotifyOnchainFundsReceivedHandlerTest { assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } + @Test + fun test_handle_ok_sep24_withoutAmounts_invalidStellarTransaction() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_USR_TRANSFER_START.toString() + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = FIAT_USD_CODE + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(any()) } throws + IOException("Invalid stellar transaction") + + val ex = assertThrows { handler.handle(request) } + assertEquals("Failed to retrieve Stellar transaction by ID[stellarTxId]", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + @Test fun test_handle_ok_sep31_withoutAmounts() { val request = @@ -479,6 +604,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val stellarTransactions: List = gson.fromJson(stellarTransactions, stellarTransactionsToken) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -491,6 +617,7 @@ class NotifyOnchainFundsReceivedHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -544,56 +671,369 @@ class NotifyOnchainFundsReceivedHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_ok_sep24_withoutAmounts_invalidStellarTransaction() { + @CsvSource(value = ["deposit", "deposit-exchange", "withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_received] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyOnchainFundsReceivedRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_received] is not supported. Status[pending_user_transfer_start], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withAmounts(kind: String) { val request = NotifyOnchainFundsReceivedRequest.builder() .transactionId(TX_ID) + .amountIn(AmountRequest("1")) + .amountOut(AmountRequest("0.9")) + .amountFee(AmountRequest("0.1")) .stellarTransactionId(STELLAR_TX_ID) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = FIAT_USD_CODE - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = FIAT_USD + txn6.amountOutAsset = STELLAR_USDC + txn6.amountFeeAsset = STELLAR_USDC + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + val operationRecordsTypeToken = + object : TypeToken>() {}.type + val operationRecords: ArrayList = + gson.fromJson(paymentOperationRecord, operationRecordsTypeToken) + + val stellarTransactionsToken = object : TypeToken>() {}.type + val stellarTransactions: List = + gson.fromJson(stellarTransactions, stellarTransactionsToken) + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { horizon.getStellarTxnOperations(any()) } throws - IOException("Invalid stellar transaction") + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(STELLAR_TX_ID) } returns operationRecords + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("Failed to retrieve Stellar transaction by ID[stellarTxId]", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.stellarTransactionId = STELLAR_TX_ID + expectedSep6Txn.stellarTransactions = stellarTransactions + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.stellarTransactions = stellarTransactions + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_sep24_notAllAmounts() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_onlyWithAmountIn(kind: String) { val request = NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) .amountIn(AmountRequest("1")) - .amountOut(AmountRequest("1")) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = FIAT_USD + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + val operationRecordsTypeToken = + object : TypeToken>() {}.type + val operationRecords: ArrayList = + gson.fromJson(paymentOperationRecord, operationRecordsTypeToken) + + val stellarTransactionsToken = object : TypeToken>() {}.type + val stellarTransactions: List = + gson.fromJson(stellarTransactions, stellarTransactionsToken) + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(STELLAR_TX_ID) } returns operationRecords + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.stellarTransactionId = STELLAR_TX_ID + expectedSep6Txn.stellarTransactions = stellarTransactions + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.stellarTransactions = stellarTransactions + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withoutAmounts(kind: String) { + val request = + NotifyOnchainFundsReceivedRequest.builder() .transactionId(TX_ID) + .stellarTransactionId(STELLAR_TX_ID) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + val operationRecordsTypeToken = + object : TypeToken>() {}.type + val operationRecords: ArrayList = + gson.fromJson(paymentOperationRecord, operationRecordsTypeToken) + + val stellarTransactionsToken = object : TypeToken>() {}.type + val stellarTransactions: List = + gson.fromJson(stellarTransactions, stellarTransactionsToken) + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(STELLAR_TX_ID) } returns operationRecords + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals( - "Invalid amounts combination provided: all, none or only amount_in should be set", - ex.message + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.stellarTransactionId = STELLAR_TX_ID + expectedSep6Txn.stellarTransactions = stellarTransactions + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT ) + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = Instant.parse(STELLAR_PAYMENT_DATE) + expectedResponse.amountExpected = Amount(null, FIAT_USD) + expectedResponse.stellarTransactions = stellarTransactions + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withoutAmounts_invalidStellarTransaction(kind: String) { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(any()) } throws + IOException("Invalid stellar transaction") + + val ex = assertThrows { handler.handle(request) } + assertEquals("Failed to retrieve Stellar transaction by ID[stellarTxId]", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -703,48 +1143,6 @@ class NotifyOnchainFundsReceivedHandlerTest { ] """ - @Test - fun test_handle_sep24_invalidAmounts() { - val request = - NotifyOnchainFundsReceivedRequest.builder() - .amountIn(AmountRequest("1")) - .amountOut(AmountRequest("1")) - .amountFee(AmountRequest("1")) - .transactionId(TX_ID) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_USR_TRANSFER_START.toString() - txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = FIAT_USD_CODE - txn24.amountInAsset = FIAT_USD - txn24.amountOutAsset = STELLAR_USDC - txn24.amountFeeAsset = STELLAR_USDC - val sep24TxnCapture = slot() - - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - - request.amountIn.amount = "-1" - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.amount should be positive", ex.message) - request.amountIn.amount = "1" - - request.amountOut.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.amount should be positive", ex.message) - request.amountOut.amount = "1" - - request.amountFee.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.amount should be non-negative", ex.message) - request.amountFee.amount = "1" - - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - private val stellarTransactions = """ [ diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandlerTest.kt index 9ce00f4e88..0adef2b5fd 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandlerTest.kt @@ -11,6 +11,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -19,12 +21,15 @@ import org.stellar.anchor.api.exception.rpc.InternalErrorException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse -import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.* -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyOnchainFundsSentRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.api.shared.StellarTransaction import org.stellar.anchor.asset.AssetService import org.stellar.anchor.event.EventService @@ -33,10 +38,12 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.horizon.Horizon import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils import org.stellar.sdk.responses.operations.OperationResponse import org.stellar.sdk.responses.operations.PaymentOperationResponse @@ -50,6 +57,8 @@ class NotifyOnchainFundsSentHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -76,6 +85,7 @@ class NotifyOnchainFundsSentHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyOnchainFundsSentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -93,6 +103,7 @@ class NotifyOnchainFundsSentHandlerTest { txn24.status = PENDING_STELLAR.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -103,98 +114,137 @@ class NotifyOnchainFundsSentHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { + val transferReceivedAt = Instant.now() val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() + txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = transferReceivedAt + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_onchain_funds_sent] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_invalidStellarTransaction() { + val transferReceivedAt = Instant.now() + val request = + NotifyOnchainFundsSentRequest.builder() + .transactionId(TX_ID) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = transferReceivedAt + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(any()) } throws + IOException("Invalid stellar transaction") + + val ex = assertThrows { handler.handle(request) } + assertEquals("Failed to retrieve Stellar transaction by ID[stellarTxId]", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { + fun test_handle_sep24_unsupportedStatus() { val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_STELLAR.toString() - txn24.kind = WITHDRAWAL.kind + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_stellar], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_onchain_funds_sent] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferNotReceived() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = PENDING_STELLAR.toString() txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_stellar], kind[withdrawal], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { - val transferReceivedAt = Instant.now() + fun test_handle_sep24_transferNotReceived() { val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() - txn24.kind = DEPOSIT.kind - txn24.transferReceivedAt = transferReceivedAt + txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok() { + fun test_handle_sep24_ok() { val transferReceivedAt = Instant.now() val request = NotifyOnchainFundsSentRequest.builder() @@ -217,6 +267,7 @@ class NotifyOnchainFundsSentHandlerTest { val stellarTransactions: List = gson.fromJson(stellarTransactions, stellarTransactionsToken) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -229,6 +280,7 @@ class NotifyOnchainFundsSentHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -282,32 +334,170 @@ class NotifyOnchainFundsSentHandlerTest { assertTrue(sep24TxnCapture.captured.completedAt <= endDate) } - @Test - fun test_handle_invalidStellarTransaction() { + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_sent] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_STELLAR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_stellar], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferNotReceived(kind: String) { + val request = NotifyOnchainFundsSentRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_onchain_funds_sent] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok(kind: String) { val transferReceivedAt = Instant.now() val request = NotifyOnchainFundsSentRequest.builder() .transactionId(TX_ID) .stellarTransactionId(STELLAR_TX_ID) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() - txn24.kind = DEPOSIT.kind - txn24.transferReceivedAt = transferReceivedAt - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + val operationRecordsTypeToken = + object : TypeToken>() {}.type + val operationRecords: ArrayList = + gson.fromJson(paymentOperationRecord, operationRecordsTypeToken) + + val stellarTransactionsToken = object : TypeToken>() {}.type + val stellarTransactions: List = + gson.fromJson(stellarTransactions, stellarTransactionsToken) + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { horizon.getStellarTxnOperations(any()) } throws - IOException("Invalid stellar transaction") + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { horizon.getStellarTxnOperations(STELLAR_TX_ID) } returns operationRecords + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("Failed to retrieve Stellar transaction by ID[stellarTxId]", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = COMPLETED.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.completedAt = sep6TxnCapture.captured.completedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + expectedSep6Txn.stellarTransactionId = STELLAR_TX_ID + expectedSep6Txn.stellarTransactions = stellarTransactions + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = COMPLETED + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.completedAt = sep6TxnCapture.captured.completedAt + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.stellarTransactions = stellarTransactions + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + assertTrue(sep6TxnCapture.captured.completedAt >= startDate) + assertTrue(sep6TxnCapture.captured.completedAt <= endDate) } private val paymentOperationRecord = diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandlerTest.kt index 955419796b..7208642db0 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandlerTest.kt @@ -40,6 +40,7 @@ import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTIO import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyRefundPendingHandlerTest { @@ -53,6 +54,8 @@ class NotifyRefundPendingHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -78,6 +81,7 @@ class NotifyRefundPendingHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyRefundPendingHandler( + txn6Store, txn24Store, txn31Store, requestValidator, diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundSentHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundSentHandlerTest.kt index 5b899cdfbc..060c99bc40 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundSentHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundSentHandlerTest.kt @@ -19,26 +19,19 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse -import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT -import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE -import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.* +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountAssetRequest import org.stellar.anchor.api.rpc.method.NotifyRefundSentRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* -import org.stellar.anchor.api.shared.Amount -import org.stellar.anchor.api.shared.Customers -import org.stellar.anchor.api.shared.RefundPayment -import org.stellar.anchor.api.shared.Refunds -import org.stellar.anchor.api.shared.StellarId +import org.stellar.anchor.api.shared.* import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.event.EventService import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService +import org.stellar.anchor.platform.data.* import org.stellar.anchor.platform.data.JdbcSep24RefundPayment import org.stellar.anchor.platform.data.JdbcSep24Refunds import org.stellar.anchor.platform.data.JdbcSep24Transaction @@ -49,6 +42,7 @@ import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTIO import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyRefundSentHandlerTest { @@ -62,6 +56,8 @@ class NotifyRefundSentHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -87,6 +83,7 @@ class NotifyRefundSentHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = NotifyRefundSentHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -1017,7 +1014,7 @@ class NotifyRefundSentHandlerTest { expectedResponse.amountFee = Amount("0", STELLAR_USDC) expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt expectedResponse.transferReceivedAt = transferReceivedAt - expectedResponse.customers = Customers(StellarId(null, null), StellarId(null, null)) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) val refundPayment = RefundPayment() refundPayment.amount = Amount("1", txn31.amountInAsset) diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandlerTest.kt index aafcbedcc3..67290245f7 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandlerTest.kt @@ -18,9 +18,7 @@ import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyTransactionErrorRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount @@ -33,11 +31,13 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.data.JdbcTransactionPendingTrustRepo import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyTransactionErrorHandlerTest { @@ -49,6 +49,8 @@ class NotifyTransactionErrorHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -76,6 +78,7 @@ class NotifyTransactionErrorHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyTransactionErrorHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -93,6 +96,7 @@ class NotifyTransactionErrorHandlerTest { txn24.status = ERROR.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -103,6 +107,7 @@ class NotifyTransactionErrorHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -114,6 +119,7 @@ class NotifyTransactionErrorHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = EXPIRED.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -123,6 +129,7 @@ class NotifyTransactionErrorHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -134,6 +141,7 @@ class NotifyTransactionErrorHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = COMPLETED.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -143,6 +151,7 @@ class NotifyTransactionErrorHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -154,12 +163,14 @@ class NotifyTransactionErrorHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals("message is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -172,6 +183,7 @@ class NotifyTransactionErrorHandlerTest { txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -180,6 +192,7 @@ class NotifyTransactionErrorHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -196,6 +209,7 @@ class NotifyTransactionErrorHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -209,6 +223,7 @@ class NotifyTransactionErrorHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { transactionPendingTrustRepo.deleteById(TX_ID) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -268,6 +283,7 @@ class NotifyTransactionErrorHandlerTest { val sep31TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -277,6 +293,7 @@ class NotifyTransactionErrorHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } val expectedSep31Txn = JdbcSep31Transaction() @@ -300,7 +317,7 @@ class NotifyTransactionErrorHandlerTest { expectedResponse.amountFee = Amount(null, null) expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt expectedResponse.message = TX_MESSAGE - expectedResponse.customers = Customers(StellarId(null, null), StellarId(null, null)) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -325,4 +342,81 @@ class NotifyTransactionErrorHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt >= startDate) assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } + + @Test + fun test_handle_ok_sep6() { + val request = + NotifyTransactionErrorRequest.builder().transactionId(TX_ID).message(TX_MESSAGE).build() + val txn6 = JdbcSep6Transaction() + txn6.id = TX_ID + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = DEPOSIT.kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { transactionPendingTrustRepo.deleteById(TX_ID) } just Runs + every { transactionPendingTrustRepo.existsById(TX_ID) } returns true + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { transactionPendingTrustRepo.deleteById(TX_ID) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.id = TX_ID + expectedSep6Txn.kind = DEPOSIT.kind + expectedSep6Txn.status = ERROR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.message = TX_MESSAGE + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.id = TX_ID + expectedResponse.sep = SEP_6 + expectedResponse.kind = DEPOSIT + expectedResponse.status = ERROR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.message = TX_MESSAGE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandlerTest.kt index ab18395c4a..63967042c8 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandlerTest.kt @@ -18,8 +18,7 @@ import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyTransactionExpiredRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount @@ -32,11 +31,13 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.data.JdbcTransactionPendingTrustRepo import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyTransactionExpiredHandlerTest { @@ -48,6 +49,8 @@ class NotifyTransactionExpiredHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -75,6 +78,7 @@ class NotifyTransactionExpiredHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyTransactionExpiredHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -92,6 +96,7 @@ class NotifyTransactionExpiredHandlerTest { txn24.status = ERROR.toString() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -102,6 +107,7 @@ class NotifyTransactionExpiredHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -113,6 +119,7 @@ class NotifyTransactionExpiredHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = EXPIRED.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -122,6 +129,7 @@ class NotifyTransactionExpiredHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -133,6 +141,7 @@ class NotifyTransactionExpiredHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = COMPLETED.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -142,6 +151,7 @@ class NotifyTransactionExpiredHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -153,12 +163,14 @@ class NotifyTransactionExpiredHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals("message is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -172,6 +184,7 @@ class NotifyTransactionExpiredHandlerTest { txn24.status = PENDING_ANCHOR.toString() txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -181,6 +194,7 @@ class NotifyTransactionExpiredHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -193,6 +207,7 @@ class NotifyTransactionExpiredHandlerTest { txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -201,6 +216,7 @@ class NotifyTransactionExpiredHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -217,6 +233,7 @@ class NotifyTransactionExpiredHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -230,6 +247,7 @@ class NotifyTransactionExpiredHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { transactionPendingTrustRepo.deleteById(TX_ID) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -289,6 +307,7 @@ class NotifyTransactionExpiredHandlerTest { val sep31TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -298,6 +317,7 @@ class NotifyTransactionExpiredHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } val expectedSep31Txn = JdbcSep31Transaction() @@ -321,7 +341,7 @@ class NotifyTransactionExpiredHandlerTest { expectedResponse.amountFee = Amount(null, null) expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt expectedResponse.message = TX_MESSAGE - expectedResponse.customers = Customers(StellarId(null, null), StellarId(null, null)) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -346,4 +366,81 @@ class NotifyTransactionExpiredHandlerTest { assertTrue(expectedSep31Txn.updatedAt >= startDate) assertTrue(expectedSep31Txn.updatedAt <= endDate) } + + @Test + fun test_handle_ok_sep6() { + val request = + NotifyTransactionExpiredRequest.builder().transactionId(TX_ID).message(TX_MESSAGE).build() + val txn6 = JdbcSep6Transaction() + txn6.id = TX_ID + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = DEPOSIT.kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { transactionPendingTrustRepo.deleteById(TX_ID) } just Runs + every { transactionPendingTrustRepo.existsById(TX_ID) } returns true + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { transactionPendingTrustRepo.deleteById(TX_ID) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.id = TX_ID + expectedSep6Txn.kind = DEPOSIT.kind + expectedSep6Txn.status = EXPIRED.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.message = TX_MESSAGE + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.id = TX_ID + expectedResponse.sep = SEP_6 + expectedResponse.kind = DEPOSIT + expectedResponse.status = EXPIRED + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.message = TX_MESSAGE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandlerTest.kt index 510d689234..6e824d4650 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandlerTest.kt @@ -18,13 +18,9 @@ import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyTransactionRecoveryRequest -import org.stellar.anchor.api.sep.SepTransactionStatus.ERROR -import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR -import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_RECEIVER +import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.api.shared.Customers import org.stellar.anchor.api.shared.StellarId @@ -35,10 +31,12 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyTransactionRecoveryHandlerTest { @@ -50,6 +48,8 @@ class NotifyTransactionRecoveryHandlerTest { private const val TX_MESSAGE = "testMessage" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -74,6 +74,7 @@ class NotifyTransactionRecoveryHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyTransactionRecoveryHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -91,6 +92,7 @@ class NotifyTransactionRecoveryHandlerTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -101,6 +103,7 @@ class NotifyTransactionRecoveryHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -113,6 +116,7 @@ class NotifyTransactionRecoveryHandlerTest { txn24.status = PENDING_ANCHOR.toString() txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -122,6 +126,7 @@ class NotifyTransactionRecoveryHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -133,6 +138,7 @@ class NotifyTransactionRecoveryHandlerTest { val txn24 = JdbcSep24Transaction() txn24.status = ERROR.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -142,6 +148,7 @@ class NotifyTransactionRecoveryHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -155,6 +162,7 @@ class NotifyTransactionRecoveryHandlerTest { txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { requestValidator.validate(request) } throws @@ -163,6 +171,7 @@ class NotifyTransactionRecoveryHandlerTest { val ex = assertThrows { handler.handle(request) } assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -180,6 +189,7 @@ class NotifyTransactionRecoveryHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -191,6 +201,7 @@ class NotifyTransactionRecoveryHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -247,6 +258,7 @@ class NotifyTransactionRecoveryHandlerTest { txn31.requiredInfoMessage = TX_MESSAGE val sep31TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 every { txn31Store.save(capture(sep31TxnCapture)) } returns null @@ -255,6 +267,7 @@ class NotifyTransactionRecoveryHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } val expectedSep31Txn = JdbcSep31Transaction() @@ -278,7 +291,7 @@ class NotifyTransactionRecoveryHandlerTest { expectedResponse.amountFee = Amount(null, null) expectedResponse.updatedAt = sep31TxnCapture.captured.updatedAt expectedResponse.transferReceivedAt = transferReceivedAt - expectedResponse.customers = Customers(StellarId(null, null), StellarId(null, null)) + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -289,4 +302,77 @@ class NotifyTransactionRecoveryHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt >= startDate) assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } + + @Test + fun test_handle_ok_sep6() { + val transferReceivedAt = Instant.now() + val request = NotifyTransactionRecoveryRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = ERROR.toString() + txn6.kind = DEPOSIT.kind + txn6.message = TX_MESSAGE + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = DEPOSIT.kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = DEPOSIT + expectedResponse.status = PENDING_ANCHOR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTrustSetHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTrustSetHandlerTest.kt index 3fd48d4332..d9a97ed8ba 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTrustSetHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTrustSetHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,13 +18,15 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.NotifyTrustSetRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.custody.CustodyService import org.stellar.anchor.event.EventService @@ -31,10 +35,12 @@ import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.config.PropertyCustodyConfig import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class NotifyTrustSetHandlerTest { @@ -45,6 +51,8 @@ class NotifyTrustSetHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -73,6 +81,7 @@ class NotifyTrustSetHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = NotifyTrustSetHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -92,6 +101,7 @@ class NotifyTrustSetHandlerTest { txn24.kind = DEPOSIT.kind val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -102,81 +112,89 @@ class NotifyTrustSetHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = PENDING_TRUST.toString() txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { + fun test_handle_sep24_unsupportedStatus() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() - txn24.kind = WITHDRAWAL.kind + txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_unsupportedKind() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_TRUST.toString() - txn24.kind = DEPOSIT.kind + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_custodyIntegrationDisabled() { + fun test_handle_sep24_ok_custodyIntegrationDisabled() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_TRUST.toString() txn24.kind = DEPOSIT.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -188,6 +206,7 @@ class NotifyTrustSetHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -220,7 +239,7 @@ class NotifyTrustSetHandlerTest { } @Test - fun test_handle_ok_custodyIntegrationEnabled_success() { + fun test_handle_sep24_ok_custodyIntegrationEnabled_success() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).success(true).build() val txn24 = JdbcSep24Transaction() txn24.id = TX_ID @@ -228,6 +247,7 @@ class NotifyTrustSetHandlerTest { txn24.kind = DEPOSIT.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -240,6 +260,7 @@ class NotifyTrustSetHandlerTest { val endDate = Instant.now() verify(exactly = 1) { custodyService.createTransactionPayment(TX_ID, null) } + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -274,13 +295,14 @@ class NotifyTrustSetHandlerTest { } @Test - fun test_handle_ok_custodyIntegrationEnabled_fail() { + fun test_handle_sep24_ok_custodyIntegrationEnabled_fail() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).success(false).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_TRUST.toString() txn24.kind = DEPOSIT.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -293,6 +315,7 @@ class NotifyTrustSetHandlerTest { val endDate = Instant.now() verify(exactly = 0) { custodyService.createTransactionPayment(any(), any()) } + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -325,7 +348,7 @@ class NotifyTrustSetHandlerTest { } @Test - fun test_handle_ok() { + fun test_handle_sep24_ok() { val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_TRUST.toString() @@ -333,6 +356,7 @@ class NotifyTrustSetHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -345,6 +369,7 @@ class NotifyTrustSetHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -389,4 +414,289 @@ class NotifyTrustSetHandlerTest { assertTrue(expectedSep24Txn.updatedAt >= startDate) assertTrue(expectedSep24Txn.updatedAt <= endDate) } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[notify_trust_set] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_custodyIntegrationDisabled(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_custodyIntegrationEnabled_success(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).success(true).build() + val txn6 = JdbcSep6Transaction() + txn6.id = TX_ID + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 1) { custodyService.createTransactionPayment(TX_ID, null) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.id = TX_ID + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_STELLAR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.id = TX_ID + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_STELLAR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + assertTrue(expectedSep6Txn.updatedAt >= startDate) + assertTrue(expectedSep6Txn.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_custodyIntegrationEnabled_fail(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).success(false).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { custodyService.createTransactionPayment(any(), any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + assertTrue(expectedSep6Txn.updatedAt >= startDate) + assertTrue(expectedSep6Txn.updatedAt <= endDate) + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok(kind: String) { + val request = NotifyTrustSetRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_ANCHOR.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_ANCHOR + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(expectedSep6Txn.updatedAt >= startDate) + assertTrue(expectedSep6Txn.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandlerTest.kt index 0c2554ba47..554f304a36 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandlerTest.kt @@ -9,14 +9,19 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData +import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.RECEIVE -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 import org.stellar.anchor.api.rpc.method.RequestCustomerInfoUpdateRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount @@ -27,10 +32,12 @@ import org.stellar.anchor.event.EventService import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep31Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class RequestCustomerInfoUpdateHandlerTest { @@ -41,6 +48,8 @@ class RequestCustomerInfoUpdateHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -66,6 +75,7 @@ class RequestCustomerInfoUpdateHandlerTest { eventSession this.handler = RequestCustomerInfoUpdateHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -82,6 +92,7 @@ class RequestCustomerInfoUpdateHandlerTest { txn31.status = PENDING_RECEIVER.toString() val spyTxn31 = spyk(txn31) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns spyTxn31 every { spyTxn31.protocol } returns SEP_38.sep.toString() @@ -92,52 +103,57 @@ class RequestCustomerInfoUpdateHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = RequestCustomerInfoUpdateRequest.builder().transactionId(TX_ID).build() val txn31 = JdbcSep31Transaction() - txn31.status = INCOMPLETE.toString() + txn31.status = PENDING_RECEIVER.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[request_customer_info_update] is not supported. Status[incomplete], kind[receive], protocol[31], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep31_unsupportedStatus() { val request = RequestCustomerInfoUpdateRequest.builder().transactionId(TX_ID).build() val txn31 = JdbcSep31Transaction() - txn31.status = PENDING_RECEIVER.toString() + txn31.status = INCOMPLETE.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(TX_ID) } returns txn31 - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_customer_info_update] is not supported. Status[incomplete], kind[receive], protocol[31], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok() { + fun test_handle_sep31_ok() { val request = RequestCustomerInfoUpdateRequest.builder().transactionId(TX_ID).build() val txn31 = JdbcSep31Transaction() txn31.status = PENDING_RECEIVER.toString() @@ -202,4 +218,123 @@ class RequestCustomerInfoUpdateHandlerTest { assertTrue(sep31TxnCapture.captured.updatedAt >= startDate) assertTrue(sep31TxnCapture.captured.updatedAt <= endDate) } + + @Test + fun test_handle_sep6_unsupportedStatus() { + val request = + RequestCustomerInfoUpdateRequest.builder() + .transactionId(TX_ID) + .requiredCustomerInfoUpdates(listOf("email_address", "family_name", "given_name")) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = DEPOSIT.toString() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_customer_info_update] is not supported. Status[pending_user_transfer_start], kind[DEPOSIT], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @ParameterizedTest + @CsvSource( + value = + [ + "deposit, incomplete", + "deposit, pending_anchor", + "deposit, pending_customer_info_update", + "deposit-exchange, incomplete", + "deposit-exchange, pending_anchor", + "deposit-exchange, pending_customer_info_update", + "withdrawal, incomplete", + "withdrawal, pending_anchor", + "withdrawal, pending_customer_info_update", + "withdrawal-exchange, incomplete", + "withdrawal-exchange, pending_anchor", + "withdrawal-exchange, pending_customer_info_update" + ] + ) + fun test_handle_sep6_ok(kind: String, status: String) { + val request = + RequestCustomerInfoUpdateRequest.builder() + .transactionId(TX_ID) + .requiredCustomerInfoUpdates(listOf("email_address", "family_name", "given_name")) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = status + txn6.kind = kind + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_CUSTOMER_INFO_UPDATE.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requiredCustomerInfoUpdates = + listOf("email_address", "family_name", "given_name") + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = PlatformTransactionData.Sep.SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_CUSTOMER_INFO_UPDATE + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + expectedResponse.requiredCustomerInfoUpdates = + listOf("email_address", "family_name", "given_name") + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(PlatformTransactionData.Sep.SEP_6.sep.toString()) + .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandlerTest.kt index 7a965fa6c3..a233f63248 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -17,15 +19,18 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountAssetRequest import org.stellar.anchor.api.rpc.method.AmountRequest import org.stellar.anchor.api.rpc.method.RequestOffchainFundsRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.InstructionField +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.event.EventService @@ -33,10 +38,12 @@ import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics.PLATFORM_RPC_TRANSACTION import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class RequestOffchainFundsHandlerTest { @@ -51,6 +58,8 @@ class RequestOffchainFundsHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -76,6 +85,7 @@ class RequestOffchainFundsHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = RequestOffchainFundsHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -93,6 +103,7 @@ class RequestOffchainFundsHandlerTest { txn24.kind = DEPOSIT.kind val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -103,6 +114,7 @@ class RequestOffchainFundsHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } @@ -115,6 +127,7 @@ class RequestOffchainFundsHandlerTest { txn24.status = PENDING_EXTERNAL.toString() txn24.kind = DEPOSIT.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -124,76 +137,272 @@ class RequestOffchainFundsHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferReceived() { + fun test_handle_invalidRequest() { val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = INCOMPLETE.toString() txn24.kind = DEPOSIT.kind - txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_withoutAmounts_amountsAbsent() { + val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals("amount_in is required", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_withoutAmounts_amount_out_absent() { + val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + txn24.amountIn = "1" + txn24.amountInAsset = FIAT_USD + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals("amount_out is required", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_withoutAmounts_amount_fee_absent() { + val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + txn24.amountIn = "1" + txn24.amountInAsset = FIAT_USD + txn24.amountOut = "0.9" + txn24.amountOutAsset = STELLAR_USDC + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee is required", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_notAllAmounts() { + val request = + RequestOffchainFundsRequest.builder() + .amountIn(AmountAssetRequest("1", FIAT_USD)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .transactionId(TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[request_offchain_funds] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", + "All or none of the amount_in, amount_out, and amount_fee should be set", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { - val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() + fun test_handle_invalidAmounts() { + val request = + RequestOffchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", FIAT_USD)) + .amountOut(AmountAssetRequest("1", STELLAR_USDC)) + .amountFee(AmountAssetRequest("1", FIAT_USD)) + .amountExpected(AmountRequest("1")) + .build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind + txn24.kind = DEPOSIT.kind + txn24.requestAssetCode = FIAT_USD_CODE + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + request.amountIn.amount = "-1" + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.amount should be positive", ex.message) + request.amountIn.amount = "1" + + request.amountOut.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.amount should be positive", ex.message) + request.amountOut.amount = "1" + + request.amountFee.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.amount should be non-negative", ex.message) + request.amountFee.amount = "1" + + request.amountExpected.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_expected.amount should be positive", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_invalidAssets() { + val request = + RequestOffchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", FIAT_USD)) + .amountOut(AmountAssetRequest("1", STELLAR_USDC)) + .amountFee(AmountAssetRequest("1", FIAT_USD)) + .amountExpected(AmountRequest("1")) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + txn24.requestAssetCode = FIAT_USD_CODE + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + request.amountIn.asset = STELLAR_USDC + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.asset should be non-stellar asset", ex.message) + request.amountIn.asset = FIAT_USD + request.amountOut.asset = FIAT_USD + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.asset should be stellar asset", ex.message) + request.amountOut.asset = STELLAR_USDC + + request.amountFee.asset = STELLAR_USDC + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.asset should be non-stellar asset", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_transferReceived() { + val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = DEPOSIT.kind + txn24.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals( - "RPC method[request_offchain_funds] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", + "RPC method[request_offchain_funds] is not supported. Status[pending_anchor], kind[deposit], protocol[24], funds received[true]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_unsupportedKind() { val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind + txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_offchain_funds] is not supported. Status[incomplete], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_withExpectedAmount() { + fun test_handle_sep24_ok_withExpectedAmount() { val request = RequestOffchainFundsRequest.builder() .transactionId(TX_ID) @@ -209,6 +418,7 @@ class RequestOffchainFundsHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -220,6 +430,7 @@ class RequestOffchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -277,7 +488,7 @@ class RequestOffchainFundsHandlerTest { } @Test - fun test_handle_ok_withoutAmountExpected() { + fun test_handle_sep24_ok_withoutAmountExpected() { val request = RequestOffchainFundsRequest.builder() .transactionId(TX_ID) @@ -303,6 +514,7 @@ class RequestOffchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -360,7 +572,7 @@ class RequestOffchainFundsHandlerTest { } @Test - fun test_handle_ok_withoutAmounts_amountsPresent() { + fun test_handle_sep24_ok_withoutAmounts_amountsPresent() { val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() @@ -376,6 +588,7 @@ class RequestOffchainFundsHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -387,6 +600,7 @@ class RequestOffchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -444,179 +658,354 @@ class RequestOffchainFundsHandlerTest { } @Test - fun test_handle_withoutAmounts_amountsAbsent() { + fun test_handle_sep6_transferReceived() { val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = DEPOSIT.kind + txn6.transferReceivedAt = Instant.now() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(any()) } returns txn6 + every { txn24Store.findByTransactionId(TX_ID) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_in is required", ex.message) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_offchain_funds] is not supported. Status[pending_anchor], kind[deposit], protocol[6], funds received[true]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_withoutAmounts_amount_out_absent() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - txn24.amountIn = "1" - txn24.amountInAsset = FIAT_USD - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_out is required", ex.message) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_offchain_funds] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_withoutAmounts_amount_fee_absent() { - val request = RequestOffchainFundsRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - txn24.amountIn = "1" - txn24.amountInAsset = FIAT_USD - txn24.amountOut = "0.9" - txn24.amountOutAsset = STELLAR_USDC - val sep24TxnCapture = slot() + @CsvSource( + value = + [ + "deposit, incomplete", + "deposit, pending_anchor", + "deposit, pending_customer_info_update", + "deposit-exchange, incomplete", + "deposit-exchange, pending_anchor", + "deposit-exchange, pending_customer_info_update" + ] + ) + @ParameterizedTest + fun test_handle_sep6_ok_withExpectedAmount(kind: String, status: String) { + val request = + RequestOffchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", FIAT_USD)) + .amountOut(AmountAssetRequest("0.9", STELLAR_USDC)) + .amountFee(AmountAssetRequest("0.1", FIAT_USD)) + .amountExpected(AmountRequest("1")) + .instructions(mapOf("first_name" to InstructionField.builder().build())) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = status + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee is required", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } + verify(exactly = 1) { sepTransactionCounter.increment() } - @Test - fun test_handle_notAllAmounts() { - val request = - RequestOffchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", FIAT_USD)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .transactionId(TX_ID) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - val sep24TxnCapture = slot() + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = FIAT_USD + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.instructions = mapOf("first_name" to InstructionField.builder().build()) - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "All or none of the amount_in, amount_out, and amount_fee should be set", - ex.message + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", FIAT_USD) + expectedResponse.amountExpected = Amount("1", FIAT_USD) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + expectedResponse.instructions = mapOf("first_name" to InstructionField.builder().build()) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_invalidAmounts() { + @CsvSource( + value = + [ + "deposit, incomplete", + "deposit, pending_anchor", + "deposit, pending_customer_info_update", + "deposit-exchange, incomplete", + "deposit-exchange, pending_anchor", + "deposit-exchange, pending_customer_info_update" + ] + ) + @ParameterizedTest + fun test_handle_sep6_ok_withoutAmountExpected(kind: String, status: String) { val request = RequestOffchainFundsRequest.builder() .transactionId(TX_ID) .amountIn(AmountAssetRequest("1", FIAT_USD)) - .amountOut(AmountAssetRequest("1", STELLAR_USDC)) - .amountFee(AmountAssetRequest("1", FIAT_USD)) - .amountExpected(AmountRequest("1")) + .amountOut(AmountAssetRequest("0.9", STELLAR_USDC)) + .amountFee(AmountAssetRequest("0.1", FIAT_USD)) + .instructions(mapOf("first_name" to InstructionField.builder().build())) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - txn24.requestAssetCode = FIAT_USD_CODE - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = status + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - request.amountIn.amount = "-1" - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.amount should be positive", ex.message) - request.amountIn.amount = "1" + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() - request.amountOut.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.amount should be positive", ex.message) - request.amountOut.amount = "1" + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } - request.amountFee.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.amount should be non-negative", ex.message) - request.amountFee.amount = "1" + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = FIAT_USD + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.instructions = mapOf("first_name" to InstructionField.builder().build()) - request.amountExpected.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_expected.amount should be positive", ex.message) + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", FIAT_USD) + expectedResponse.amountExpected = Amount("1", FIAT_USD) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + expectedResponse.instructions = mapOf("first_name" to InstructionField.builder().build()) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_invalidAssets() { + @CsvSource( + value = + [ + "deposit, incomplete", + "deposit, pending_anchor", + "deposit, pending_customer_info_update", + "deposit-exchange, incomplete", + "deposit-exchange, pending_anchor", + "deposit-exchange, pending_customer_info_update" + ] + ) + @ParameterizedTest + fun test_handle_sep6_ok_withoutAmounts_amountsPresent(kind: String, status: String) { val request = RequestOffchainFundsRequest.builder() .transactionId(TX_ID) - .amountIn(AmountAssetRequest("1", FIAT_USD)) - .amountOut(AmountAssetRequest("1", STELLAR_USDC)) - .amountFee(AmountAssetRequest("1", FIAT_USD)) - .amountExpected(AmountRequest("1")) + .instructions(mapOf("first_name" to InstructionField.builder().build())) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind - txn24.requestAssetCode = FIAT_USD_CODE - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = status + txn6.kind = kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountIn = "1" + txn6.amountInAsset = FIAT_USD + txn6.amountOut = "0.9" + txn6.amountOutAsset = STELLAR_USDC + txn6.amountFee = "0.1" + txn6.amountFeeAsset = STELLAR_USDC + txn6.amountExpected = "1" + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - - request.amountIn.asset = STELLAR_USDC - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.asset should be non-stellar asset", ex.message) - request.amountIn.asset = FIAT_USD - - request.amountOut.asset = FIAT_USD - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.asset should be stellar asset", ex.message) - request.amountOut.asset = STELLAR_USDC + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - request.amountFee.asset = STELLAR_USDC - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.asset should be non-stellar asset", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = FIAT_USD_CODE + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = FIAT_USD + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = STELLAR_USDC + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.instructions = mapOf("first_name" to InstructionField.builder().build()) + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", FIAT_USD) + expectedResponse.amountOut = Amount("0.9", STELLAR_USDC) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", FIAT_USD) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + expectedResponse.instructions = mapOf("first_name" to InstructionField.builder().build()) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt index 1b68c1e4cc..6b62dea738 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -17,16 +19,18 @@ import org.stellar.anchor.api.exception.BadRequestException import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.WITHDRAWAL -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.AmountAssetRequest import org.stellar.anchor.api.rpc.method.AmountRequest import org.stellar.anchor.api.rpc.method.RequestOnchainFundsRequest import org.stellar.anchor.api.sep.SepTransactionStatus.* import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers import org.stellar.anchor.api.shared.SepDepositInfo +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.config.CustodyConfig @@ -38,13 +42,18 @@ import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.service.Sep24DepositInfoNoneGenerator import org.stellar.anchor.platform.service.Sep24DepositInfoSelfGenerator +import org.stellar.anchor.platform.service.Sep6DepositInfoNoneGenerator +import org.stellar.anchor.platform.service.Sep6DepositInfoSelfGenerator import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24Transaction import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6Transaction +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class RequestOnchainFundsHandlerTest { @@ -69,6 +78,8 @@ class RequestOnchainFundsHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -81,6 +92,8 @@ class RequestOnchainFundsHandlerTest { @MockK(relaxed = true) private lateinit var custodyService: CustodyService + @MockK(relaxed = true) private lateinit var sep6DepositInfoGenerator: Sep6DepositInfoNoneGenerator + @MockK(relaxed = true) private lateinit var sep24DepositInfoGenerator: Sep24DepositInfoNoneGenerator @@ -101,12 +114,14 @@ class RequestOnchainFundsHandlerTest { this.assetService = DefaultAssetService.fromJsonResource("test_assets.json") this.handler = RequestOnchainFundsHandler( + txn6Store, txn24Store, txn31Store, requestValidator, assetService, custodyService, custodyConfig, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, eventService, metricsService @@ -121,6 +136,7 @@ class RequestOnchainFundsHandlerTest { txn24.kind = DEPOSIT.kind val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -131,216 +147,265 @@ class RequestOnchainFundsHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_invalidRequest() { val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_TRUST.toString() + txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[request_onchain_funds] is not supported. Status[pending_trust], kind[withdrawal], protocol[24], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferReceived() { + fun test_handle_withoutAmounts_amountsAbsent() { val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind - txn24.transferReceivedAt = Instant.now() + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[request_onchain_funds] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[true]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals("amount_in is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedKind() { + fun test_handle_withoutAmounts_amount_out_absent() { val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() - txn24.kind = DEPOSIT.kind + txn24.kind = WITHDRAWAL.kind + txn24.amountIn = "1" + txn24.amountInAsset = STELLAR_USDC + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[request_onchain_funds] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", - ex.message - ) + val ex = assertThrows { handler.handle(request) } + assertEquals("amount_out is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle__withoutAmounts_amount_fee_absent() { val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind + txn24.amountIn = "1" + txn24.amountInAsset = STELLAR_USDC + txn24.amountOut = "0.9" + txn24.amountOutAsset = FIAT_USD + val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) + every { txn24Store.save(capture(sep24TxnCapture)) } returns null val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + assertEquals("amount_fee is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_withExpectedAmount() { + fun test_handle_notAllAmounts() { val request = RequestOnchainFundsRequest.builder() + .amountIn(AmountAssetRequest("1", FIAT_USD)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) .transactionId(TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "All or none of the amount_in, amount_out, and amount_fee should be set", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_invalidMemo() { + val request = + RequestOnchainFundsRequest.builder() .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("0.9", FIAT_USD)) - .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) - .amountExpected(AmountRequest("1")) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) + .transactionId(TX_ID) .memo(TEXT_MEMO) - .memoType(TEXT_MEMO_TYPE) + .memoType(INVALID_MEMO_TYPE) .destinationAccount(DESTINATION_ACCOUNT) .build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = STELLAR_USDC_CODE - txn24.requestAssetIssuer = STELLAR_USDC_ISSUER val sep24TxnCapture = slot() - val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns false - every { custodyConfig.type } returns NONE - every { eventSession.publish(capture(anchorEventCapture)) } just Runs - every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns - sepTransactionCounter - val startDate = Instant.now() - val response = handler.handle(request) - val endDate = Instant.now() + val ex = assertThrows { handler.handle(request) } + assertEquals("Invalid memo or memo_type: Invalid memo type: invalidMemoType", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } - verify(exactly = 1) { sepTransactionCounter.increment() } + verify(exactly = 0) { sepTransactionCounter.increment() } + } - val expectedSep24Txn = JdbcSep24Transaction() - expectedSep24Txn.kind = WITHDRAWAL.kind - expectedSep24Txn.status = PENDING_USR_TRANSFER_START.toString() - expectedSep24Txn.updatedAt = sep24TxnCapture.captured.updatedAt - expectedSep24Txn.requestAssetCode = STELLAR_USDC_CODE - expectedSep24Txn.requestAssetIssuer = STELLAR_USDC_ISSUER - expectedSep24Txn.amountIn = "1" - expectedSep24Txn.amountInAsset = STELLAR_USDC - expectedSep24Txn.amountOut = "0.9" - expectedSep24Txn.amountOutAsset = FIAT_USD - expectedSep24Txn.amountFee = "0.1" - expectedSep24Txn.amountFeeAsset = STELLAR_USDC - expectedSep24Txn.amountExpected = "1" - expectedSep24Txn.memo = TEXT_MEMO - expectedSep24Txn.memoType = TEXT_MEMO_TYPE - expectedSep24Txn.toAccount = DESTINATION_ACCOUNT - expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT + @Test + fun test_handle_notSupportedMemoType() { + val request = + RequestOnchainFundsRequest.builder() + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) + .transactionId(TX_ID) + .memo(HASH_MEMO) + .memoType(HASH_MEMO_TYPE) + .destinationAccount(DESTINATION_ACCOUNT) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() - JSONAssert.assertEquals( - gson.toJson(expectedSep24Txn), - gson.toJson(sep24TxnCapture.captured), - JSONCompareMode.STRICT - ) + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { custodyConfig.type } returns FIREBLOCKS - val expectedResponse = GetTransactionResponse() - expectedResponse.sep = SEP_24 - expectedResponse.kind = WITHDRAWAL - expectedResponse.status = PENDING_USR_TRANSFER_START - expectedResponse.amountIn = Amount("1", STELLAR_USDC) - expectedResponse.amountOut = Amount("0.9", FIAT_USD) - expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) - expectedResponse.amountExpected = Amount("1", STELLAR_USDC) - expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt - expectedResponse.memo = TEXT_MEMO - expectedResponse.memoType = TEXT_MEMO_TYPE - expectedResponse.destinationAccount = DESTINATION_ACCOUNT + val ex = assertThrows { handler.handle(request) } + assertEquals("Memo type[hash] is not supported for custody type[fireblocks]", ex.message) - JSONAssert.assertEquals( - gson.toJson(expectedResponse), - gson.toJson(response), - JSONCompareMode.STRICT - ) + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } - val expectedEvent = - AnchorEvent.builder() - .id(anchorEventCapture.captured.id) - .sep(SEP_24.sep.toString()) - .type(TRANSACTION_STATUS_CHANGED) - .transaction(expectedResponse) + @Test + fun test_handle_ok_missingMemo() { + val request = + RequestOnchainFundsRequest.builder() + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) + .transactionId(TX_ID) + .destinationAccount(DESTINATION_ACCOUNT) .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() - JSONAssert.assertEquals( - gson.toJson(expectedEvent), - gson.toJson(anchorEventCapture.captured), - JSONCompareMode.STRICT - ) + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null - assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) - assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) + val ex = assertThrows { handler.handle(request) } + assertEquals("memo and memo_type are required", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok_autogeneratedMemo() { - val sep24DepositInfoGenerator: Sep24DepositInfoSelfGenerator = mockk() - this.handler = - RequestOnchainFundsHandler( - txn24Store, - txn31Store, - requestValidator, - assetService, - custodyService, - custodyConfig, - sep24DepositInfoGenerator, - eventService, - metricsService - ) + fun test_handle_ok_missingDestinationAccount() { + val request = + RequestOnchainFundsRequest.builder() + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) + .transactionId(TX_ID) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals("destination_account is required", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_invalidAmounts() { val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("0.9", FIAT_USD)) - .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) .amountExpected(AmountRequest("1")) .build() val txn24 = JdbcSep24Transaction() @@ -349,16 +414,176 @@ class RequestOnchainFundsHandlerTest { txn24.requestAssetCode = STELLAR_USDC_CODE txn24.requestAssetIssuer = STELLAR_USDC_ISSUER val sep24TxnCapture = slot() - val anchorEventCapture = slot() - val depositInfo = SepDepositInfo(DESTINATION_ACCOUNT_2, TEXT_MEMO_2, TEXT_MEMO_TYPE) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns false - every { custodyConfig.type } returns NONE - every { sep24DepositInfoGenerator.generate(ofType(Sep24Transaction::class)) } returns - depositInfo + + request.amountIn.amount = "-1" + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.amount should be positive", ex.message) + request.amountIn.amount = "1" + + request.amountOut.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.amount should be positive", ex.message) + request.amountOut.amount = "1" + + request.amountFee.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.amount should be non-negative", ex.message) + request.amountFee.amount = "1" + + request.amountExpected.amount = "-1" + ex = assertThrows { handler.handle(request) } + assertEquals("amount_expected.amount should be positive", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_invalidAssets() { + val request = + RequestOnchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("1", FIAT_USD)) + .amountFee(AmountAssetRequest("1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = STELLAR_USDC_CODE + txn24.requestAssetIssuer = STELLAR_USDC_ISSUER + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + + request.amountIn.asset = FIAT_USD + var ex = assertThrows { handler.handle(request) } + assertEquals("amount_in.asset should be stellar asset", ex.message) + request.amountIn.asset = STELLAR_USDC + + request.amountOut.asset = STELLAR_USDC + ex = assertThrows { handler.handle(request) } + assertEquals("amount_out.asset should be non-stellar asset", ex.message) + request.amountOut.asset = FIAT_USD + + request.amountFee.asset = FIAT_USD + ex = assertThrows { handler.handle(request) } + assertEquals("amount_fee.asset should be stellar asset", ex.message) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_unsupportedStatus() { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_TRUST.toString() + txn24.kind = WITHDRAWAL.kind + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_onchain_funds] is not supported. Status[pending_trust], kind[withdrawal], protocol[24], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_transferReceived() { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_ANCHOR.toString() + txn24.kind = WITHDRAWAL.kind + txn24.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_onchain_funds] is not supported. Status[pending_anchor], kind[withdrawal], protocol[24], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_sep24_unsupportedKind() { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = DEPOSIT.kind + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_onchain_funds] is not supported. Status[incomplete], kind[deposit], protocol[24], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @Test + fun test_handle_ok_sep24_withExpectedAmount() { + val request = + RequestOnchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) + .destinationAccount(DESTINATION_ACCOUNT) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = INCOMPLETE.toString() + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = STELLAR_USDC_CODE + txn24.requestAssetIssuer = STELLAR_USDC_ISSUER + val sep24TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { custodyConfig.type } returns NONE every { eventSession.publish(capture(anchorEventCapture)) } just Runs every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns sepTransactionCounter @@ -367,6 +592,7 @@ class RequestOnchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -384,10 +610,10 @@ class RequestOnchainFundsHandlerTest { expectedSep24Txn.amountFee = "0.1" expectedSep24Txn.amountFeeAsset = STELLAR_USDC expectedSep24Txn.amountExpected = "1" - expectedSep24Txn.memo = TEXT_MEMO_2 + expectedSep24Txn.memo = TEXT_MEMO expectedSep24Txn.memoType = TEXT_MEMO_TYPE - expectedSep24Txn.toAccount = DESTINATION_ACCOUNT_2 - expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT_2 + expectedSep24Txn.toAccount = DESTINATION_ACCOUNT + expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT JSONAssert.assertEquals( gson.toJson(expectedSep24Txn), @@ -404,9 +630,9 @@ class RequestOnchainFundsHandlerTest { expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) expectedResponse.amountExpected = Amount("1", STELLAR_USDC) expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt - expectedResponse.memo = TEXT_MEMO_2 + expectedResponse.memo = TEXT_MEMO expectedResponse.memoType = TEXT_MEMO_TYPE - expectedResponse.destinationAccount = DESTINATION_ACCOUNT_2 + expectedResponse.destinationAccount = DESTINATION_ACCOUNT JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -433,7 +659,23 @@ class RequestOnchainFundsHandlerTest { } @Test - fun test_handle_ok_withExpectedAmount_custodyIntegrationEnabled() { + fun test_handle_sep24_ok_autogeneratedMemo() { + val sep24DepositInfoGenerator: Sep24DepositInfoSelfGenerator = mockk() + this.handler = + RequestOnchainFundsHandler( + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + custodyService, + custodyConfig, + sep6DepositInfoGenerator, + sep24DepositInfoGenerator, + eventService, + metricsService + ) + val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) @@ -441,9 +683,6 @@ class RequestOnchainFundsHandlerTest { .amountOut(AmountAssetRequest("0.9", FIAT_USD)) .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) .amountExpected(AmountRequest("1")) - .memo(TEXT_MEMO) - .memoType(TEXT_MEMO_TYPE) - .destinationAccount(DESTINATION_ACCOUNT) .build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() @@ -451,15 +690,17 @@ class RequestOnchainFundsHandlerTest { txn24.requestAssetCode = STELLAR_USDC_CODE txn24.requestAssetIssuer = STELLAR_USDC_ISSUER val sep24TxnCapture = slot() - val sep24CustodyTxnCapture = slot() val anchorEventCapture = slot() + val depositInfo = SepDepositInfo(DESTINATION_ACCOUNT_2, TEXT_MEMO_2, TEXT_MEMO_TYPE) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { custodyConfig.isCustodyIntegrationEnabled } returns false every { custodyConfig.type } returns NONE - every { custodyService.createTransaction(capture(sep24CustodyTxnCapture)) } just Runs + every { sep24DepositInfoGenerator.generate(ofType(Sep24Transaction::class)) } returns + depositInfo every { eventSession.publish(capture(anchorEventCapture)) } just Runs every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns sepTransactionCounter @@ -468,7 +709,9 @@ class RequestOnchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } val expectedSep24Txn = JdbcSep24Transaction() @@ -484,10 +727,10 @@ class RequestOnchainFundsHandlerTest { expectedSep24Txn.amountFee = "0.1" expectedSep24Txn.amountFeeAsset = STELLAR_USDC expectedSep24Txn.amountExpected = "1" - expectedSep24Txn.memo = TEXT_MEMO + expectedSep24Txn.memo = TEXT_MEMO_2 expectedSep24Txn.memoType = TEXT_MEMO_TYPE - expectedSep24Txn.toAccount = DESTINATION_ACCOUNT - expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT + expectedSep24Txn.toAccount = DESTINATION_ACCOUNT_2 + expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT_2 JSONAssert.assertEquals( gson.toJson(expectedSep24Txn), @@ -495,12 +738,6 @@ class RequestOnchainFundsHandlerTest { JSONCompareMode.STRICT ) - JSONAssert.assertEquals( - gson.toJson(expectedSep24Txn), - gson.toJson(sep24CustodyTxnCapture.captured), - JSONCompareMode.STRICT - ) - val expectedResponse = GetTransactionResponse() expectedResponse.sep = SEP_24 expectedResponse.kind = WITHDRAWAL @@ -510,9 +747,9 @@ class RequestOnchainFundsHandlerTest { expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) expectedResponse.amountExpected = Amount("1", STELLAR_USDC) expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt - expectedResponse.memo = TEXT_MEMO + expectedResponse.memo = TEXT_MEMO_2 expectedResponse.memoType = TEXT_MEMO_TYPE - expectedResponse.destinationAccount = DESTINATION_ACCOUNT + expectedResponse.destinationAccount = DESTINATION_ACCOUNT_2 JSONAssert.assertEquals( gson.toJson(expectedResponse), @@ -539,13 +776,14 @@ class RequestOnchainFundsHandlerTest { } @Test - fun test_handle_ok_withoutAmountExpected() { + fun test_handle_sep24_ok_withExpectedAmount_custodyIntegrationEnabled() { val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) .amountIn(AmountAssetRequest("1", STELLAR_USDC)) .amountOut(AmountAssetRequest("0.9", FIAT_USD)) .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) .memo(TEXT_MEMO) .memoType(TEXT_MEMO_TYPE) .destinationAccount(DESTINATION_ACCOUNT) @@ -556,13 +794,16 @@ class RequestOnchainFundsHandlerTest { txn24.requestAssetCode = STELLAR_USDC_CODE txn24.requestAssetIssuer = STELLAR_USDC_ISSUER val sep24TxnCapture = slot() + val sep24CustodyTxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { custodyConfig.isCustodyIntegrationEnabled } returns true every { custodyConfig.type } returns NONE + every { custodyService.createTransaction(capture(sep24CustodyTxnCapture)) } just Runs every { eventSession.publish(capture(anchorEventCapture)) } just Runs every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns sepTransactionCounter @@ -571,8 +812,8 @@ class RequestOnchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } val expectedSep24Txn = JdbcSep24Transaction() @@ -599,6 +840,12 @@ class RequestOnchainFundsHandlerTest { JSONCompareMode.STRICT ) + JSONAssert.assertEquals( + gson.toJson(expectedSep24Txn), + gson.toJson(sep24CustodyTxnCapture.captured), + JSONCompareMode.STRICT + ) + val expectedResponse = GetTransactionResponse() expectedResponse.sep = SEP_24 expectedResponse.kind = WITHDRAWAL @@ -637,10 +884,13 @@ class RequestOnchainFundsHandlerTest { } @Test - fun test_handle_ok_withoutAmounts_amountsPresent() { + fun test_handle_sep24_ok_withoutAmountExpected() { val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) .memo(TEXT_MEMO) .memoType(TEXT_MEMO_TYPE) .destinationAccount(DESTINATION_ACCOUNT) @@ -650,16 +900,10 @@ class RequestOnchainFundsHandlerTest { txn24.kind = WITHDRAWAL.kind txn24.requestAssetCode = STELLAR_USDC_CODE txn24.requestAssetIssuer = STELLAR_USDC_ISSUER - txn24.amountIn = "1" - txn24.amountInAsset = STELLAR_USDC - txn24.amountOut = "0.9" - txn24.amountOutAsset = FIAT_USD - txn24.amountFee = "0.1" - txn24.amountFeeAsset = STELLAR_USDC - txn24.amountExpected = "1" val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -673,6 +917,7 @@ class RequestOnchainFundsHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -739,82 +984,122 @@ class RequestOnchainFundsHandlerTest { } @Test - fun test_handle_withoutAmounts_amountsAbsent() { - val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + fun test_handle_sep24_ok_withoutAmounts_amountsPresent() { + val request = + RequestOnchainFundsRequest.builder() + .transactionId(TX_ID) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) + .destinationAccount(DESTINATION_ACCOUNT) + .build() val txn24 = JdbcSep24Transaction() txn24.status = INCOMPLETE.toString() txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = STELLAR_USDC_CODE + txn24.requestAssetIssuer = STELLAR_USDC_ISSUER + txn24.amountIn = "1" + txn24.amountInAsset = STELLAR_USDC + txn24.amountOut = "0.9" + txn24.amountOutAsset = FIAT_USD + txn24.amountFee = "0.1" + txn24.amountFeeAsset = STELLAR_USDC + txn24.amountExpected = "1" val sep24TxnCapture = slot() + val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { custodyConfig.type } returns NONE + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_in is required", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() - verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } - - @Test - fun test_handle_withoutAmounts_amount_out_absent() { - val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - txn24.amountIn = "1" - txn24.amountInAsset = STELLAR_USDC - val sep24TxnCapture = slot() + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sepTransactionCounter.increment() } - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + val expectedSep24Txn = JdbcSep24Transaction() + expectedSep24Txn.kind = WITHDRAWAL.kind + expectedSep24Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep24Txn.updatedAt = sep24TxnCapture.captured.updatedAt + expectedSep24Txn.requestAssetCode = STELLAR_USDC_CODE + expectedSep24Txn.requestAssetIssuer = STELLAR_USDC_ISSUER + expectedSep24Txn.amountIn = "1" + expectedSep24Txn.amountInAsset = STELLAR_USDC + expectedSep24Txn.amountOut = "0.9" + expectedSep24Txn.amountOutAsset = FIAT_USD + expectedSep24Txn.amountFee = "0.1" + expectedSep24Txn.amountFeeAsset = STELLAR_USDC + expectedSep24Txn.amountExpected = "1" + expectedSep24Txn.memo = TEXT_MEMO + expectedSep24Txn.memoType = TEXT_MEMO_TYPE + expectedSep24Txn.toAccount = DESTINATION_ACCOUNT + expectedSep24Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_out is required", ex.message) + JSONAssert.assertEquals( + gson.toJson(expectedSep24Txn), + gson.toJson(sep24TxnCapture.captured), + JSONCompareMode.STRICT + ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } - } + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_24 + expectedResponse.kind = WITHDRAWAL + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", STELLAR_USDC) + expectedResponse.amountOut = Amount("0.9", FIAT_USD) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", STELLAR_USDC) + expectedResponse.updatedAt = sep24TxnCapture.captured.updatedAt + expectedResponse.memo = TEXT_MEMO + expectedResponse.memoType = TEXT_MEMO_TYPE + expectedResponse.destinationAccount = DESTINATION_ACCOUNT - @Test - fun test_handle_withoutAmounts_amount_fee_absent() { - val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - txn24.amountIn = "1" - txn24.amountInAsset = STELLAR_USDC - txn24.amountOut = "0.9" - txn24.amountOutAsset = FIAT_USD - val sep24TxnCapture = slot() + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 - every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_24.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() - val ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee is required", ex.message) + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } @Test - fun test_handle_notNoneGenerator() { + fun test_handle_sep24_notNoneGenerator() { val sep24DepositInfoGenerator: Sep24DepositInfoSelfGenerator = mockk() this.handler = RequestOnchainFundsHandler( + txn6Store, txn24Store, txn31Store, requestValidator, assetService, custodyService, custodyConfig, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, eventService, metricsService @@ -834,6 +1119,7 @@ class RequestOnchainFundsHandlerTest { txn24.kind = WITHDRAWAL.kind val sep24TxnCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -844,233 +1130,554 @@ class RequestOnchainFundsHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_notAllAmounts() { - val request = - RequestOnchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", FIAT_USD)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .transactionId(TX_ID) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } + val ex = assertThrows { handler.handle(request) } assertEquals( - "All or none of the amount_in, amount_out, and amount_fee should be set", + "RPC method[request_onchain_funds] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[false]", ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_invalidMemo() { - val request = - RequestOnchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .amountFee(AmountAssetRequest("1", STELLAR_USDC)) - .transactionId(TX_ID) - .memo(TEXT_MEMO) - .memoType(INVALID_MEMO_TYPE) - .destinationAccount(DESTINATION_ACCOUNT) - .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferReceived(kind: String) { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - val ex = assertThrows { handler.handle(request) } - assertEquals("Invalid memo or memo_type: Invalid memo type: invalidMemoType", ex.message) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_onchain_funds] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } - @Test - fun test_handle_notSupportedMemoType() { + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = RequestOnchainFundsRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_onchain_funds] is not supported. Status[incomplete], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_ok_sep6_withExpectedAmount(kind: String) { val request = RequestOnchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .amountFee(AmountAssetRequest("1", STELLAR_USDC)) .transactionId(TX_ID) - .memo(HASH_MEMO) - .memoType(HASH_MEMO_TYPE) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) .destinationAccount(DESTINATION_ACCOUNT) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + txn6.requestAssetCode = STELLAR_USDC_CODE + txn6.requestAssetIssuer = STELLAR_USDC_ISSUER + val sep6TxnCapture = slot() + val sep6CustodyTxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null - every { custodyConfig.type } returns FIREBLOCKS + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { custodyConfig.type } returns NONE + every { custodyService.createTransaction(capture(sep6CustodyTxnCapture)) } just Runs + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("Memo type[hash] is not supported for custody type[fireblocks]", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = STELLAR_USDC_CODE + expectedSep6Txn.requestAssetIssuer = STELLAR_USDC_ISSUER + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = FIAT_USD + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.memo = TEXT_MEMO + expectedSep6Txn.memoType = TEXT_MEMO_TYPE + expectedSep6Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6CustodyTxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", STELLAR_USDC) + expectedResponse.amountOut = Amount("0.9", FIAT_USD) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", STELLAR_USDC) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.memo = TEXT_MEMO + expectedResponse.memoType = TEXT_MEMO_TYPE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_ok_missingMemo() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_autogeneratedMemo(kind: String) { + val sep6DepositInfoGenerator: Sep6DepositInfoSelfGenerator = mockk() + this.handler = + RequestOnchainFundsHandler( + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + custodyService, + custodyConfig, + sep6DepositInfoGenerator, + sep24DepositInfoGenerator, + eventService, + metricsService + ) + val request = RequestOnchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .amountFee(AmountAssetRequest("1", STELLAR_USDC)) .transactionId(TX_ID) - .destinationAccount(DESTINATION_ACCOUNT) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + txn6.requestAssetCode = STELLAR_USDC_CODE + txn6.requestAssetIssuer = STELLAR_USDC_ISSUER + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + val depositInfo = SepDepositInfo(DESTINATION_ACCOUNT_2, TEXT_MEMO_2, TEXT_MEMO_TYPE) - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { custodyConfig.type } returns NONE + every { sep6DepositInfoGenerator.generate(ofType(Sep6Transaction::class)) } returns depositInfo + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("memo and memo_type are required", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep6Transaction::class)) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = STELLAR_USDC_CODE + expectedSep6Txn.requestAssetIssuer = STELLAR_USDC_ISSUER + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = FIAT_USD + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.memo = TEXT_MEMO_2 + expectedSep6Txn.memoType = TEXT_MEMO_TYPE + expectedSep6Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT_2 + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", STELLAR_USDC) + expectedResponse.amountOut = Amount("0.9", FIAT_USD) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", STELLAR_USDC) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.memo = TEXT_MEMO_2 + expectedResponse.memoType = TEXT_MEMO_TYPE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_ok_missingDestinationAccount() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_withoutAmountExpected(kind: String) { val request = RequestOnchainFundsRequest.builder() - .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .amountFee(AmountAssetRequest("1", STELLAR_USDC)) .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .amountFee(AmountAssetRequest("0.1", STELLAR_USDC)) .memo(TEXT_MEMO) .memoType(TEXT_MEMO_TYPE) + .destinationAccount(DESTINATION_ACCOUNT) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + txn6.requestAssetCode = STELLAR_USDC_CODE + txn6.requestAssetIssuer = STELLAR_USDC_ISSUER + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - val ex = assertThrows { handler.handle(request) } - assertEquals("destination_account is required", ex.message) + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = STELLAR_USDC_CODE + expectedSep6Txn.requestAssetIssuer = STELLAR_USDC_ISSUER + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = FIAT_USD + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.memo = TEXT_MEMO + expectedSep6Txn.memoType = TEXT_MEMO_TYPE + expectedSep6Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", STELLAR_USDC) + expectedResponse.amountOut = Amount("0.9", FIAT_USD) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", STELLAR_USDC) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.memo = TEXT_MEMO + expectedResponse.memoType = TEXT_MEMO_TYPE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_invalidAmounts() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok_withoutAmounts_amountsPresent(kind: String) { val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) - .amountIn(AmountAssetRequest("1", STELLAR_USDC)) - .amountOut(AmountAssetRequest("1", FIAT_USD)) - .amountFee(AmountAssetRequest("1", STELLAR_USDC)) - .amountExpected(AmountRequest("1")) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) + .destinationAccount(DESTINATION_ACCOUNT) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = STELLAR_USDC_CODE - txn24.requestAssetIssuer = STELLAR_USDC_ISSUER - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + txn6.requestAssetCode = STELLAR_USDC_CODE + txn6.requestAssetIssuer = STELLAR_USDC_ISSUER + txn6.amountIn = "1" + txn6.amountInAsset = STELLAR_USDC + txn6.amountOut = "0.9" + txn6.amountOutAsset = FIAT_USD + txn6.amountFee = "0.1" + txn6.amountFeeAsset = STELLAR_USDC + txn6.amountExpected = "1" + val sep6TxnCapture = slot() + val anchorEventCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter - request.amountIn.amount = "-1" - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.amount should be positive", ex.message) - request.amountIn.amount = "1" + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() - request.amountOut.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.amount should be positive", ex.message) - request.amountOut.amount = "1" + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sepTransactionCounter.increment() } - request.amountFee.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.amount should be non-negative", ex.message) - request.amountFee.amount = "1" + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_USR_TRANSFER_START.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.requestAssetCode = STELLAR_USDC_CODE + expectedSep6Txn.requestAssetIssuer = STELLAR_USDC_ISSUER + expectedSep6Txn.amountIn = "1" + expectedSep6Txn.amountInAsset = STELLAR_USDC + expectedSep6Txn.amountOut = "0.9" + expectedSep6Txn.amountOutAsset = FIAT_USD + expectedSep6Txn.amountFee = "0.1" + expectedSep6Txn.amountFeeAsset = STELLAR_USDC + expectedSep6Txn.amountExpected = "1" + expectedSep6Txn.memo = TEXT_MEMO + expectedSep6Txn.memoType = TEXT_MEMO_TYPE + expectedSep6Txn.withdrawAnchorAccount = DESTINATION_ACCOUNT - request.amountExpected.amount = "-1" - ex = assertThrows { handler.handle(request) } - assertEquals("amount_expected.amount should be positive", ex.message) + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) - verify(exactly = 0) { txn24Store.save(any()) } - verify(exactly = 0) { txn31Store.save(any()) } - verify(exactly = 0) { sepTransactionCounter.increment() } + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_USR_TRANSFER_START + expectedResponse.amountIn = Amount("1", STELLAR_USDC) + expectedResponse.amountOut = Amount("0.9", FIAT_USD) + expectedResponse.amountFee = Amount("0.1", STELLAR_USDC) + expectedResponse.amountExpected = Amount("1", STELLAR_USDC) + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.memo = TEXT_MEMO + expectedResponse.memoType = TEXT_MEMO_TYPE + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) } - @Test - fun test_handle_invalidAssets() { + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_notNoneGenerator(kind: String) { + val sep6DepositInfoGenerator: Sep6DepositInfoSelfGenerator = mockk() + this.handler = + RequestOnchainFundsHandler( + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + custodyService, + custodyConfig, + sep6DepositInfoGenerator, + sep24DepositInfoGenerator, + eventService, + metricsService + ) + val request = RequestOnchainFundsRequest.builder() .transactionId(TX_ID) + .memo(TEXT_MEMO) + .memoType(TEXT_MEMO_TYPE) .amountIn(AmountAssetRequest("1", STELLAR_USDC)) .amountOut(AmountAssetRequest("1", FIAT_USD)) .amountFee(AmountAssetRequest("1", STELLAR_USDC)) - .amountExpected(AmountRequest("1")) .build() - val txn24 = JdbcSep24Transaction() - txn24.status = INCOMPLETE.toString() - txn24.kind = WITHDRAWAL.kind - txn24.requestAssetCode = STELLAR_USDC_CODE - txn24.requestAssetIssuer = STELLAR_USDC_ISSUER - val sep24TxnCapture = slot() + val txn6 = JdbcSep6Transaction() + txn6.status = INCOMPLETE.toString() + txn6.kind = kind + val sep6TxnCapture = slot() - every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null - every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { txn24Store.save(capture(sep6TxnCapture)) } returns null - request.amountIn.asset = FIAT_USD - var ex = assertThrows { handler.handle(request) } - assertEquals("amount_in.asset should be stellar asset", ex.message) - request.amountIn.asset = STELLAR_USDC - - request.amountOut.asset = STELLAR_USDC - ex = assertThrows { handler.handle(request) } - assertEquals("amount_out.asset should be non-stellar asset", ex.message) - request.amountOut.asset = FIAT_USD - - request.amountFee.asset = FIAT_USD - ex = assertThrows { handler.handle(request) } - assertEquals("amount_fee.asset should be stellar asset", ex.message) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "Anchor is not configured to accept memo, memo_type and destination_account", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestTrustlineHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestTrustlineHandlerTest.kt index 0d57a9f911..a0bfae843c 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestTrustlineHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestTrustlineHandlerTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertTrue 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.event.AnchorEvent @@ -16,13 +18,15 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.exception.rpc.InvalidParamsException import org.stellar.anchor.api.exception.rpc.InvalidRequestException import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.platform.PlatformTransactionData.Kind.DEPOSIT -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_24 -import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_38 +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.* import org.stellar.anchor.api.rpc.method.RequestTrustRequest import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_ANCHOR import org.stellar.anchor.api.sep.SepTransactionStatus.PENDING_TRUST import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.Customers +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.asset.AssetService import org.stellar.anchor.config.CustodyConfig import org.stellar.anchor.event.EventService @@ -30,10 +34,12 @@ import org.stellar.anchor.event.EventService.EventQueue.TRANSACTION import org.stellar.anchor.event.EventService.Session import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils class RequestTrustlineHandlerTest { @@ -44,6 +50,8 @@ class RequestTrustlineHandlerTest { private const val VALIDATION_ERROR_MESSAGE = "Invalid request" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -70,6 +78,7 @@ class RequestTrustlineHandlerTest { every { eventService.createSession(any(), TRANSACTION) } returns eventSession this.handler = RequestTrustlineHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -89,6 +98,7 @@ class RequestTrustlineHandlerTest { txn24.transferReceivedAt = Instant.now() val spyTxn24 = spyk(txn24) + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns spyTxn24 every { txn31Store.findByTransactionId(any()) } returns null every { spyTxn24.protocol } returns SEP_38.sep.toString() @@ -99,61 +109,66 @@ class RequestTrustlineHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_unsupportedStatus() { + fun test_handle_custodyIntegrationEnabled() { val request = RequestTrustRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_TRUST.toString() + txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } - assertEquals( - "RPC method[request_trust] is not supported. Status[pending_trust], kind[deposit], protocol[24], funds received[true]", - ex.message - ) + assertEquals("RPC method[request_trust] requires disabled custody integration", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_handle_custodyIntegrationEnabled() { + fun test_handle_invalidRequest() { val request = RequestTrustRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() - every { custodyConfig.isCustodyIntegrationEnabled } returns true + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null + every { requestValidator.validate(request) } throws + InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals("RPC method[request_trust] requires disabled custody integration", ex.message) + val ex = assertThrows { handler.handle(request) } + assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_transferNotReceived() { + fun test_handle_sep24_transferNotReceived() { val request = RequestTrustRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() txn24.status = PENDING_ANCHOR.toString() txn24.kind = DEPOSIT.kind every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null @@ -163,34 +178,38 @@ class RequestTrustlineHandlerTest { ex.message ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_invalidRequest() { + fun test_handle_sep24_unsupportedStatus() { val request = RequestTrustRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() - txn24.status = PENDING_ANCHOR.toString() + txn24.status = PENDING_TRUST.toString() txn24.kind = DEPOSIT.kind txn24.transferReceivedAt = Instant.now() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null - every { requestValidator.validate(request) } throws - InvalidParamsException(VALIDATION_ERROR_MESSAGE) - val ex = assertThrows { handler.handle(request) } - assertEquals(VALIDATION_ERROR_MESSAGE, ex.message?.trimIndent()) + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_trust] is not supported. Status[pending_trust], kind[deposit], protocol[24], funds received[true]", + ex.message + ) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } @Test - fun test_handle_ok() { + fun test_handle_sep24_ok() { val transferReceivedAt = Instant.now() val request = RequestTrustRequest.builder().transactionId(TX_ID).build() val txn24 = JdbcSep24Transaction() @@ -200,6 +219,7 @@ class RequestTrustlineHandlerTest { val sep24TxnCapture = slot() val anchorEventCapture = slot() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(TX_ID) } returns txn24 every { txn31Store.findByTransactionId(any()) } returns null every { txn24Store.save(capture(sep24TxnCapture)) } returns null @@ -212,6 +232,7 @@ class RequestTrustlineHandlerTest { val response = handler.handle(request) val endDate = Instant.now() + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } @@ -257,4 +278,153 @@ class RequestTrustlineHandlerTest { assertTrue(sep24TxnCapture.captured.updatedAt >= startDate) assertTrue(sep24TxnCapture.captured.updatedAt <= endDate) } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_transferNotReceived(kind: String) { + val request = RequestTrustRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + every { custodyConfig.isCustodyIntegrationEnabled } returns false + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_trust] is not supported. Status[pending_anchor], kind[$kind], protocol[6], funds received[false]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedKind(kind: String) { + val request = RequestTrustRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_trust] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_unsupportedStatus(kind: String) { + val request = RequestTrustRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_TRUST.toString() + txn6.kind = kind + txn6.transferReceivedAt = Instant.now() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + + val ex = assertThrows { handler.handle(request) } + assertEquals( + "RPC method[request_trust] is not supported. Status[pending_trust], kind[$kind], protocol[6], funds received[true]", + ex.message + ) + + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 0) { sepTransactionCounter.increment() } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_handle_sep6_ok(kind: String) { + val transferReceivedAt = Instant.now() + val request = RequestTrustRequest.builder().transactionId(TX_ID).build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_ANCHOR.toString() + txn6.kind = kind + txn6.transferReceivedAt = transferReceivedAt + val sep6TxnCapture = slot() + val anchorEventCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns false + every { eventSession.publish(capture(anchorEventCapture)) } just Runs + every { metricsService.counter(AnchorMetrics.PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + val startDate = Instant.now() + val response = handler.handle(request) + val endDate = Instant.now() + + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + verify(exactly = 1) { sepTransactionCounter.increment() } + + val expectedSep6Txn = JdbcSep6Transaction() + expectedSep6Txn.kind = kind + expectedSep6Txn.status = PENDING_TRUST.toString() + expectedSep6Txn.updatedAt = sep6TxnCapture.captured.updatedAt + expectedSep6Txn.transferReceivedAt = transferReceivedAt + + JSONAssert.assertEquals( + gson.toJson(expectedSep6Txn), + gson.toJson(sep6TxnCapture.captured), + JSONCompareMode.STRICT + ) + + val expectedResponse = GetTransactionResponse() + expectedResponse.sep = SEP_6 + expectedResponse.kind = PlatformTransactionData.Kind.from(kind) + expectedResponse.status = PENDING_TRUST + expectedResponse.amountExpected = Amount(null, "") + expectedResponse.updatedAt = sep6TxnCapture.captured.updatedAt + expectedResponse.transferReceivedAt = transferReceivedAt + expectedResponse.customers = Customers(StellarId(null, null, null), StellarId(null, null, null)) + + JSONAssert.assertEquals( + gson.toJson(expectedResponse), + gson.toJson(response), + JSONCompareMode.STRICT + ) + + val expectedEvent = + AnchorEvent.builder() + .id(anchorEventCapture.captured.id) + .sep(SEP_6.sep.toString()) + .type(TRANSACTION_STATUS_CHANGED) + .transaction(expectedResponse) + .build() + + JSONAssert.assertEquals( + gson.toJson(expectedEvent), + gson.toJson(anchorEventCapture.captured), + JSONCompareMode.STRICT + ) + + assertTrue(sep6TxnCapture.captured.updatedAt >= startDate) + assertTrue(sep6TxnCapture.captured.updatedAt <= endDate) + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RpcMethodHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RpcMethodHandlerTest.kt index 47d3054fa7..322a1d29ed 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RpcMethodHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RpcMethodHandlerTest.kt @@ -1,7 +1,9 @@ package org.stellar.anchor.platform.rpc -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify import java.util.* import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -23,11 +25,13 @@ import org.stellar.anchor.platform.data.JdbcSepTransaction import org.stellar.anchor.platform.validator.RequestValidator import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore class RpcMethodHandlerTest { // test implementation class RpcMethodHandlerTestImpl( + txn6Store: Sep6TransactionStore, txn24Store: Sep24TransactionStore, txn31Store: Sep31TransactionStore, requestValidator: RequestValidator, @@ -36,6 +40,7 @@ class RpcMethodHandlerTest { metricsService: MetricsService ) : RpcMethodHandler( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -69,6 +74,8 @@ class RpcMethodHandlerTest { private const val TX_ID = "testId" } + @MockK(relaxed = true) private lateinit var txn6Store: Sep6TransactionStore + @MockK(relaxed = true) private lateinit var txn24Store: Sep24TransactionStore @MockK(relaxed = true) private lateinit var txn31Store: Sep31TransactionStore @@ -88,6 +95,7 @@ class RpcMethodHandlerTest { MockKAnnotations.init(this, relaxUnitFun = true) this.handler = RpcMethodHandlerTestImpl( + txn6Store, txn24Store, txn31Store, requestValidator, @@ -103,12 +111,14 @@ class RpcMethodHandlerTest { val tnx24 = JdbcSep24Transaction() tnx24.status = INCOMPLETE.toString() + every { txn6Store.findByTransactionId(any()) } returns null every { txn24Store.findByTransactionId(any()) } returns null every { txn31Store.findByTransactionId(any()) } returns null val ex = assertThrows { handler.handle(request) } assertEquals("Transaction with id[testId] is not found", ex.message) + verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/CustodyServiceTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/CustodyServiceTest.kt index cf53c2ffcc..0fe4a59846 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/CustodyServiceTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/CustodyServiceTest.kt @@ -1,17 +1,14 @@ package org.stellar.anchor.platform.service -import io.mockk.MockKAnnotations -import io.mockk.Runs -import io.mockk.every +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.just -import io.mockk.slot -import io.mockk.verify import java.util.* import org.junit.jupiter.api.Assertions 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.CsvSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.api.custody.CreateCustodyTransactionRequest @@ -26,6 +23,7 @@ import org.stellar.anchor.custody.CustodyService import org.stellar.anchor.platform.apiclient.CustodyApiClient 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.util.GsonUtils class CustodyServiceTest { @@ -45,6 +43,46 @@ class CustodyServiceTest { custodyService = CustodyServiceImpl(Optional.of(custodyApiClient)) } + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_createTransaction_sep6Deposit(kind: String) { + val txn = + gson.fromJson(sep6DepositEntity, JdbcSep6Transaction::class.java).apply { this.kind = kind } + val requestCapture = slot() + + every { custodyApiClient.createTransaction(capture(requestCapture)) } just Runs + + custodyService.createTransaction(txn) + + JSONAssert.assertEquals( + sep6DepositRequest.replace("testKind", kind), + gson.toJson(requestCapture.captured), + JSONCompareMode.STRICT + ) + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_createTransaction_sep6Withdrawal(kind: String) { + val txn = + gson.fromJson(sep6WithdrawalEntity, JdbcSep6Transaction::class.java).apply { + this.kind = kind + } + val requestCapture = slot() + + every { custodyApiClient.createTransaction(capture(requestCapture)) } just Runs + + custodyService.createTransaction(txn) + + println(gson.toJson(requestCapture.captured)) + + JSONAssert.assertEquals( + sep6WithdrawalRequest.replace("testKind", kind), + gson.toJson(requestCapture.captured), + JSONCompareMode.STRICT + ) + } + @Test fun test_createTransaction_sep24Deposit() { val txn = gson.fromJson(sep24DepositEntity, JdbcSep24Transaction::class.java) @@ -188,6 +226,110 @@ class CustodyServiceTest { Assertions.assertEquals("Forbidden", exception.rawMessage) } + private val sep6DepositEntity = + """ + { + "id" : "testId", + "stellar_transaction_id": "testStellarTransactionId", + "external_transaction_id": "testExternalTransactionId", + "status": "pending_anchor", + "kind": "deposit", + "started_at": "2022-04-18T14:00:00.000Z", + "completed_at": "2022-04-18T14:00:00.000Z", + "updated_at": "2022-04-18T14:00:00.000Z", + "transfer_received_at": "2022-04-18T14:00:00.000Z", + "type": "SWIFT", + "requestAssetCode": "testRequestAssetCode", + "requestAssetIssuer": "testRequestAssetIssuer", + "amount_in": "testAmountIn", + "amount_in_asset": "testAmountInAsset", + "amount_out": "testAmountOut", + "amount_out_asset": "testAmountOutAsset", + "amount_fee": "testAmountFee", + "amount_fee_asset": "testAmountFeeAsset", + "amount_expected": "testAmountExpected", + "sep10_account": "testSep10Account", + "sep10_account_memo": "testSep10AccountMemo", + "from_account": "testFromAccount", + "to_account": "testToAccount", + "memo": "testMemo", + "memo_type": "testMemoType", + "quote_id": "testQuoteId", + "message": "testMessage", + "refundMemo": "testRefundMemo", + "refundMemoType": "testRefundMemoType" + } + """ + .trimIndent() + + private val sep6DepositRequest = + """ + { + "id": "testId", + "memo": "testMemo", + "memoType": "testMemoType", + "protocol": "6", + "toAccount": "testToAccount", + "amount": "testAmountOut", + "asset": "testAmountOutAsset", + "kind": "testKind" + } + """ + .trimIndent() + + private val sep6WithdrawalEntity = + """ + { + "id": "testId", + "stellar_transaction_id": "testStellarTransactionId", + "external_transaction_id": "testExternalTransactionId", + "status": "pending_anchor", + "kind": "withdrawal", + "started_at": "2022-04-18T14:00:00.000Z", + "completed_at": "2022-04-18T14:00:00.000Z", + "updated_at": "2022-04-18T14:00:00.000Z", + "transfer_received_at": "2022-04-18T14:00:00.000Z", + "type": "bank_account", + "requestAssetCode": "testRequestAssetCode", + "requestAssetIssuer": "testRequestAssetIssuer", + "amount_in": "testAmountIn", + "amount_in_asset": "testAmountInAsset", + "amount_out": "testAmountOut", + "amount_out_asset": "testAmountOutAsset", + "amount_fee": "testAmountFee", + "amount_fee_asset": "testAmountFeeAsset", + "amount_expected": "testAmountExpected", + "sep10_account": "testSep10Account", + "sep10_account_memo": "testSep10AccountMemo", + "withdraw_anchor_account": "testWithdrawAnchorAccount", + "from_account": "testFromAccount", + "to_account": "testToAccount", + "memo": "testMemo", + "memo_type": "testMemoType", + "quote_id": "testQuoteId", + "message": "testMessage", + "refundMemo": "testRefundMemo", + "refundMemoType": "testRefundMemoType" + } + """ + .trimIndent() + + private val sep6WithdrawalRequest = + """ + { + "id": "testId", + "memo": "testMemo", + "memoType": "testMemoType", + "protocol": "6", + "fromAccount": "testFromAccount", + "toAccount": "testWithdrawAnchorAccount", + "amount": "testAmountExpected", + "asset": "testAmountInAsset", + "kind": "testKind" + } + """ + .trimIndent() + private val sep24DepositEntity = """ { 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 684ef2e1c9..e5ba534ebf 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 @@ -14,13 +14,10 @@ import org.junit.jupiter.params.provider.CsvSource import org.stellar.anchor.api.exception.SepException import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.sep.SepTransactionStatus -import org.stellar.anchor.api.shared.* +import org.stellar.anchor.api.shared.StellarId import org.stellar.anchor.apiclient.PlatformApiClient import org.stellar.anchor.platform.config.RpcConfig -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.* import org.stellar.anchor.platform.observer.ObservedPayment import org.stellar.anchor.util.GsonUtils import org.stellar.sdk.Asset @@ -30,6 +27,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 @MockK(relaxed = true) private lateinit var rpcConfig: RpcConfig @@ -44,6 +42,7 @@ class PaymentOperationToEventListenerTest { PaymentOperationToEventListener( sep31TransactionStore, sep24TransactionStore, + sep6TransactionStore, platformApiClient, rpcConfig ) @@ -58,12 +57,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 @@ -72,6 +75,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" @@ -93,6 +98,9 @@ class PaymentOperationToEventListenerTest { } returns null every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns null + every { + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns null paymentOperationToEventListener.onReceived(p) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( @@ -414,6 +422,9 @@ class PaymentOperationToEventListenerTest { every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns null + every { + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns null val sep24TxnCopy = gson.fromJson(gson.toJson(sep24TxMock), JdbcSep24Transaction::class.java) every { @@ -454,6 +465,101 @@ class PaymentOperationToEventListenerTest { assertEquals("payment received", messageCapture.captured) } + @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 sep6TxMock = JdbcSep6Transaction() + sep6TxMock.id = "ceaa7677-a5a7-434e-b02a-8e0801b3e7bd" + sep6TxMock.requestAssetCode = assetCode + sep6TxMock.requestAssetIssuer = assetIssuer + sep6TxMock.amountExpected = "10.0000000" + sep6TxMock.memo = "OWI3OGYwZmEtOTNmOS00MTk4LThkOTMtZTc2ZmQwODQ" + sep6TxMock.memoType = "hash" + sep6TxMock.kind = PlatformTransactionData.Kind.WITHDRAWAL.kind + + // TODO: this shouldn't be necessary + every { + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) + } returns null + every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns + null + + val sep6TxnCopy = gson.fromJson(gson.toJson(sep6TxMock), JdbcSep6Transaction::class.java) + every { + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + capture(slotMemo), + capture(slotStatus) + ) + } returns sep6TxnCopy + + val txnIdCapture = slot() + val stellarTxnIdCapture = slot() + val amountCapture = slot() + val messageCapture = slot() + + every { rpcConfig.customMessages.incomingPaymentReceived } returns "payment received" + every { + platformApiClient.notifyOnchainFundsReceived( + capture(txnIdCapture), + capture(stellarTxnIdCapture), + capture(amountCapture), + capture(messageCapture) + ) + } just Runs + + paymentOperationToEventListener.onReceived(p) + verify(exactly = 1) { + sep24TransactionStore.findOneByToAccountAndMemoAndStatus( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + "OWI3OGYwZmEtOTNmOS00MTk4LThkOTMtZTc2ZmQwODQ=", + "pending_user_transfer_start" + ) + } + + assertEquals(sep6TxMock.id, txnIdCapture.captured) + assertEquals(p.transactionHash, stellarTxnIdCapture.captured) + assertEquals(p.amount, amountCapture.captured) + assertEquals("payment received", messageCapture.captured) + } + private fun createAsset(assetType: String, assetCode: String, assetIssuer: String?): Asset { return if (assetType == "native") { AssetTypeNative() diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGeneratorTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGeneratorTest.kt new file mode 100644 index 0000000000..d241f1ab41 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoCustodyGeneratorTest.kt @@ -0,0 +1,44 @@ +package org.stellar.anchor.platform.service + +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.stellar.anchor.api.custody.GenerateDepositAddressResponse +import org.stellar.anchor.api.shared.SepDepositInfo +import org.stellar.anchor.platform.apiclient.CustodyApiClient +import org.stellar.anchor.platform.data.JdbcSep6Transaction + +class Sep6DepositInfoCustodyGeneratorTest { + companion object { + private const val ADDRESS = "testAccount" + private const val MEMO = "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc=" + private const val MEMO_TYPE = "hash" + private const val ASSET_ID = "USDC" + } + + @MockK(relaxed = true) lateinit var custodyApiClient: CustodyApiClient + + private lateinit var generator: Sep6DepositInfoCustodyGenerator + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + generator = Sep6DepositInfoCustodyGenerator(custodyApiClient) + } + + @Test + fun test_sep6_custodyGenerator_success() { + val txn = JdbcSep6Transaction() + txn.amountInAsset = ASSET_ID + + every { custodyApiClient.generateDepositAddress(ASSET_ID) } returns + GenerateDepositAddressResponse(ADDRESS, MEMO, MEMO_TYPE) + + val result = generator.generate(txn) + val expected = SepDepositInfo(ADDRESS, MEMO, MEMO_TYPE) + assertEquals(expected, result) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGeneratorTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGeneratorTest.kt new file mode 100644 index 0000000000..6edb815058 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep6DepositInfoSelfGeneratorTest.kt @@ -0,0 +1,49 @@ +package org.stellar.anchor.platform.service + +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.stellar.anchor.api.sep.AssetInfo +import org.stellar.anchor.api.shared.SepDepositInfo +import org.stellar.anchor.asset.AssetService +import org.stellar.anchor.platform.data.JdbcSep6Transaction + +class Sep6DepositInfoSelfGeneratorTest { + + companion object { + private val TXN_ID = "testId" + private const val ASSET_CODE = "USDC" + private const val ASSET_ISSUER = "testIssuer" + private const val DISTRIBUTION_ACCOUNT = "testAccount" + } + + @MockK(relaxed = true) lateinit var assetService: AssetService + + private lateinit var generator: Sep6DepositInfoSelfGenerator + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + val asset = mockk() + every { asset.distributionAccount } returns DISTRIBUTION_ACCOUNT + every { assetService.getAsset(ASSET_CODE, ASSET_ISSUER) } returns asset + generator = Sep6DepositInfoSelfGenerator(assetService) + } + + @Test + fun test_sep6_custodyGenerator_success() { + val txn = JdbcSep6Transaction() + txn.id = TXN_ID + txn.requestAssetCode = ASSET_CODE + txn.requestAssetIssuer = ASSET_ISSUER + + val result = generator.generate(txn) + val expected = + SepDepositInfo(DISTRIBUTION_ACCOUNT, "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDB0ZXN0SWQ=", "hash") + assertEquals(expected, result) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructorTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructorTest.kt index 660d9c7b1d..2f7d2a10ee 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructorTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/SimpleInteractiveUrlConstructorTest.kt @@ -22,8 +22,8 @@ import org.stellar.anchor.auth.JwtService.* import org.stellar.anchor.auth.Sep10Jwt import org.stellar.anchor.auth.Sep24InteractiveUrlJwt import org.stellar.anchor.config.ClientsConfig.ClientConfig -import org.stellar.anchor.config.ClientsConfig.ClientType -import org.stellar.anchor.config.ClientsConfig.ClientType.* +import org.stellar.anchor.config.ClientsConfig.ClientType.CUSTODIAL +import org.stellar.anchor.config.ClientsConfig.ClientType.NONCUSTODIAL import org.stellar.anchor.config.CustodySecretConfig import org.stellar.anchor.config.SecretConfig import org.stellar.anchor.platform.callback.PlatformIntegrationHelperTest.Companion.TEST_HOME_DOMAIN @@ -81,14 +81,14 @@ class SimpleInteractiveUrlConstructorTest { } returns ClientConfig( "some-wallet", - ClientType.CUSTODIAL, + CUSTODIAL, "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", null, null, false, null ) - every { testAsset.assetName } returns + every { testAsset.sep38AssetName } returns "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" every { sep10Jwt.homeDomain } returns TEST_HOME_DOMAIN 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 057da67266..ab55f9a89e 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 @@ -1,13 +1,15 @@ package org.stellar.anchor.platform.service -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.impl.annotations.MockK -import java.util.* +import io.mockk.verify import org.junit.jupiter.api.Assertions.* 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.CsvSource import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.NullSource import org.junit.jupiter.params.provider.ValueSource @@ -22,6 +24,8 @@ import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.sep.sep38.RateFee 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.CustodyConfig @@ -35,6 +39,8 @@ import org.stellar.anchor.sep24.Sep24Transaction import org.stellar.anchor.sep24.Sep24TransactionStore import org.stellar.anchor.sep31.Sep31TransactionStore import org.stellar.anchor.sep38.Sep38QuoteStore +import org.stellar.anchor.sep6.Sep6DepositInfoGenerator +import org.stellar.anchor.sep6.Sep6Transaction import org.stellar.anchor.sep6.Sep6TransactionStore import org.stellar.anchor.util.GsonUtils @@ -57,6 +63,7 @@ class TransactionServiceTest { @MockK(relaxed = true) private lateinit var assetService: AssetService @MockK(relaxed = true) private lateinit var eventService: EventService @MockK(relaxed = true) private lateinit var eventSession: Session + @MockK(relaxed = true) private lateinit var sep6DepositInfoGenerator: Sep6DepositInfoGenerator @MockK(relaxed = true) private lateinit var sep24DepositInfoGenerator: Sep24DepositInfoGenerator @MockK(relaxed = true) private lateinit var custodyService: CustodyService @MockK(relaxed = true) private lateinit var custodyConfig: CustodyConfig @@ -75,6 +82,7 @@ class TransactionServiceTest { sep38QuoteStore, assetService, eventService, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, custodyService, custodyConfig @@ -96,6 +104,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) @@ -125,6 +134,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() } @@ -141,6 +151,27 @@ class TransactionServiceTest { ) } + @Test + fun `test get SEP6 transaction`() { + // Mock the store + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep24TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.newInstance() } returns JdbcSep6Transaction() + every { sep6TransactionStore.newRefunds() } returns Refunds() + every { sep6TransactionStore.newRefundPayment() } answers { RefundPayment() } + + val mockSep6Transaction = gson.fromJson(jsonSep6Transaction, JdbcSep6Transaction::class.java) + + every { sep6TransactionStore.findByTransactionId(TEST_TXN_ID) } returns mockSep6Transaction + val gotGetTransactionResponse = transactionService.findTransaction(TEST_TXN_ID) + + JSONAssert.assertEquals( + wantedGetSep6TransactionResponse, + gson.toJson(gotGetTransactionResponse), + LENIENT + ) + } + @Test fun test_validateAsset_failure() { // fails if amount_in.amount is null @@ -205,6 +236,7 @@ class TransactionServiceTest { sep38QuoteStore, assetService, eventService, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, custodyService, custodyConfig @@ -263,6 +295,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) @@ -287,6 +320,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) @@ -311,6 +345,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 @@ -338,6 +373,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 @@ -364,6 +400,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) @@ -373,6 +410,138 @@ class TransactionServiceTest { verify(exactly = 1) { eventSession.publish(any()) } } + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_patchTransaction_sep6DepositPendingUserTransferStart(kind: String) { + val txId = "testTxId" + val tx = JdbcSep6Transaction() + tx.status = SepTransactionStatus.INCOMPLETE.toString() + tx.kind = kind + val data = PlatformTransactionData() + data.id = txId + data.memo = "12345" + data.memoType = "id" + data.status = SepTransactionStatus.PENDING_USR_TRANSFER_START + val request = + PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() + + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns tx + every { sep24TransactionStore.findByTransactionId(any()) } returns null + + transactionService.patchTransactions(request) + + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sep6TransactionStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_patchTransaction_sep6WithdrawalPendingAnchor(kind: String) { + val txId = "testTxId" + val tx = JdbcSep6Transaction() + tx.status = SepTransactionStatus.INCOMPLETE.toString() + tx.kind = kind + val data = PlatformTransactionData() + data.id = txId + data.memo = "12345" + data.memoType = "id" + data.status = SepTransactionStatus.PENDING_ANCHOR + val request = + PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() + + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns tx + every { sep24TransactionStore.findByTransactionId(any()) } returns null + + transactionService.patchTransactions(request) + + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sep6TransactionStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + } + + @CsvSource(value = ["deposit", "deposit-exchange"]) + @ParameterizedTest + fun test_patchTransaction_sep6DepositPendingAnchor(kind: String) { + val txId = "testTxId" + val tx = JdbcSep6Transaction() + tx.status = SepTransactionStatus.INCOMPLETE.toString() + tx.kind = kind + val data = PlatformTransactionData() + data.id = txId + data.memo = "12345" + data.memoType = "id" + data.status = SepTransactionStatus.PENDING_ANCHOR + val request = + PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() + + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns tx + every { sep24TransactionStore.findByTransactionId(any()) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + transactionService.patchTransactions(request) + + verify(exactly = 1) { custodyService.createTransaction(ofType(Sep6Transaction::class)) } + verify(exactly = 1) { sep6TransactionStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_patchTransaction_sep6WithdrawalPendingUserTransferStart(kind: String) { + val txId = "testTxId" + val tx = JdbcSep6Transaction() + tx.status = SepTransactionStatus.INCOMPLETE.toString() + tx.kind = kind + val data = PlatformTransactionData() + data.id = txId + data.memo = "12345" + data.memoType = "id" + data.status = SepTransactionStatus.PENDING_USR_TRANSFER_START + val request = + PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() + + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns tx + every { sep24TransactionStore.findByTransactionId(any()) } returns null + every { custodyConfig.isCustodyIntegrationEnabled } returns true + + transactionService.patchTransactions(request) + + verify(exactly = 1) { custodyService.createTransaction(ofType(Sep6Transaction::class)) } + verify(exactly = 1) { sep6TransactionStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + } + + @CsvSource(value = ["withdrawal", "withdrawal-exchange"]) + @ParameterizedTest + fun test_patchTransaction_sep6WithdrawalPendingUserTransferStart_statusNotChanged(kind: String) { + val txId = "testTxId" + val tx = JdbcSep6Transaction() + tx.status = SepTransactionStatus.PENDING_USR_TRANSFER_START.toString() + tx.kind = kind + val data = PlatformTransactionData() + data.id = txId + data.memo = "12345" + data.memoType = "id" + data.status = SepTransactionStatus.PENDING_USR_TRANSFER_START + val request = + PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() + + every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns tx + every { sep24TransactionStore.findByTransactionId(any()) } returns null + + transactionService.patchTransactions(request) + + verify(exactly = 0) { custodyService.createTransaction(ofType(Sep24Transaction::class)) } + verify(exactly = 1) { sep6TransactionStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + } + @Test fun test_updateSep31Transaction() { val quoteId = "my-quote-id" @@ -481,6 +650,7 @@ class TransactionServiceTest { sep38QuoteStore, assetService, eventService, + sep6DepositInfoGenerator, sep24DepositInfoGenerator, custodyService, custodyConfig @@ -531,6 +701,46 @@ class TransactionServiceTest { assertTrue(testSep31Transaction.updatedAt > testSep31Transaction.startedAt) } + private val jsonSep6Transaction = + """ + { + "id": "069364b1-f9f1-464f-8da2-5c36f9aad1a6", + "kind": "deposit", + "status": "completed", + "amount_in": "1", + "amount_in_asset": "iso4217:USD", + "amount_out": "1", + "amount_out_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amount_fee": "0", + "amount_fee_asset": "iso4217:USD", + "to": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG", + "started_at": "2023-10-31T21:16:29.764842Z", + "updated_at": "2023-10-31T21:16:44.652018Z", + "completed_at": "2023-10-31T21:16:44.652008Z", + "stellar_transaction_id": "a8b7f7ba67a5c63975512aa113c5a177e675c5e195a2e15920b39f5a5a91f306", + "message": "Funds sent to user", + "required_customer_info_updates": [ + "id_type", + "id_country_code", + "id_issue_date", + "id_expiration_date", + "id_number", + "address" + ], + "instructions": { + "organization.bank_number": { + "value": "121122676", + "description": "US Bank routing number" + }, + "organization.bank_account_number": { + "value": "13719713158835300", + "description": "US Bank account number" + } + } + } + """ + .trimIndent() + private val jsonSep24Transaction = """ { @@ -853,6 +1063,47 @@ class TransactionServiceTest { """ .trimIndent() + private val wantedGetSep6TransactionResponse = + """ + { + "id": "069364b1-f9f1-464f-8da2-5c36f9aad1a6", + "sep": "6", + "kind": "deposit", + "status": "completed", + "amount_expected": { "asset": "" }, + "amount_in": { "amount": "1", "asset": "iso4217:USD" }, + "amount_out": { + "amount": "1", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { "amount": "0", "asset": "iso4217:USD" }, + "started_at": "2023-10-31T21:16:29.764842Z", + "updated_at": "2023-10-31T21:16:44.652018Z", + "completed_at": "2023-10-31T21:16:44.652008Z", + "message": "Funds sent to user", + "customers": { "sender": {}, "receiver": {} }, + "required_customer_info_updates": [ + "id_type", + "id_country_code", + "id_issue_date", + "id_expiration_date", + "id_number", + "address" + ], + "instructions": { + "organization.bank_number": { + "value": "121122676", + "description": "US Bank routing number" + }, + "organization.bank_account_number": { + "value": "13719713158835300", + "description": "US Bank account number" + } + } + } + """ + .trimIndent() + @Test fun `patch transaction with bad body`() { var patchTransactionsRequest = PatchTransactionsRequest.builder().records(null).build() 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 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/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. # 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" diff --git a/service-runner/src/main/resources/profiles/default-custody-rpc/config.env b/service-runner/src/main/resources/profiles/default-custody-rpc/config.env index e480fa6e71..5ce0f9026d 100644 --- a/service-runner/src/main/resources/profiles/default-custody-rpc/config.env +++ b/service-runner/src/main/resources/profiles/default-custody-rpc/config.env @@ -42,6 +42,7 @@ sep38.enabled=true sep24.enabled=true sep24.interactive_url.base_url=http://localhost:8091/sep24/interactive sep24.more_info_url.base_url=http://localhost:8091/sep24/transaction/more_info +sep6.deposit_info_generator_type=custody sep24.deposit_info_generator_type=custody sep31.deposit_info_generator_type=custody custody.type=fireblocks diff --git a/service-runner/src/main/resources/profiles/default-custody/config.env b/service-runner/src/main/resources/profiles/default-custody/config.env index e480fa6e71..5ce0f9026d 100644 --- a/service-runner/src/main/resources/profiles/default-custody/config.env +++ b/service-runner/src/main/resources/profiles/default-custody/config.env @@ -42,6 +42,7 @@ sep38.enabled=true sep24.enabled=true sep24.interactive_url.base_url=http://localhost:8091/sep24/interactive sep24.more_info_url.base_url=http://localhost:8091/sep24/transaction/more_info +sep6.deposit_info_generator_type=custody sep24.deposit_info_generator_type=custody sep31.deposit_info_generator_type=custody custody.type=fireblocks 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 593787f5eb..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 @@ -44,16 +44,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 @@ -61,32 +65,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 d32c54e252..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,5 +1,6 @@ package org.stellar.reference.wallet +import com.google.gson.Gson import com.google.gson.JsonObject import io.ktor.http.* import io.ktor.server.application.* @@ -11,7 +12,7 @@ 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") { 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 d18c34eb02..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 @@ -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, responseType: Class): List { + suspend fun getCallbacks(txnId: String? = null, responseType: Class): List { val response = client.get { url { @@ -42,14 +43,28 @@ class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { } } - // Parse the JSON string into a list of Person objects return gson.fromJson( response.body(), TypeToken.getParameterized(List::class.java, responseType).type ) } - suspend fun getLatestCallback(): Sep24GetTransactionResponse? { + suspend fun pollCallbacks(txnId: String?, expected: Int, responseType: Class): List { + var retries = 5 + var callbacks: List = listOf() + while (retries > 0) { + // TODO: remove when callbacks are de-duped + callbacks = getCallbacks(txnId, responseType).distinct() + if (callbacks.size >= expected) { + return callbacks + } + delay(5.seconds) + retries-- + } + return callbacks + } + + suspend fun getLatestCallback(): T? { val response = client.get { url { @@ -59,7 +74,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() {