diff --git a/pom.xml b/pom.xml index 974a13c1698182312d3eb64dd07e64db7fc08d84..1488f1a9d46a8a40328f8c72a55ace44ed94d6d4 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,14 @@ h2 1.4.196 + + + + org.apache.commons + commons-lang3 + 3.8.1 + + diff --git a/src/main/java/com/nynja/walletservice/blockexplorer/EtherScanConsumer.java b/src/main/java/com/nynja/walletservice/blockexplorer/EtherScanConsumer.java index c8ecdb8511c7ecff6c1d94e94af7a4ff3776bef4..b030db340c6031b61f289c20c2a2939a39c70764 100644 --- a/src/main/java/com/nynja/walletservice/blockexplorer/EtherScanConsumer.java +++ b/src/main/java/com/nynja/walletservice/blockexplorer/EtherScanConsumer.java @@ -12,6 +12,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import static org.apache.commons.lang3.StringUtils.isBlank; + @Component @Slf4j public class EtherScanConsumer { @@ -22,39 +24,60 @@ public class EtherScanConsumer { @Value("${etherscan.apikey}") private String apikey; - public Response getNormalTransactionsByAddress(String address) { - return getTransactionsByAddress("txlist", address, "0", "9999999999", null); + //TODO Use ChainId to select etherscan urls + public Response getTxHistory(String address, String chainId, String contractAddress, String page, String offset, Sort sort) { + if (isBlank(contractAddress)) { + return getTransactionsByAddress("txlist", address, chainId, page, offset, sort); + } else { + return getERC20Transactions(address, chainId, contractAddress, page, offset, sort); + } + } + + public Response getInternalTransactions(String address, String chainId, String page, String offset, Sort sort) { + return getTransactionsByAddress("txlistinternal", chainId, address, page, offset, sort); } - public Response getNormalTransactionsByAddress(String address, String startBlock, String endBlock, Sort sort) { - return getTransactionsByAddress("txlist", address, startBlock, endBlock, sort); + private Response getERC20Transactions(String address, String chainId, String contractAddress, String page, String offset, Sort sort) { + UriComponentsBuilder builder = prepareBuilder("tokentx", chainId, address, page, offset, sort); + + if (!isBlank(contractAddress)) { + builder.queryParam("contractaddress", contractAddress); + } + + return makeGetRequest(builder); } - public Response getInternalTransactionsByAddress(String address, String startBlock, String endBlock, Sort sort) { - return getTransactionsByAddress("txlistinternal", address, startBlock, endBlock, sort); + private Response getTransactionsByAddress(String action, String address, String chainId, String page, String offset, Sort sort) { + return makeGetRequest(prepareBuilder(action, chainId, address, page, offset, sort)); } - private Response getTransactionsByAddress(String action, String address, String startBlock, String endBlock, Sort sort) { + //TODO Add differentiation between networks + private UriComponentsBuilder prepareBuilder(String action, String chainId, String address, String page, String offset, Sort sort) { UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(etherScanUrl) .queryParam("module", "account") .queryParam("action", action) .queryParam("address", address) - .queryParam("sort", sort != null ? sort.getSort() : Sort.ASC.getSort()) - .queryParam("apikey", apikey); - - if (startBlock != null) { - builder.queryParam("startblock", startBlock); + .queryParam("startblock", "0") + .queryParam("endblock", "99999999999") + .queryParam("apikey", apikey) + .queryParam("sort", sort != null ? sort.getSort() : Sort.ASC.getSort()); - if (endBlock != null) { - builder.queryParam("endblock", endBlock); - } + if (!isBlank(page)) { + builder.queryParam("page", page); } + if (!isBlank(offset)) { + builder.queryParam("offset", offset); + } + + return builder; + } + private Response makeGetRequest(UriComponentsBuilder builder) { return handleEtherScanResponse(new RestTemplate().exchange( builder.toUriString(), HttpMethod.GET, null, - new ParameterizedTypeReference>() {} + new ParameterizedTypeReference>() {} )); } diff --git a/src/main/java/com/nynja/walletservice/constant/Constants.java b/src/main/java/com/nynja/walletservice/constant/Constants.java index 94601a8ba3a2d6c98964426c392798ff2acf760f..6313d29ead4265c023349a651a0cae3dc23eac72 100644 --- a/src/main/java/com/nynja/walletservice/constant/Constants.java +++ b/src/main/java/com/nynja/walletservice/constant/Constants.java @@ -47,6 +47,7 @@ public interface Constants { interface ValidationMessages { String ADDRESS_VALIDATION_MESSAGE = "Address should be correct"; + String CHAIN_ID_VALIDATION_MESSAGE = "Chain ID should not be empty"; String HASH_VALIDATION_MESSAGE = "Hash should be correct"; } diff --git a/src/main/java/com/nynja/walletservice/controller/TransactionController.java b/src/main/java/com/nynja/walletservice/controller/TransactionController.java index a7fe092f08a7376167aca5fb5f4714fd93e06637..49ae3ed6f09c7cd752a99d0e79e8de8d53cdb8f1 100644 --- a/src/main/java/com/nynja/walletservice/controller/TransactionController.java +++ b/src/main/java/com/nynja/walletservice/controller/TransactionController.java @@ -4,7 +4,8 @@ import com.nynja.walletservice.blockexplorer.EtherScanConsumer; import com.nynja.walletservice.blockexplorer.dto.Response; import com.nynja.walletservice.blockexplorer.dto.TxListItemDto; import com.nynja.walletservice.constant.enums.Sort; -import com.nynja.walletservice.dto.EstimateGasDto; +import com.nynja.walletservice.dto.EstimateGasRequestDto; +import com.nynja.walletservice.dto.EstimateGasResponseDto; import com.nynja.walletservice.dto.SignedTransactionDto; import com.nynja.walletservice.dto.TransactionResponseDto; import com.nynja.walletservice.service.TokenService; @@ -26,6 +27,7 @@ import java.util.concurrent.CompletableFuture; import static com.nynja.walletservice.constant.Constants.RestApi.API_VERSION; import static com.nynja.walletservice.constant.Constants.RestApi.RETRIEVED; import static com.nynja.walletservice.constant.Constants.ValidationMessages.ADDRESS_VALIDATION_MESSAGE; +import static com.nynja.walletservice.constant.Constants.ValidationMessages.CHAIN_ID_VALIDATION_MESSAGE; import static com.nynja.walletservice.constant.Constants.ValidationMessages.HASH_VALIDATION_MESSAGE; import static com.nynja.walletservice.controller.ControllerHelper.handleAndRespondAsync; @@ -75,36 +77,34 @@ public class TransactionController { return web3JService.getTransactionCount(address).thenApplyAsync(ResponseEntity::ok); } - @ApiOperation(value = "Endpoint for obtaining tx history by address. External(normal) transactions", httpMethod = "GET") + @ApiOperation(value = "Endpoint for obtaining tx history by address. Internal transactions", httpMethod = "GET") @ApiResponse(code = 200, message = RETRIEVED) - @GetMapping(API_VERSION + "/normal-tx-history") - public CompletableFuture>> getNormalTransactionsHistory( + @GetMapping(API_VERSION + "/internal-tx-history") + public CompletableFuture>> getInternalTransactionsHistory( @NotBlank(message = ADDRESS_VALIDATION_MESSAGE) @RequestParam String address, - @RequestParam(required = false) String startBlock, @RequestParam(required = false) String endBlock, + @NotBlank(message = CHAIN_ID_VALIDATION_MESSAGE) @RequestParam String chainId, + @RequestParam(required = false) String page, @RequestParam(required = false) String offset, @RequestParam(required = false) Sort sort ) { - return handleAndRespondAsync(() -> etherScanConsumer.getNormalTransactionsByAddress( - address, startBlock, endBlock, sort - )); + return handleAndRespondAsync(() -> etherScanConsumer.getInternalTransactions(address, chainId, page, offset, sort)); } - @ApiOperation(value = "Endpoint for obtaining tx history by address. Internal transactions", httpMethod = "GET") + @ApiOperation(value = "Endpoint for obtaining tx history by address and/or contract address. External(normal)/ERC20 transactions", httpMethod = "GET") @ApiResponse(code = 200, message = RETRIEVED) - @GetMapping(API_VERSION + "/internal-tx-history") - public CompletableFuture>> getInternalTransactionsHistory( + @GetMapping(API_VERSION + "/tx-history") + public CompletableFuture>> getTransactionHistory( @NotBlank(message = ADDRESS_VALIDATION_MESSAGE) @RequestParam String address, - @RequestParam(required = false) String startBlock, @RequestParam(required = false) String endBlock, - @RequestParam(required = false) Sort sort + @NotBlank(message = CHAIN_ID_VALIDATION_MESSAGE) @RequestParam String chainId, + @RequestParam(required = false) String contractAddress, @RequestParam(required = false) String page, + @RequestParam(required = false) String offset, @RequestParam(required = false) Sort sort ) { - return handleAndRespondAsync(() -> etherScanConsumer.getInternalTransactionsByAddress( - address, startBlock, endBlock, sort - )); + return handleAndRespondAsync(() -> etherScanConsumer.getTxHistory(address, chainId, contractAddress, page, offset, sort)); } @ApiOperation(value = "Endpoint for transaction gas price estimation", httpMethod = "POST") @ApiResponse(code = 200, message = RETRIEVED) @PostMapping(API_VERSION + "/estimate-gas-price") - public CompletableFuture> estimateGasPrice(@RequestBody EstimateGasDto dto) { + public CompletableFuture> estimateGasPrice(@RequestBody EstimateGasRequestDto dto) { return web3JService.estimateGasPrice(dto).thenApplyAsync(ResponseEntity::ok); } } diff --git a/src/main/java/com/nynja/walletservice/dto/EstimateGasDto.java b/src/main/java/com/nynja/walletservice/dto/EstimateGasRequestDto.java similarity index 90% rename from src/main/java/com/nynja/walletservice/dto/EstimateGasDto.java rename to src/main/java/com/nynja/walletservice/dto/EstimateGasRequestDto.java index 3eed0d582f96562b6c743f0d0126a32e32d2b817..78cdc337eacba67756d02ea4da449061f9635654 100644 --- a/src/main/java/com/nynja/walletservice/dto/EstimateGasDto.java +++ b/src/main/java/com/nynja/walletservice/dto/EstimateGasRequestDto.java @@ -9,7 +9,7 @@ import java.math.BigInteger; @Data @NoArgsConstructor @AllArgsConstructor -public class EstimateGasDto { +public class EstimateGasRequestDto { private String from; private String to; private BigInteger gas; diff --git a/src/main/java/com/nynja/walletservice/dto/EstimateGasResponseDto.java b/src/main/java/com/nynja/walletservice/dto/EstimateGasResponseDto.java new file mode 100644 index 0000000000000000000000000000000000000000..540745e73641557f3d14b5e4062261c47a85b0a3 --- /dev/null +++ b/src/main/java/com/nynja/walletservice/dto/EstimateGasResponseDto.java @@ -0,0 +1,17 @@ +package com.nynja.walletservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigInteger; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EstimateGasResponseDto { + private BigInteger gasConsumed; + private BigInteger gasPrice; +} diff --git a/src/main/java/com/nynja/walletservice/exception/GlobalExceptionHandler.java b/src/main/java/com/nynja/walletservice/exception/GlobalExceptionHandler.java index 1e81b3c589b32d484787788aab80e5aaf7e9b009..4cf5524c33d5fc5cfed6be59acf3467213516672 100644 --- a/src/main/java/com/nynja/walletservice/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/nynja/walletservice/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; @@ -48,6 +49,12 @@ public class GlobalExceptionHandler { return compose(HttpStatus.BAD_REQUEST, errorMessage); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handle(MissingServletRequestParameterException e) { + log.error(e.getMessage(), e); + return compose(HttpStatus.BAD_REQUEST, e.getMessage()); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handle(RuntimeException e) { return handleException(e); diff --git a/src/main/java/com/nynja/walletservice/service/WalletService.java b/src/main/java/com/nynja/walletservice/service/WalletService.java index 354834dab5efa12b57cacce08ea801099612064b..e4f413fa60abff7cca32f8ced8ba3e3431a4a624 100644 --- a/src/main/java/com/nynja/walletservice/service/WalletService.java +++ b/src/main/java/com/nynja/walletservice/service/WalletService.java @@ -139,7 +139,8 @@ public class WalletService { return new ScanJobResult( childIndex, address, - etherScanConsumer.getNormalTransactionsByAddress(address).getResult() + //TODO Handle chainId + etherScanConsumer.getTxHistory(address, "", null, "1", "1", null).getResult() ); }; } diff --git a/src/main/java/com/nynja/walletservice/service/Web3JService.java b/src/main/java/com/nynja/walletservice/service/Web3JService.java index 9ad63f3821bba4fb036b2685bf7ed0916da59c07..e74b9bf6164f0c3dded835f141cbc79de1de691d 100644 --- a/src/main/java/com/nynja/walletservice/service/Web3JService.java +++ b/src/main/java/com/nynja/walletservice/service/Web3JService.java @@ -1,7 +1,8 @@ package com.nynja.walletservice.service; import com.nynja.walletservice.config.EthereumConfig; -import com.nynja.walletservice.dto.EstimateGasDto; +import com.nynja.walletservice.dto.EstimateGasRequestDto; +import com.nynja.walletservice.dto.EstimateGasResponseDto; import com.nynja.walletservice.dto.TransactionResponseDto; import com.nynja.walletservice.exception.TransactionFailedException; import com.nynja.walletservice.exception.TransactionNotFoundException; @@ -104,7 +105,7 @@ public class Web3JService { .thenApplyAsync(EthGetTransactionCount::getTransactionCount); } - public CompletableFuture estimateGasPrice(EstimateGasDto dto) { + public CompletableFuture estimateGasPrice(EstimateGasRequestDto dto) { return web3j().ethEstimateGas(new org.web3j.protocol.core.methods.request.Transaction( dto.getFrom(), null, @@ -113,10 +114,13 @@ public class Web3JService { dto.getTo(), dto.getValue(), dto.getData() - )).sendAsync().thenComposeAsync(ethEstimateGas -> web3j().ethGasPrice().sendAsync() - .thenApplyAsync(ethGasPrice -> - ethGasPrice.getGasPrice().multiply(ethEstimateGas.getAmountUsed())) - ); + )).sendAsync().thenComposeAsync(ethEstimateGas -> web3j().ethGasPrice() + .sendAsync() + .thenApplyAsync(ethGasPrice -> EstimateGasResponseDto.builder() + .gasConsumed(ethEstimateGas.getAmountUsed()) + .gasPrice(ethGasPrice.getGasPrice()) + .build() + )); } private Web3j web3j() { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3d76581b40930b338b3a0b2a8b48ffa865693f84..ab056887f5ac54c95bd3d4a9fd0725f7f9998622 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,14 +25,15 @@ ethereum: private-key: "960d1cad3ad7c42251f79c31e15636915f953cbfff1770cc4483efa44bf4ffc9" contract: # First deploy a contract and then place/replace it's address into this property - nynja-coin-address: "0x5c015d5b7490cc329b96aebfb9d8e5ebd78e2bf6" - gas-price: 2000000000000 - gas-limit: 4700000 + nynja-coin-address: "0xF36c7AdAd65c39A4848F3c85001F67f31d00d207" + gas-price: 41000000000 + gas-limit: 400000 wallet-discovery: search-job-pool-size: 2 etherscan: - url: http://api.etherscan.io/api + #url: http://api.etherscan.io/api #url: http://api-ropsten.etherscan.io/api + url: http://api-rinkeby.etherscan.io/api apikey: "K8J2W58EGI3EIR2FI8SYGW7FR1QKV5KDBM" message-broker: url: "http://dev2.ci.nynja.net/publish" diff --git a/src/test/java/com/nynja/walletservice/service/WalletServiceTest.java b/src/test/java/com/nynja/walletservice/service/WalletServiceTest.java index 9cfe6424c13b920c39155257a2a0218ca99318d9..464caee3d14988f33c85b3247beac509c997e473 100644 --- a/src/test/java/com/nynja/walletservice/service/WalletServiceTest.java +++ b/src/test/java/com/nynja/walletservice/service/WalletServiceTest.java @@ -20,6 +20,7 @@ import static com.nynja.walletservice.service.WalletService.BIP44_GAP; import static org.junit.Assert.assertEquals; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.times; @@ -66,7 +67,7 @@ public class WalletServiceTest { @Test public void testSingleAccountScan() { - given(etherScanConsumer.getNormalTransactionsByAddress(anyString())) + given(etherScanConsumer.getTxHistory(anyString(), anyString(), anyString(), anyString(), anyString(), any())) .will(invocation -> { String address = invocation.getArgumentAt(0, String.class); return FIRST_ADDRESS.equals(address) || SECOND_ADDRESS.equals(address) ? positiveResponse : emptyResponse; @@ -77,19 +78,22 @@ public class WalletServiceTest { //The test data implies that there will be transaction history only for 0th and 2th addresses //This means that there will be 23 requests to the block-explorer to reach the BIP-44 gap and stop the discovery - then(etherScanConsumer).should(times(BIP44_GAP + 3)).getNormalTransactionsByAddress(anyString()); + then(etherScanConsumer).should(times(BIP44_GAP + 3)) + .getTxHistory(anyString(), anyString(), anyString(), anyString(), anyString(), any()); assertEquals(2, result.size()); } @Test public void testNoResults() { - given(etherScanConsumer.getNormalTransactionsByAddress(anyString())).willReturn(emptyResponse); + given(etherScanConsumer.getTxHistory(anyString(), anyString(), anyString(), anyString(), anyString(), any())) + .willReturn(emptyResponse); //when List result = walletService.scanSingleAccount(PUB, CHAIN_CODE); - then(etherScanConsumer).should(times(BIP44_GAP)).getNormalTransactionsByAddress(anyString()); + then(etherScanConsumer).should(times(BIP44_GAP)) + .getTxHistory(anyString(), anyString(), anyString(), anyString(), anyString(), any()); assertEquals(0, result.size()); } }