diff --git a/docker-compose.yml b/docker-compose.yml index f817e04..ad5ba29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:15 diff --git a/pom.xml b/pom.xml index 489db3f..3cfed51 100644 --- a/pom.xml +++ b/pom.xml @@ -80,11 +80,22 @@ org.liquibase liquibase-core + + com.squareup.okhttp3 + okhttp + 4.12.0 + com.h2database h2 runtime + + io.github.resilience4j + resilience4j-spring-boot2 + 1.7.1 + + diff --git a/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java index b1bf73f..eb2048a 100644 --- a/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java +++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java @@ -6,10 +6,12 @@ @Data public class SingleTokenPortfolioResponseDto { + @JsonProperty("token_name") + private String name; @JsonProperty("token_symbol") - private String tokenSymbol; + private String symbol; @JsonProperty("token_address") - private String tokenAddress; + private String address; @JsonProperty("balance") private String tokenBalance; @JsonProperty("value") diff --git a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java index 372e765..24ffbdc 100644 --- a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java +++ b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java @@ -29,23 +29,22 @@ public class BalanceActivity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, name = "token_address") - private String tokenAddress; - @Column(name = "token_name") - private String tokenName; - @Column(nullable = false, name = "token_symbol") - private String tokenSymbol; @Column(nullable = false, name = "value_in_usd") private BigDecimal valueInUsd; @Column(nullable = false) private BigDecimal amount; - @Column(nullable = false) - private Date time; + @ManyToOne + @ToString.Exclude + @EqualsAndHashCode.Exclude + @JoinColumn(nullable = false, name = "token_id") + private Token token; @ManyToOne @ToString.Exclude @EqualsAndHashCode.Exclude @JoinColumn(nullable = false, name = "address_id") private MonitoredAddress monitoredAddress; + @Column(nullable = false) + private Date time; @Column(nullable = false, name = "is_deleted") private boolean isDeleted = false; } diff --git a/src/main/java/ivan/solscanbot/dto/internal/Token.java b/src/main/java/ivan/solscanbot/dto/internal/Token.java index 133d5af..9ce3a4e 100644 --- a/src/main/java/ivan/solscanbot/dto/internal/Token.java +++ b/src/main/java/ivan/solscanbot/dto/internal/Token.java @@ -21,10 +21,10 @@ public class Token { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "token_symbol") - private String tokenSymbol; - @Column(unique = true, nullable = false, name = "token_address") - private String tokenAddress; + private String name; + private String symbol; + @Column(unique = true) + private String address; @Column(name = "is_deleted") private boolean isDeleted = false; } diff --git a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java index ba03f4f..18a0ecc 100644 --- a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java +++ b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java @@ -3,9 +3,17 @@ import ivan.solscanbot.config.MapperConfig; import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto; import ivan.solscanbot.dto.internal.BalanceActivity; +import ivan.solscanbot.dto.internal.Token; import org.mapstruct.Mapper; @Mapper(config = MapperConfig.class) public interface ActivityMapper { BalanceActivity toModel(SingleBalanceActivityResponseDto activityDto); + + default BalanceActivity toModel(SingleBalanceActivityResponseDto activityDto, + Token token) { + BalanceActivity act = toModel(activityDto); + act.setToken(token); + return act; + } } diff --git a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java index 2e73407..5f4b102 100644 --- a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java +++ b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java @@ -1,11 +1,14 @@ package ivan.solscanbot.mapper; import ivan.solscanbot.config.MapperConfig; +import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto; import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto; import ivan.solscanbot.dto.internal.Token; import org.mapstruct.Mapper; @Mapper(config = MapperConfig.class) public interface TokenMapper { - Token toModel(SingleTokenPortfolioResponseDto dto); + Token toModelFromPortfolioDto(SingleTokenPortfolioResponseDto dto); + + Token toModelFromMetaDto(TokenMetaResponseDto dto); } diff --git a/src/main/java/ivan/solscanbot/repository/TokenRepository.java b/src/main/java/ivan/solscanbot/repository/TokenRepository.java index 7711686..c01a809 100644 --- a/src/main/java/ivan/solscanbot/repository/TokenRepository.java +++ b/src/main/java/ivan/solscanbot/repository/TokenRepository.java @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TokenRepository extends JpaRepository { - Optional findByTokenAddress(String tokenAddress); + Optional findByAddress(String address); } diff --git a/src/main/java/ivan/solscanbot/service/MonitoringService.java b/src/main/java/ivan/solscanbot/service/MonitoringService.java index cce99c9..24bca58 100644 --- a/src/main/java/ivan/solscanbot/service/MonitoringService.java +++ b/src/main/java/ivan/solscanbot/service/MonitoringService.java @@ -6,13 +6,16 @@ import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto; import ivan.solscanbot.dto.internal.BalanceActivity; import ivan.solscanbot.dto.internal.MonitoredAddress; +import ivan.solscanbot.dto.internal.Token; import ivan.solscanbot.mapper.ActivityMapper; +import ivan.solscanbot.mapper.TokenMapper; import ivan.solscanbot.repository.ActivityRepository; import ivan.solscanbot.repository.MonitoredAddressRepository; +import ivan.solscanbot.repository.TokenRepository; import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -20,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -32,24 +36,25 @@ public class MonitoringService { private static final int CHUNK_SIZE = 20; private static final String SOLSCAN_ACCOUNT_URL = "https://solscan.io/account/"; private static final String SOLSCAN_TOKEN_URL = "https://solscan.io/token/"; - private static final String FIRST_TRACKING_ADDRESS = - "APZmQqyytWLMFioMsskqhWrGJCd9Fpo7L2f2YhdpSe6U"; private final MonitoredAddressRepository addressRepository; private final TelegramBot telegramBot; private final SolScanServiceImpl solScanService; private final ActivityRepository activityRepository; + private final TokenRepository tokenRepository; private final ActivityMapper activityMapper; + private final TokenMapper tokenMapper; + private RateLimiter rateLimiter = RateLimiter.create(1.0); - @Scheduled(fixedDelay = 300000) + @Scheduled(fixedDelay = 60000) public void newActivityFound() { - RateLimiter rateLimiter = RateLimiter.create(5.0); addressRepository.findAll().forEach(address -> { try { rateLimiter.acquire(); Set activities = fetchAndProcessActivities(address); if (!activities.isEmpty()) { activityRepository.saveAll(activities); + log.info("New activity found"); sendTelegramNotification(address, activities); } } catch (Exception e) { @@ -61,74 +66,103 @@ public void newActivityFound() { private Set fetchAndProcessActivities(MonitoredAddress address) { Set newActivities = solScanService.getNewBalanceActivities(address.getAddress()) - .stream() - .filter(act -> BigDecimal.ZERO.equals(act.getPreBalance())) - .collect(Collectors.toSet()); - + .stream() + .filter(act -> BigDecimal.ZERO.equals(act.getPreBalance())) + .collect(Collectors.toSet()); + log.info("Fetching new activities for address {}", address.getAddress()); + if (newActivities.isEmpty()) { + return Collections.emptySet(); + } List tokenAddresses = newActivities.stream() .map(SingleBalanceActivityResponseDto::getTokenAddress) + .distinct() .toList(); Map metaMap = batchFetchTokenMetadata(tokenAddresses); return newActivities.stream() - .map(activityMapper::toModel) - .map(act -> enrichWithTokenMeta(act, metaMap.get(act.getTokenAddress()))) - .filter(tok -> tok.getValueInUsd().compareTo(BigDecimal.valueOf(100)) > 0) + .map(dto -> { + TokenMetaResponseDto meta = metaMap.get(dto.getTokenAddress()); + if (meta == null) { + return Optional.empty(); + } + return enrichWithTokenMeta(dto, meta); + }) + .filter(Optional::isPresent) + .map(Optional::get) .peek(act -> act.setMonitoredAddress(address)) .collect(Collectors.toSet()); } private Map batchFetchTokenMetadata(List tokenAddresses) { Map metaMap = new ConcurrentHashMap<>(); - List addressList = new ArrayList<>(tokenAddresses); - for (int i = 0; i < addressList.size(); i += CHUNK_SIZE) { - List chunk = addressList.subList( - i, Math.min(i + CHUNK_SIZE, addressList.size())); + log.info("Fetching token metadata for addresses {}", tokenAddresses); + if (tokenAddresses.isEmpty()) { + return metaMap; + } + + List> chunks = partitionList(tokenAddresses); + chunks.parallelStream().forEach(chunk -> { try { Map chunkResults = solScanService.getMetaMapFromAddresses(chunk); metaMap.putAll(chunkResults); } catch (Exception e) { - log.error("Failed to fetch batch metadata for chunk: {}", chunk, e); + log.warn("Batch metadata fetch failed, falling back to individual requests", e); fetchTokenMetadata(chunk, metaMap); } - } + }); + return metaMap; } + private List> partitionList(List list) { + return IntStream.range(0, (list.size() + CHUNK_SIZE - 1) / CHUNK_SIZE) + .mapToObj(i -> list.subList( + i * CHUNK_SIZE, Math.min(list.size(), (i + 1) * CHUNK_SIZE))) + .collect(Collectors.toList()); + } + private void fetchTokenMetadata(List tokenAddresses, Map metaMap) { tokenAddresses.forEach(address -> { - TokenMetaResponseDto meta = solScanService.getTokenMeta(address); - metaMap.put(address, meta); + try { + TokenMetaResponseDto meta = solScanService.getTokenMeta(address); + if (meta != null) { + metaMap.put(address, meta); + } + } catch (Exception e) { + log.error("Failed to fetch metadata for token: {}", address, e); + } }); } - private BalanceActivity enrichWithTokenMeta(BalanceActivity act, TokenMetaResponseDto meta) { + private Optional enrichWithTokenMeta( + SingleBalanceActivityResponseDto dto, + TokenMetaResponseDto meta + ) { BigDecimal price = Optional.ofNullable(meta.getPrice()).orElse(BigDecimal.ZERO); int decimals = meta.getDecimals() > 0 ? meta.getDecimals() : 9; - BigDecimal normalizedAmount = act.getAmount() + + BigDecimal normalizedAmount = dto.getAmount() .divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL32); - act.setValueInUsd(normalizedAmount.multiply(price)) - .setTokenName(meta.getName()) - .setTokenSymbol(meta.getSymbol()); - return act; + BigDecimal valueInUsd = normalizedAmount.multiply(price); + if (valueInUsd.compareTo(BigDecimal.valueOf(1)) > 0) { + Token token = tokenRepository.findByAddress(meta.getAddress()) + .orElseGet(() -> tokenRepository.save(tokenMapper.toModelFromMetaDto(meta))); + + BalanceActivity act = activityMapper.toModel(dto, token); + act.setValueInUsd(valueInUsd); + log.info("Saving the Token: {} and creating Balance Activity entity: {}.", + token.getName(), act.getValueInUsd()); + return Optional.of(act); + } + return Optional.empty(); } private void sendTelegramNotification(MonitoredAddress address, Set activities) { - AtomicInteger count = new AtomicInteger(1); - String tokens = activities.stream() - .map(act -> String.format( - "%d. Token: %s\nUSD Value: $%s\nToken link: [%s](%s#balanceChanges)\n", - count.getAndIncrement(), - act.getTokenName(), - act.getValueInUsd().setScale(2, RoundingMode.HALF_UP), - shortenAddress(act.getTokenAddress()), - SOLSCAN_TOKEN_URL + act.getTokenAddress() - )) - .collect(Collectors.joining()); + String tokens = formatTokensMessage(activities); String message = String.format("New activity for address: [%s](%s#balanceChanges)\n%s", shortenAddress(address.getAddress()), SOLSCAN_ACCOUNT_URL + address.getAddress(), @@ -136,27 +170,23 @@ private void sendTelegramNotification(MonitoredAddress address, telegramBot.sendNotification(address.getChatId(), message); } + private String formatTokensMessage(Set balanceActivities) { + AtomicInteger counter = new AtomicInteger(1); + return balanceActivities.stream() + .map(act -> String.format( + "%d. Token: %s\nUSD Value: $%s\nToken link: [%s](%s#balanceChanges)\n", + counter.getAndIncrement(), + act.getToken().getName(), + act.getValueInUsd().setScale(2, RoundingMode.HALF_UP), + shortenAddress(act.getToken().getAddress()), + SOLSCAN_TOKEN_URL + act.getToken().getAddress() + )) + .collect(Collectors.joining()); + } + private String shortenAddress(String address) { return address.length() > 8 ? address.substring(0, 4) + "..." + address.substring(address.length() - 4) : address; } - - /*public void monitorAddress() { - try { - if (solScanService.newTokenTransfer(FIRST_TRACKING_ADDRESS)) { - Set ids = - addressRepository.findAll() - .stream() - .map(MonitoredAddress::getChatId) - .collect(Collectors.toSet()); - log.info("New transfer detected, sending notification"); - for (Long id : ids) { - telegramBot.sendNotification(id, "!!!"); - } - } - } catch (Exception e) { - log.error("Error in scheduled task", e); - } - }*/ } diff --git a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java index 2612537..29ca2a2 100644 --- a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java +++ b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java @@ -22,9 +22,11 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class SolScanServiceImpl implements SolScanService { @@ -67,7 +69,7 @@ public Set getTokensByAddress(String address) { @Override public Set getNewBalanceActivities(String address) { HttpClient httpClient = HttpClient.newHttpClient(); - long fromTime = Instant.now().getEpochSecond() - 300; + long fromTime = Instant.now().getEpochSecond() - 60; String url = SOL_SCAN_ACTIVITIES_URL + "?address=" + address + "&from_time=" + fromTime; HttpRequest httpRequest = HttpRequest.newBuilder() @@ -88,6 +90,7 @@ public Set getNewBalanceActivities(String addr } @Override + public Map getMetaMapFromAddresses(List tokenAddresses) { String apiUrl = SOL_SCAN_TOKEN_MULTI_URL + "?address[]=" + String.join("&address[]=", tokenAddresses); @@ -97,6 +100,7 @@ public Map getMetaMapFromAddresses(List to .header("accept", "application/json") .header("token", solScanKey) .build(); + log.info("solscan get batch meta tokens request"); try { HttpResponse response = client.send( request, HttpResponse.BodyHandlers.ofString()); diff --git a/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java index 1571f3d..b746ec5 100644 --- a/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java +++ b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java @@ -116,12 +116,12 @@ private String formatTokenList(MonitoredAddress solanaAddress) { AtomicInteger count = new AtomicInteger(1); return solanaAddress.getTokens().stream() .map(token -> { - String tokenUrl = SOLSCAN_TOKEN_URL + token.getTokenAddress() + "#holders"; + String tokenUrl = SOLSCAN_TOKEN_URL + token.getAddress() + "#holders"; return String.format( "%d. %s: [%s](%s)", count.getAndIncrement(), - token.getTokenSymbol(), - shortenAddress(token.getTokenAddress()), + token.getSymbol(), + shortenAddress(token.getAddress()), tokenUrl ); }) @@ -155,8 +155,8 @@ private String shortenAddress(String address) { private Set mapAndSaveTokensFromSolscan( Set tokensFromSolScan) { return tokensFromSolScan.stream() - .map(tokenMapper::toModel) - .map(t -> tokenRepository.findByTokenAddress(t.getTokenAddress()) + .map(tokenMapper::toModelFromPortfolioDto) + .map(t -> tokenRepository.findByAddress(t.getAddress()) .orElseGet(() -> tokenRepository.save(t))) .collect(Collectors.toSet()); } @@ -165,7 +165,7 @@ private Set getFilteredTokensFromSolscan( String solanaAddress) { return solScanService.getTokensByAddress(solanaAddress) .stream() - .filter(tok -> tok.getTokenSymbol() != null) + .filter(tok -> tok.getSymbol() != null) .filter(tok -> tok.getTokenValue().compareTo(BigDecimal.TEN) > 0) .sorted(Comparator.comparing(SingleTokenPortfolioResponseDto::getTokenValue) .reversed()) diff --git a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java index a5394a9..bcf8a1d 100644 --- a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java +++ b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java @@ -47,7 +47,7 @@ public void verifyUserHasCertainAddress(long chatId, String address) { if (!addressRepository.existsByAddressAndChatId(address, chatId)) { throw new AddressNotMonitoredException( "You need to add this address to monitored address first " - + "'/add address1 address2' ...'. Address: " + address); + + "/add ...\nAddress: " + address); } } diff --git a/src/main/resources/db/changelog/changes/create-database.yaml b/src/main/resources/db/changelog/changes/create-database.yaml index d26b17a..7ced94d 100644 --- a/src/main/resources/db/changelog/changes/create-database.yaml +++ b/src/main/resources/db/changelog/changes/create-database.yaml @@ -14,14 +14,16 @@ databaseChangeLog: primaryKey: true nullable: false - column: - name: token_symbol + name: name type: VARCHAR(255) - column: - name: token_address + name: symbol + type: VARCHAR(255) + - column: + name: address type: VARCHAR(255) constraints: unique: true - nullable: false - column: name: is_deleted type: BOOLEAN @@ -70,19 +72,6 @@ databaseChangeLog: constraints: primaryKey: true nullable: false - - column: - name: token_address - type: VARCHAR(255) - constraints: - nullable: false - - column: - name: token_name - type: VARCHAR(255) - - column: - name: token_symbol - type: VARCHAR(255) - constraints: - nullable: false - column: name: value_in_usd type: JAVA.MATH.BIGDECIMAL @@ -98,6 +87,13 @@ databaseChangeLog: type: TIMESTAMP constraints: nullable: false + - column: + name: token_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_balance_activity_token + references: tokens(id) - column: name: address_id type: BIGINT