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