diff --git a/accounting-domain/src/main/java/onlydust/com/marketplace/accounting/domain/service/AccountingService.java b/accounting-domain/src/main/java/onlydust/com/marketplace/accounting/domain/service/AccountingService.java index 2bcd937fa6..8c1faae7a7 100644 --- a/accounting-domain/src/main/java/onlydust/com/marketplace/accounting/domain/service/AccountingService.java +++ b/accounting-domain/src/main/java/onlydust/com/marketplace/accounting/domain/service/AccountingService.java @@ -467,6 +467,7 @@ public Map transferredAmountPerOrigin(RewardId i } @Override + @Transactional public Deposit previewDeposit(final @NonNull UserId userId, final @NonNull SponsorId sponsorId, final @NonNull Network network, final @NonNull String transactionReference) { if (!permissionPort.isUserSponsorLead(userId, sponsorId)) diff --git a/bootstrap/src/test/java/onlydust/com/marketplace/api/it/api/DepositsApiIT.java b/bootstrap/src/test/java/onlydust/com/marketplace/api/it/api/DepositsApiIT.java index 86c588e551..bbfb311b68 100644 --- a/bootstrap/src/test/java/onlydust/com/marketplace/api/it/api/DepositsApiIT.java +++ b/bootstrap/src/test/java/onlydust/com/marketplace/api/it/api/DepositsApiIT.java @@ -289,6 +289,91 @@ void should_preview_a_deposit_of_usdc_on_ethereum() { """); } + @Test + void should_preview_a_proxied_deposit_of_usdc_on_ethereum() { + // Given + onlyDustWallets.setEthereum("0x8371e21f595dbf98caffdcef665ebcaccb983cb1"); + final var depositId = new MutableObject(); + + // When + client.post() + .uri(getApiURI(SPONSOR_DEPOSITS.formatted(sponsor.id()))) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + caller.jwt()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "network": "ETHEREUM", + "transactionReference": "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd" + } + """) + .exchange() + // Then + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.id").value(depositId::setValue); + + client.get() + .uri(getApiURI(DEPOSIT_BY_ID.formatted(depositId))) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + caller.jwt()) + .exchange() + // Then + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.senderInformation.name").isEqualTo(sponsor.name()) + .json(""" + { + "amount": { + "amount": 23000.000000, + "prettyAmount": 23000.00, + "currency": { + "id": "562bbf65-8a71-4d30-ad63-520c0d68ba27", + "code": "USDC", + "name": "USD Coin", + "logoUrl": "https://s2.coinmarketcap.com/static/img/coins/64x64/3408.png", + "decimals": 6 + }, + "usdEquivalent": 23230.02, + "usdConversionRate": 1.010001 + }, + "status": "DRAFT", + "currentBalance": { + "amount": 0, + "prettyAmount": 0, + "currency": { + "id": "562bbf65-8a71-4d30-ad63-520c0d68ba27", + "code": "USDC", + "name": "USD Coin", + "logoUrl": "https://s2.coinmarketcap.com/static/img/coins/64x64/3408.png", + "decimals": 6 + }, + "usdEquivalent": 0.00, + "usdConversionRate": 1.010001 + }, + "finalBalance": { + "amount": 23000.000000, + "prettyAmount": 23000.00, + "currency": { + "id": "562bbf65-8a71-4d30-ad63-520c0d68ba27", + "code": "USDC", + "name": "USD Coin", + "logoUrl": "https://s2.coinmarketcap.com/static/img/coins/64x64/3408.png", + "decimals": 6 + }, + "usdEquivalent": 23230.02, + "usdConversionRate": 1.010001 + }, + "senderInformation": { + "accountNumber": "0x13b2639533ec7741172563b490b64cde14a34258", + "transactionReference": "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd" + }, + "billingInformation": null, + "latestBillingInformation": null + } + """); + } + @Test void should_preview_a_deposit_of_eth_on_optimism() { // Given diff --git a/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionByHashERC20_proxied.json b/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionByHashERC20_proxied.json new file mode 100644 index 0000000000..7877924c71 --- /dev/null +++ b/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionByHashERC20_proxied.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "accessList": [], + "blockHash": "0xef8b66f05b3d7da11d46791de8888e3f62720a0b334d1cd50d3fcf7017cb27ef", + "blockNumber": "0x1430db7", + "chainId": "0x1", + "from": "0x3ab6ca1c61b37312c7ab3816087dc64237d09fe2", + "gas": "0x155b4", + "gasPrice": "0x551e0d961", + "hash": "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd", + "input": "0x6a761202000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000cc283c1ced6f9eaf288efee2e924cd01e5578990000000000000000000000000000000000000000000000000000000055ae826000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c3c6277fcc2492884d46aaa3b741ae298ae286ce795b73aef57b4a1840a3cd7293063078f0643c5cab6fc309018992960f5609fcdc5ebd1cd908706a07867448141b0000000000000000000000003ab6ca1c61b37312c7ab3816087dc64237d09fe2000000000000000000000000000000000000000000000000000000000000000001c687231683a177b9dbb0cb79c59c5570240dccdf231305ef783a13f7b4687b9b34c94d754497a3d7c9aab2f1f7c31b09986c2507220f69c4ecfe67ea5d455ead1c0000000000000000000000000000000000000000000000000000000000", + "maxFeePerGas": "0x619264a7c", + "maxPriorityFeePerGas": "0x7029fd40", + "nonce": "0x10", + "r": "0xa27c415ed8f8b52bc34b9e1235c73386711f1f06cfe835175f9d5b36b948d33", + "s": "0x4ff1b160159a77479d86121655022740a3771ee2a68a527dcf9f3063cdfbf87c", + "to": "0x13b2639533ec7741172563b490b64cde14a34258", + "transactionIndex": "0xb2", + "type": "0x2", + "v": "0x0", + "value": "0x0", + "yParity": "0x0" + } +} \ No newline at end of file diff --git a/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionReceiptERC20_proxied.json b/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionReceiptERC20_proxied.json new file mode 100644 index 0000000000..80052df681 --- /dev/null +++ b/bootstrap/src/test/resources/wiremock/ethereum/__files/body-eth_getTransactionReceiptERC20_proxied.json @@ -0,0 +1,49 @@ +{ + "jsonrpc": "2.0", + "id": {{jsonPath request.body '$.id'}}, + "result": { + "blockHash": "0xef8b66f05b3d7da11d46791de8888e3f62720a0b334d1cd50d3fcf7017cb27ef", + "blockNumber": "0x1430db7", + "contractAddress": null, + "cumulativeGasUsed": "0x11c0c51", + "effectiveGasPrice": "0x551e0d961", + "from": "0x3ab6ca1c61b37312c7ab3816087dc64237d09fe2", + "gasUsed": "0x15216", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "blockHash": "0xef8b66f05b3d7da11d46791de8888e3f62720a0b334d1cd50d3fcf7017cb27ef", + "blockNumber": "0x1430db7", + "data": "0x000000000000000000000000000000000000000000000000000000055ae82600", + "logIndex": "0x231", + "removed": false, + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000013b2639533ec7741172563b490b64cde14a34258", + "0x0000000000000000000000008371e21f595dbf98caffdcef665ebcaccb983cb1" + ], + "transactionHash": "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd", + "transactionIndex": "0xb2" + }, + { + "address": "0x13b2639533ec7741172563b490b64cde14a34258", + "blockHash": "0xef8b66f05b3d7da11d46791de8888e3f62720a0b334d1cd50d3fcf7017cb27ef", + "blockNumber": "0x1430db7", + "data": "0xfd0caa8036e464ffca63457a88b9bf516f415897cbbc50af8c254758141ed3310000000000000000000000000000000000000000000000000000000000000000", + "logIndex": "0x232", + "removed": false, + "topics": [ + "0x442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e" + ], + "transactionHash": "{{jsonPath request.body '$.params[0]'}}", + "transactionIndex": "0xb2" + } + ], + "logsBloom": "0x00000000400000000000000000000000000100000000000000000000040000000000000000000000000000000000000008000000000000000000000000000000000000000000000008000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010020000000000000000000002000000000000000000000000010000000000000000000000010100000000200000002000000004000000000000000000000000000000000000000002000000000000000000000000000000000100000000000000000000000000000004000000000000000000000000000000000000000000000000000000", + "status": "0x1", + "to": "0x13b2639533ec7741172563b490b64cde14a34258", + "transactionHash": "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd", + "transactionIndex": "0xb2", + "type": "0x2" + } +} \ No newline at end of file diff --git a/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionByHashERC20_proxied.json b/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionByHashERC20_proxied.json new file mode 100644 index 0000000000..76c3c88956 --- /dev/null +++ b/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionByHashERC20_proxied.json @@ -0,0 +1,27 @@ +{ + "priority": 1, + "request": { + "url": "/", + "method": "POST", + "bodyPatterns": [ + { + "equalToJson": { + "jsonrpc": "2.0", + "method": "eth_getTransactionByHash", + "params": [ + "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd" + ] + }, + "ignoreArrayOrder": true, + "ignoreExtraElements": true + } + ] + }, + "response": { + "status": 200, + "bodyFileName": "body-eth_getTransactionByHashERC20_proxied.json", + "headers": { + "Content-Type": "application/json" + } + } +} \ No newline at end of file diff --git a/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionReceiptERC20_proxied.json b/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionReceiptERC20_proxied.json new file mode 100644 index 0000000000..fa92d3f9ab --- /dev/null +++ b/bootstrap/src/test/resources/wiremock/ethereum/mappings/eth_getTransactionReceiptERC20_proxied.json @@ -0,0 +1,27 @@ +{ + "priority": 1, + "request": { + "url": "/", + "method": "POST", + "bodyPatterns": [ + { + "equalToJson": { + "jsonrpc": "2.0", + "method": "eth_getTransactionReceipt", + "params": [ + "0x384cf237da4ed3592b5140ab1ff5bbbad8b06abef3a5e2ae250d0f4333ea27dd" + ] + }, + "ignoreArrayOrder": true, + "ignoreExtraElements": true + } + ] + }, + "response": { + "status": 200, + "bodyFileName": "body-eth_getTransactionReceiptERC20_proxied.json", + "headers": { + "Content-Type": "application/json" + } + } +} \ No newline at end of file diff --git a/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/NotAnERC20TransferException.java b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/NotAnERC20TransferException.java new file mode 100644 index 0000000000..24de94eaa9 --- /dev/null +++ b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/NotAnERC20TransferException.java @@ -0,0 +1,7 @@ +package onlydust.com.marketplace.api.infura; + +public class NotAnERC20TransferException extends RuntimeException { + public NotAnERC20TransferException(String message) { + super(message); + } +} diff --git a/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/Web3Client.java b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/Web3Client.java index 4e112d45c5..959a6b5cb1 100644 --- a/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/Web3Client.java +++ b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/Web3Client.java @@ -13,6 +13,7 @@ import org.web3j.protocol.core.methods.response.EthBlock; import org.web3j.protocol.core.methods.response.EthGetTransactionReceipt; import org.web3j.protocol.core.methods.response.EthTransaction; +import org.web3j.protocol.core.methods.response.TransactionReceipt; import org.web3j.protocol.http.HttpService; import org.web3j.tx.gas.ContractGasProvider; import org.web3j.tx.gas.DefaultGasProvider; @@ -21,6 +22,7 @@ import java.math.BigInteger; import java.util.HexFormat; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -88,6 +90,15 @@ public static ERC20Contract load(String contractAddress, Web3j web3j, Credential return new ERC20Contract(contractAddress, web3j, credentials, contractGasProvider); } + public static Optional fromTransferReceipt(TransactionReceipt receipt, Web3j web3j, Credentials credentials, + ContractGasProvider contractGasProvider) { + final var calledContract = ERC20Contract.load(receipt.getTo(), web3j, credentials, contractGasProvider); + // Returns the contract from which the Transfer event originated + return calledContract.getTransferEvents(receipt).stream() + .map(l -> ERC20Contract.load(l.log.getAddress(), web3j, credentials, contractGasProvider)) + .findFirst(); + } + public CompletableFuture nameWithBinaryFallback() { return super.name().sendAsync().thenCompose(r -> r.isEmpty() ? binaryCallAsync("name") : completedFuture(r)); } diff --git a/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/adapters/Web3EvmTransactionStorageAdapter.java b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/adapters/Web3EvmTransactionStorageAdapter.java index 277a7eaa86..0320659289 100644 --- a/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/adapters/Web3EvmTransactionStorageAdapter.java +++ b/infrastructure/json-rpc-adapter/src/main/java/onlydust/com/marketplace/api/infura/adapters/Web3EvmTransactionStorageAdapter.java @@ -3,6 +3,7 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import onlydust.com.marketplace.accounting.domain.port.out.BlockchainTransactionStoragePort; +import onlydust.com.marketplace.api.infura.NotAnERC20TransferException; import onlydust.com.marketplace.api.infura.Web3Client; import onlydust.com.marketplace.kernel.model.blockchain.Ethereum; import onlydust.com.marketplace.kernel.model.blockchain.evm.EvmTransaction; @@ -61,7 +62,8 @@ private Optional tryFromERC20(final @NonNull EthBlock.Block bloc final @NonNull Transaction transaction, final @NonNull TransactionReceipt receipt) { try { - final var erc20 = ERC20Contract.load(transaction.getTo(), web3j, credentials, gasPriceProvider); + final var erc20 = ERC20Contract.fromTransferReceipt(receipt, web3j, credentials, gasPriceProvider) + .orElseThrow(() -> new NotAnERC20TransferException("Transaction %s is not an ERC20 transfer".formatted(transaction.getHash()))); final var decimals = erc20.decimals().send(); return erc20.getTransferEvents(receipt).stream() @@ -73,10 +75,10 @@ private Optional tryFromERC20(final @NonNull EthBlock.Block bloc Ethereum.accountAddress(l._from), Ethereum.accountAddress(l._to), new BigDecimal(l._value, decimals.intValue()), - Ethereum.contractAddress(transaction.getTo()))) + Ethereum.contractAddress(erc20.getContractAddress()))) .findFirst() .map(identity()); - } catch (ContractCallException e) { + } catch (ContractCallException | NotAnERC20TransferException e) { return Optional.empty(); } catch (Exception e) { throw internalServerError("Unable to fetch ERC20 decimals at address %s".formatted(transaction.getTo()), e);