From 786ffd2b6b8ac82ee4ebec0c3b48a2de599dbb7a Mon Sep 17 00:00:00 2001 From: philipliu Date: Mon, 2 Oct 2023 15:27:36 -0400 Subject: [PATCH] Validate KYC before initiating transaction --- .../SepCustomerInfoNeededException.java | 12 ++++ .../api/sep/CustomerInfoNeededResponse.java | 12 ++++ .../anchor/api/sep/sep12/Sep12Status.java | 5 +- .../stellar/anchor/sep6/RequestValidator.java | 31 ++++++++- .../anchor/sep6/RequestValidatorTest.kt | 68 +++++++++++++++++-- .../anchor/platform/test/Sep6End2EndTest.kt | 8 ++- .../stellar/anchor/platform/test/Sep6Tests.kt | 22 +++++- .../platform/component/sep/SepBeans.java | 3 +- .../AbstractControllerExceptionHandler.java | 7 ++ 9 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java diff --git a/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java b/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java new file mode 100644 index 0000000000..49eb2d0aa4 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java @@ -0,0 +1,12 @@ +package org.stellar.anchor.api.exception; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Thrown when a customer's info is needed to complete a request. */ +@RequiredArgsConstructor +@Getter +public class SepCustomerInfoNeededException extends AnchorException { + private final List fields; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java new file mode 100644 index 0000000000..25ac39a272 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java @@ -0,0 +1,12 @@ +package org.stellar.anchor.api.sep; + +import java.util.List; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Data +public class CustomerInfoNeededResponse { + private final String type = "non_interactive_customer_info_needed"; + private final List fields; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java index fb7c9b992a..7d5cfd23eb 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java @@ -13,10 +13,7 @@ public enum Sep12Status { PROCESSING("PROCESSING"), @SerializedName("REJECTED") - REJECTED("REJECTED"), - - @SerializedName("VERIFICATION_REQUIRED") - VERIFICATION_REQUIRED("VERIFICATION_REQUIRED"); + REJECTED("REJECTED"); private final String name; diff --git a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java index 4193a4500c..677cc652ff 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java +++ b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java @@ -1,10 +1,14 @@ package org.stellar.anchor.sep6; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import lombok.NonNull; import lombok.RequiredArgsConstructor; -import org.stellar.anchor.api.exception.SepValidationException; +import org.stellar.anchor.api.callback.CustomerIntegration; +import org.stellar.anchor.api.callback.GetCustomerRequest; +import org.stellar.anchor.api.callback.GetCustomerResponse; +import org.stellar.anchor.api.exception.*; import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.asset.AssetService; import org.stellar.sdk.KeyPair; @@ -13,6 +17,7 @@ @RequiredArgsConstructor public class RequestValidator { @NonNull private final AssetService assetService; + @NonNull private final CustomerIntegration customerIntegration; /** * Validates that the requested asset is valid and enabled for deposit. @@ -102,11 +107,33 @@ public void validateTypes(String requestType, String assetCode, List val * @param account the account * @throws SepValidationException if the account is invalid */ - public void validateAccount(String account) throws SepValidationException { + public void validateAccount(String account) throws AnchorException { try { KeyPair.fromAccountId(account); } catch (RuntimeException ex) { throw new SepValidationException(String.format("invalid account %s", account)); } + + GetCustomerRequest request = GetCustomerRequest.builder().account(account).build(); + GetCustomerResponse response = customerIntegration.getCustomer(request); + + if (response == null || response.getStatus() == null) { + throw new ServerErrorException("unable to get required fields for customer") {}; + } + + switch (response.getStatus()) { + case "NEEDS_INFO": + throw new SepCustomerInfoNeededException(new ArrayList<>(response.getFields().keySet())); + case "PROCESSING": + throw new SepNotAuthorizedException("customer is being reviewed by anchor"); + case "REJECTED": + throw new SepNotAuthorizedException("customer rejected by anchor"); + case "ACCEPTED": + // do nothing + break; + default: + throw new ServerErrorException( + String.format("unknown customer status: %s", response.getStatus())); + } } } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt index 044b6aee9b..f3e75f44c3 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt @@ -1,9 +1,8 @@ package org.stellar.anchor.sep6 -import io.mockk.MockKAnnotations -import io.mockk.every +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.mockk +import kotlin.test.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -11,19 +10,28 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.api.callback.CustomerIntegration +import org.stellar.anchor.api.callback.GetCustomerRequest +import org.stellar.anchor.api.callback.GetCustomerResponse +import org.stellar.anchor.api.exception.SepCustomerInfoNeededException +import org.stellar.anchor.api.exception.SepNotAuthorizedException import org.stellar.anchor.api.exception.SepValidationException +import org.stellar.anchor.api.exception.ServerErrorException import org.stellar.anchor.api.sep.AssetInfo +import org.stellar.anchor.api.sep.sep12.Sep12Status +import org.stellar.anchor.api.shared.CustomerField import org.stellar.anchor.asset.AssetService class RequestValidatorTest { @MockK(relaxed = true) lateinit var assetService: AssetService + @MockK(relaxed = true) lateinit var customerIntegration: CustomerIntegration private lateinit var requestValidator: RequestValidator @BeforeEach fun setup() { MockKAnnotations.init(this, relaxUnitFun = true) - requestValidator = RequestValidator(assetService) + requestValidator = RequestValidator(assetService, customerIntegration) } @Test @@ -140,11 +148,63 @@ class RequestValidatorTest { @Test fun `test validateAccount`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.ACCEPTED.name).build() requestValidator.validateAccount(TEST_ACCOUNT) } @Test fun `test validateAccount with invalid account`() { assertThrows { requestValidator.validateAccount("??") } + + verify { customerIntegration wasNot called } + } + + @Test + fun `test validateAccount customerIntegration failure`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } throws RuntimeException("test") + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with needs info customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns + GetCustomerResponse.builder() + .status(Sep12Status.NEEDS_INFO.name) + .fields(mapOf("first_name" to CustomerField.builder().build())) + .build() + val ex = + assertThrows { + requestValidator.validateAccount(TEST_ACCOUNT) + } + assertEquals(listOf("first_name"), ex.fields) + } + + @Test + fun `test validateAccount with processing customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.PROCESSING.name).build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with rejected customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.REJECTED.name).build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with unknown status customer`() { + every { customerIntegration.getCustomer(any()) } returns + GetCustomerResponse.builder().status("??").build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index 365c902ece..80782dcfda 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -52,7 +52,9 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) // Create a customer before starting the transaction - anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + anchor + .customer(token) + .add(mapOf("first_name" to "John", "last_name" to "Doe", "email_address" to "john@email.com")) val deposit = sep6Client.deposit( @@ -98,7 +100,9 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) // Create a customer before starting the transaction - anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + anchor + .customer(token) + .add(mapOf("first_name" to "John", "last_name" to "Doe", "email_address" to "john@email.com")) val withdraw = sep6Client.withdraw( diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index e25229958f..f4251e80b8 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -2,14 +2,24 @@ package org.stellar.anchor.platform.test import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode +import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.platform.CLIENT_WALLET_ACCOUNT +import org.stellar.anchor.platform.Sep12Client import org.stellar.anchor.platform.Sep6Client import org.stellar.anchor.platform.gson import org.stellar.anchor.util.Log import org.stellar.anchor.util.Sep1Helper.TomlContent class Sep6Tests(val toml: TomlContent, jwt: String) { - private val sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) + private val sep6Client: Sep6Client + private val sep12Client: Sep12Client + + init { + sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) + sep12Client = Sep12Client(toml.getString("KYC_SERVER"), jwt) + // Create a customer before running any tests + putCustomer() + } private val expectedSep6Info = """ @@ -150,6 +160,16 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { ) } + private fun putCustomer() { + val request = + Sep12PutCustomerRequest.builder() + .firstName("John") + .lastName("Doe") + .emailAddress("john@email.com") + .build() + sep12Client.putCustomer(request) + } + fun testAll() { Log.info("Performing SEP6 tests") `test Sep6 info endpoint`() diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index 0c87563e3f..643a7b2bbb 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 @@ -122,9 +122,10 @@ Sep6Service sep6Service( AssetService assetService, Sep6TransactionStore txnStore, EventService eventService, + CustomerIntegration customerIntegration, FeeIntegration feeIntegration, Sep38QuoteStore sep38QuoteStore) { - RequestValidator requestValidator = new RequestValidator(assetService); + RequestValidator requestValidator = new RequestValidator(assetService, customerIntegration); ExchangeAmountsCalculator exchangeAmountsCalculator = new ExchangeAmountsCalculator(feeIntegration, sep38QuoteStore, assetService); return new Sep6Service( diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java index c801f0439a..6f0299886f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.stellar.anchor.api.exception.*; +import org.stellar.anchor.api.sep.CustomerInfoNeededResponse; import org.stellar.anchor.api.sep.SepExceptionResponse; public abstract class AbstractControllerExceptionHandler { @@ -41,6 +42,12 @@ public SepExceptionResponse handleAuthError(SepException ex) { return new SepExceptionResponse(ex.getMessage()); } + @ExceptionHandler(SepCustomerInfoNeededException.class) + @ResponseStatus(value = HttpStatus.FORBIDDEN) + public CustomerInfoNeededResponse handle(SepCustomerInfoNeededException ex) { + return new CustomerInfoNeededResponse(ex.getFields()); + } + @ExceptionHandler({SepNotFoundException.class, NotFoundException.class}) @ResponseStatus(value = HttpStatus.NOT_FOUND) SepExceptionResponse handleNotFound(AnchorException ex) {