diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..638b0d7
Binary files /dev/null and b/.DS_Store differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b94f92..83db30b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,16 +4,16 @@ on: [push]
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
- java-version: '21'
+ java-version: '17'
cache: 'maven'
- name: Build with Maven
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0f5d8c5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,4 @@
+FROM openjdk:17-jdk-slim
+ARG JAR_FILE=target/*.jar
+COPY ${JAR_FILE} app.jar
+ENTRYPOINT ["java", "-jar", "/app.jar"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f817e04
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,43 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: postgres:15
+ container_name: postgres
+ environment:
+ POSTGRES_DB: $POSTGRES_DATABASE
+ POSTGRES_USER: $POSTGRES_USERNAME
+ POSTGRES_PASSWORD: $POSTGRES_PASSWORD
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - $POSTGRES_LOCAL_PORT:$POSTGRES_DOCKER_PORT
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USERNAME} -d ${POSTGRES_DATABASE}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ telegram-bot:
+ build: .
+ env_file: .env
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ ports:
+ - $SPRING_LOCAL_PORT:$SPRING_DOCKER_PORT
+ environment:
+ SPRING_APPLICATION_JSON: '{
+ "spring.datasource.url": "jdbc:postgresql://postgres:${POSTGRES_DOCKER_PORT}/${POSTGRES_DATABASE}",
+ "spring.datasource.username": "${POSTGRES_USERNAME}",
+ "spring.datasource.password": "${POSTGRES_PASSWORD}",
+ "spring.datasource.driver-class-name": "org.postgresql.Driver",
+ "spring.jpa.properties.hibernate.dialect": "org.hibernate.dialect.PostgreSQLDialect",
+ "spring.liquibase.enabled": true,
+ "spring.liquibase.change-log": "classpath:db/changelog/db.changelog-master.yaml"
+ }'
+ JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:${DEBUG_PORT}"
+
+volumes:
+ postgres_data:
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 30b56d6..489db3f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,22 +13,9 @@
0.0.1-SNAPSHOT
solscanbot
solscanbot
-
-
-
-
-
-
-
-
-
-
-
-
-
checkstyle.xml
- 21
+ 17
1.6.3
0.2.0
@@ -42,14 +29,13 @@
spring-boot-starter-data-jpa
- org.projectlombok
- lombok
+ org.springframework.boot
+ spring-boot-starter
-
- com.h2database
- h2
- runtime
+ org.springframework.boot
+ spring-boot-starter-test
+ test
@@ -62,16 +48,42 @@
mapstruct
${org.mapstruct.version}
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+ org.springframework
+ spring-context
+
+
+ org.hibernate.validator
+ hibernate-validator
+
+
+ jakarta.validation
+ jakarta.validation-api
+
org.springframework.boot
- spring-boot-starter-test
- test
+ spring-boot-starter-jdbc
- org.projectlombok
- lombok
- provided
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.liquibase
+ liquibase-core
+
+
+ com.h2database
+ h2
+ runtime
diff --git a/src/main/java/ivan/solscanbot/bot/TelegramBot.java b/src/main/java/ivan/solscanbot/bot/TelegramBot.java
new file mode 100644
index 0000000..1cb1042
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/bot/TelegramBot.java
@@ -0,0 +1,125 @@
+package ivan.solscanbot.bot;
+
+import ivan.solscanbot.exception.AddressAlreadyExistsException;
+import ivan.solscanbot.exception.AddressNotMonitoredException;
+import ivan.solscanbot.exception.ExceedsAmountOfAddressesException;
+import ivan.solscanbot.exception.InvalidAddressException;
+import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException;
+import ivan.solscanbot.service.TelegramService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.telegram.telegrambots.bots.TelegramLongPollingBot;
+import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
+import org.telegram.telegrambots.meta.api.objects.Update;
+import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
+
+@Component
+public class TelegramBot extends TelegramLongPollingBot {
+ private final String token;
+ private final String username;
+ private final TelegramService telegramService;
+
+ public TelegramBot(
+ @Value("${telegram.bot.token}") String token,
+ @Value("${telegram.bot.username}") String username,
+ TelegramService telegramService
+ ) {
+ this.token = token;
+ this.username = username;
+ this.telegramService = telegramService;
+ }
+
+ @Override
+ public String getBotUsername() {
+ return username;
+ }
+
+ @Override
+ public String getBotToken() {
+ return token;
+ }
+
+ @Override
+ public void onUpdateReceived(Update update) {
+ if (update.hasMessage() && update.getMessage().hasText()) {
+ String messageText = update.getMessage().getText();
+ long chatId = update.getMessage().getChatId();
+
+ if (messageText.startsWith("/help")) {
+ handleHelp(chatId);
+ } else if (messageText.startsWith("/add")) {
+ handleAddAddresses(chatId, messageText);
+ } else if (messageText.startsWith("/list")) {
+ handleListAddresses(chatId);
+ } else if (messageText.startsWith("/remove")) {
+ handleRemoveAddress(chatId, messageText);
+ } else if (messageText.startsWith("/portfolio")) {
+ handleGetPortfolios(chatId, messageText);
+ } else {
+ sendMessage(
+ chatId, "Unknown command. Use /help to get clarifications");
+ }
+ }
+ }
+
+ public void sendNotification(long chatId, String notification) {
+ sendMessage(chatId, notification);
+ }
+
+ private void handleHelp(long chatId) {
+ String message = telegramService.getHelp();
+ sendMessage(chatId, message);
+ }
+
+ private void handleGetPortfolios(long chatId, String messageText) {
+ try {
+ String message = telegramService.getPortfolios(chatId, messageText);
+ sendMessage(chatId, message);
+ } catch (InvalidAddressException | AddressNotMonitoredException
+ | ExceedsAmountOfAddressesException e) {
+ sendMessage(chatId, e.getMessage());
+ }
+ }
+
+ private void handleAddAddresses(long chatId, String messageText) {
+ try {
+ String message = telegramService.addAddresses(chatId, messageText);
+ sendMessage(chatId, message);
+ } catch (InvalidAddressException | AddressAlreadyExistsException
+ | ExceedsAmountOfAddressesException e) {
+ sendMessage(chatId, e.getMessage());
+ }
+ }
+
+ private void handleListAddresses(long chatId) {
+ try {
+ String message = telegramService.listAddresses(chatId);
+ sendMessage(chatId, message);
+ } catch (UserNotHaveAnyMonitoredAddressesException e) {
+ sendMessage(chatId, e.getMessage());
+ }
+ }
+
+ private void handleRemoveAddress(long chatId, String messageText) {
+ try {
+ String message = telegramService.removeAddresses(chatId, messageText);
+ sendMessage(chatId, message);
+ } catch (InvalidAddressException | AddressNotMonitoredException
+ | ExceedsAmountOfAddressesException e) {
+ sendMessage(chatId, e.getMessage());
+ }
+ }
+
+ private void sendMessage(long chatId, String text) {
+ SendMessage message = new SendMessage();
+ message.setChatId(String.valueOf(chatId));
+ message.setText(text);
+ message.enableMarkdown(true);
+ message.disableWebPagePreview();
+ try {
+ execute(message);
+ } catch (TelegramApiException e) {
+ throw new RuntimeException("Error while sending a message.", e);
+ }
+ }
+}
diff --git a/src/main/java/ivan/solscanbot/config/MapperConfig.java b/src/main/java/ivan/solscanbot/config/MapperConfig.java
index 80e401e..159614b 100644
--- a/src/main/java/ivan/solscanbot/config/MapperConfig.java
+++ b/src/main/java/ivan/solscanbot/config/MapperConfig.java
@@ -7,6 +7,6 @@
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
- implementationPackage = ".impl"
-)public class MapperConfig {
+ implementationPackage = ".impl")
+public class MapperConfig {
}
diff --git a/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java b/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java
index c626ea1..45a156e 100644
--- a/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java
+++ b/src/main/java/ivan/solscanbot/config/TelegramBotConfig.java
@@ -1,6 +1,6 @@
package ivan.solscanbot.config;
-import ivan.solscanbot.service.DeFiMonitorBot;
+import ivan.solscanbot.bot.TelegramBot;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.TelegramBotsApi;
@@ -10,7 +10,7 @@
@Configuration
public class TelegramBotConfig {
@Bean
- public TelegramBotsApi telegramBotsApi(DeFiMonitorBot bot) throws TelegramApiException {
+ public TelegramBotsApi telegramBotsApi(TelegramBot bot) throws TelegramApiException {
TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class);
botsApi.registerBot(bot);
return botsApi;
diff --git a/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java
deleted file mode 100644
index 3546ee4..0000000
--- a/src/main/java/ivan/solscanbot/dto/external/TokenMetaResponseDto.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package ivan.solscanbot.dto.external;
-
-import lombok.Data;
-
-@Data
-public class TokenMetaResponseDto {
- private String name;
- private String symbol;
-}
diff --git a/src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java
similarity index 83%
rename from src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java
rename to src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java
index b727b74..08efeaa 100644
--- a/src/main/java/ivan/solscanbot/dto/external/BalanceActivitiesResponseDto.java
+++ b/src/main/java/ivan/solscanbot/dto/external/activity/BalanceActivitiesResponseDto.java
@@ -1,4 +1,4 @@
-package ivan.solscanbot.dto.external;
+package ivan.solscanbot.dto.external.activity;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Set;
diff --git a/src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java
similarity index 90%
rename from src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java
rename to src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java
index 121dab5..8d45b8d 100644
--- a/src/main/java/ivan/solscanbot/dto/external/SingleBalanceActivityResponseDto.java
+++ b/src/main/java/ivan/solscanbot/dto/external/activity/SingleBalanceActivityResponseDto.java
@@ -1,4 +1,4 @@
-package ivan.solscanbot.dto.external;
+package ivan.solscanbot.dto.external.activity;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java
new file mode 100644
index 0000000..feb3d88
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/meta/DataResponseDto.java
@@ -0,0 +1,8 @@
+package ivan.solscanbot.dto.external.meta;
+
+import lombok.Data;
+
+@Data
+public class DataResponseDto {
+ private TokenMetaResponseDto data;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java
new file mode 100644
index 0000000..838fa72
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/meta/MultiDataResponseDto.java
@@ -0,0 +1,9 @@
+package ivan.solscanbot.dto.external.meta;
+
+import java.util.Set;
+import lombok.Data;
+
+@Data
+public class MultiDataResponseDto {
+ private Set data;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java
new file mode 100644
index 0000000..690ac1c
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/meta/TokenMetaResponseDto.java
@@ -0,0 +1,13 @@
+package ivan.solscanbot.dto.external.meta;
+
+import java.math.BigDecimal;
+import lombok.Data;
+
+@Data
+public class TokenMetaResponseDto {
+ private String name;
+ private String symbol;
+ private String address;
+ private int decimals;
+ private BigDecimal price;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java
new file mode 100644
index 0000000..447d872
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/PortfolioWrapperDto.java
@@ -0,0 +1,8 @@
+package ivan.solscanbot.dto.external.portfolio;
+
+import lombok.Data;
+
+@Data
+public class PortfolioWrapperDto {
+ private TokenPortfoliosResponseDto data;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java
similarity index 69%
rename from src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java
rename to src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java
index 8c9f6d4..b1bf73f 100644
--- a/src/main/java/ivan/solscanbot/dto/external/SingleTokenPortfolioResponseDto.java
+++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/SingleTokenPortfolioResponseDto.java
@@ -1,4 +1,4 @@
-package ivan.solscanbot.dto.external;
+package ivan.solscanbot.dto.external.portfolio;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
@@ -6,11 +6,11 @@
@Data
public class SingleTokenPortfolioResponseDto {
- @JsonProperty("token_name")
- private String tokenName;
+ @JsonProperty("token_symbol")
+ private String tokenSymbol;
@JsonProperty("token_address")
private String tokenAddress;
- @JsonProperty("token_balance")
+ @JsonProperty("balance")
private String tokenBalance;
@JsonProperty("value")
private BigDecimal tokenValue;
diff --git a/src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java b/src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java
similarity index 75%
rename from src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java
rename to src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java
index 1517eda..e04fb51 100644
--- a/src/main/java/ivan/solscanbot/dto/external/TokenPortfoliosResponseDto.java
+++ b/src/main/java/ivan/solscanbot/dto/external/portfolio/TokenPortfoliosResponseDto.java
@@ -1,4 +1,4 @@
-package ivan.solscanbot.dto.external;
+package ivan.solscanbot.dto.external.portfolio;
import java.util.Set;
import lombok.Data;
diff --git a/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java b/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java
new file mode 100644
index 0000000..cba6426
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/transfer/Data.java
@@ -0,0 +1,10 @@
+package ivan.solscanbot.dto.external.transfer;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+@lombok.Data
+public class Data {
+ @JsonProperty("data")
+ private List data;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java b/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java
new file mode 100644
index 0000000..1764afe
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/dto/external/transfer/TransferData.java
@@ -0,0 +1,11 @@
+package ivan.solscanbot.dto.external.transfer;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Date;
+import lombok.Data;
+
+@Data
+public class TransferData {
+ @JsonProperty("time")
+ private Date time;
+}
diff --git a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java
index 9e7bb1e..372e765 100644
--- a/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java
+++ b/src/main/java/ivan/solscanbot/dto/internal/BalanceActivity.java
@@ -5,36 +5,47 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
+import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
+import lombok.ToString;
import lombok.experimental.Accessors;
import org.hibernate.annotations.SQLDelete;
-import org.hibernate.annotations.SQLRestriction;
+import org.hibernate.annotations.Where;
@Data
@Entity
@NoArgsConstructor
@Accessors(chain = true)
-@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?")
-@SQLRestriction("is_deleted = false")
+@SQLDelete(sql = "UPDATE balance_activities SET is_deleted = true WHERE id = ?")
+@Where(clause = "is_deleted = false")
+@Table(name = "balance_activities")
public class BalanceActivity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @Column(nullable = false)
- private String address;
@Column(nullable = false, name = "token_address")
private String tokenAddress;
- @Column(nullable = false, name = "token_name")
+ @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 = "address_id")
+ private MonitoredAddress monitoredAddress;
@Column(nullable = false, name = "is_deleted")
private boolean isDeleted = false;
}
diff --git a/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java b/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java
index efb1df9..e081a19 100644
--- a/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java
+++ b/src/main/java/ivan/solscanbot/dto/internal/MonitoredAddress.java
@@ -8,6 +8,7 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
+import jakarta.persistence.Table;
import java.util.HashSet;
import java.util.Set;
import lombok.Data;
@@ -15,13 +16,14 @@
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.hibernate.annotations.SQLDelete;
-import org.hibernate.annotations.SQLRestriction;
+import org.hibernate.annotations.Where;
-@Entity
@Data
+@Entity
@NoArgsConstructor
-@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?")
-@SQLRestriction("is_deleted = false")
+@SQLDelete(sql = "UPDATE monitored_addresses SET is_deleted = true WHERE id = ?")
+@Where(clause = "is_deleted = false")
+@Table(name = "monitored_addresses")
public class MonitoredAddress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
diff --git a/src/main/java/ivan/solscanbot/dto/internal/Token.java b/src/main/java/ivan/solscanbot/dto/internal/Token.java
index 3611a6b..133d5af 100644
--- a/src/main/java/ivan/solscanbot/dto/internal/Token.java
+++ b/src/main/java/ivan/solscanbot/dto/internal/Token.java
@@ -5,29 +5,26 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
-import java.math.BigDecimal;
+import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
-import org.hibernate.annotations.SQLRestriction;
+import org.hibernate.annotations.Where;
@Data
@Entity
@NoArgsConstructor
-@SQLDelete(sql = "UPDATE roles SET is_deleted = true WHERE id = ?")
-@SQLRestriction("is_deleted = false")
+@SQLDelete(sql = "UPDATE tokens SET is_deleted = true WHERE id = ?")
+@Where(clause = "is_deleted = false")
+@Table(name = "tokens")
public class Token {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @Column(unique = true, nullable = false, name = "token_name")
- private String tokenName;
+ @Column(name = "token_symbol")
+ private String tokenSymbol;
@Column(unique = true, nullable = false, name = "token_address")
private String tokenAddress;
- @Column(unique = true, nullable = false, name = "token_balance")
- private String tokenBalance;
- @Column(unique = true, nullable = false, name = "token_value")
- private BigDecimal tokenValue;
@Column(name = "is_deleted")
private boolean isDeleted = false;
}
diff --git a/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java b/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java
new file mode 100644
index 0000000..c49c4e8
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/exception/ExceedsAmountOfAddressesException.java
@@ -0,0 +1,11 @@
+package ivan.solscanbot.exception;
+
+public class ExceedsAmountOfAddressesException extends RuntimeException {
+ public ExceedsAmountOfAddressesException(String message) {
+ super(message);
+ }
+
+ public ExceedsAmountOfAddressesException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java
index 87c1280..ba03f4f 100644
--- a/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java
+++ b/src/main/java/ivan/solscanbot/mapper/ActivityMapper.java
@@ -1,7 +1,7 @@
package ivan.solscanbot.mapper;
import ivan.solscanbot.config.MapperConfig;
-import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto;
+import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto;
import ivan.solscanbot.dto.internal.BalanceActivity;
import org.mapstruct.Mapper;
diff --git a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java
index c199ff2..2e73407 100644
--- a/src/main/java/ivan/solscanbot/mapper/TokenMapper.java
+++ b/src/main/java/ivan/solscanbot/mapper/TokenMapper.java
@@ -1,7 +1,7 @@
package ivan.solscanbot.mapper;
import ivan.solscanbot.config.MapperConfig;
-import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto;
+import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto;
import ivan.solscanbot.dto.internal.Token;
import org.mapstruct.Mapper;
diff --git a/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java b/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java
index fe3d6e6..6dc522f 100644
--- a/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java
+++ b/src/main/java/ivan/solscanbot/repository/MonitoredAddressRepository.java
@@ -3,6 +3,7 @@
import ivan.solscanbot.dto.internal.MonitoredAddress;
import java.util.List;
import java.util.Optional;
+import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MonitoredAddressRepository
@@ -14,5 +15,6 @@ public interface MonitoredAddressRepository
List findByChatId(Long chatId);
+ @EntityGraph(attributePaths = "tokens")
Optional findByAddress(String address);
}
diff --git a/src/main/java/ivan/solscanbot/repository/TokenRepository.java b/src/main/java/ivan/solscanbot/repository/TokenRepository.java
index b16fd25..7711686 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 findByTokenName(String tokenName);
+ Optional findByTokenAddress(String tokenAddress);
}
diff --git a/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java b/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java
deleted file mode 100644
index 7efb014..0000000
--- a/src/main/java/ivan/solscanbot/service/DeFiMonitorBot.java
+++ /dev/null
@@ -1,185 +0,0 @@
-package ivan.solscanbot.service;
-
-import ivan.solscanbot.dto.internal.MonitoredAddress;
-import ivan.solscanbot.dto.internal.Token;
-import ivan.solscanbot.exception.AddressAlreadyExistsException;
-import ivan.solscanbot.exception.AddressNotMonitoredException;
-import ivan.solscanbot.exception.InvalidAddressException;
-import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException;
-import ivan.solscanbot.mapper.TokenMapper;
-import ivan.solscanbot.repository.MonitoredAddressRepository;
-import ivan.solscanbot.repository.TokenRepository;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Collectors;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-import org.telegram.telegrambots.bots.TelegramLongPollingBot;
-import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
-import org.telegram.telegrambots.meta.api.objects.Update;
-import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
-
-@Component
-public class DeFiMonitorBot extends TelegramLongPollingBot {
- private static final String SOLANA_ADDRESS_PATTERN = "[1-9A-HJ-NP-Za-km-z]{32,44}";
-
- private final String token;
- private final String username;
- private final MonitoredAddressRepository addressRepository;
- private final TokenRepository tokenRepository;
- private final SolScanServiceImpl solScanService;
- private final TokenMapper tokenMapper;
-
- public DeFiMonitorBot(
- @Value("${telegram.bot.token}") String token,
- @Value("${telegram.bot.username}") String username,
- MonitoredAddressRepository addressRepository, TokenRepository tokenRepository,
- SolScanServiceImpl solScanService, TokenMapper tokenMapper
- ) {
- this.token = token;
- this.username = username;
- this.addressRepository = addressRepository;
- this.tokenRepository = tokenRepository;
- this.solScanService = solScanService;
- this.tokenMapper = tokenMapper;
- }
-
- @Override
- public String getBotUsername() {
- return username;
- }
-
- @Override
- public String getBotToken() {
- return token;
- }
-
- @Override
- public void onUpdateReceived(Update update) {
- if (update.hasMessage() && update.getMessage().hasText()) {
- String messageText = update.getMessage().getText();
- long chatId = update.getMessage().getChatId();
-
- if (messageText.startsWith("/add")) {
- handleAddAddress(chatId, messageText);
- } else if (messageText.startsWith("/list")) {
- handleListAddresses(chatId);
- } else if (messageText.startsWith("/remove")) {
- handleRemoveAddress(chatId, messageText);
- } else if (messageText.startsWith("/portfolio")) {
- handleAddressPortfolio(chatId, messageText);
- } else {
- sendMessage(
- chatId, "Unknown command. Use /add, /list, or /remove");
- }
- }
- }
-
- public void sendMessage(long chatId, String text) {
- SendMessage message = new SendMessage();
- message.setChatId(String.valueOf(chatId));
- message.setText(text);
- try {
- execute(message);
- } catch (TelegramApiException e) {
- throw new RuntimeException("Error while sending a message.", e);
- }
- }
-
- private void handleAddressPortfolio(long chatId, String messageText) {
- String address = getSolanaAddressFromMessage(messageText);
- verifySolanaAddress(chatId, address);
- verifyUserHasCertainAddress(chatId, address);
-
- MonitoredAddress solanaAddress = addressRepository.findByAddress(address)
- .orElseThrow(() -> new AddressNotMonitoredException(address));
-
- String tokens = Optional.ofNullable(solanaAddress.getTokens())
- .orElse(Collections.emptySet())
- .stream()
- .map(Token::getTokenName)
- .collect(Collectors.joining(", "));
-
- sendMessage(chatId, "Tokens in portfolio:\n" + tokens);
- }
-
- public void handleAddAddress(long chatId, String message) {
- String solanaAddress = getSolanaAddressFromMessage(message);
- verifySolanaAddress(chatId, solanaAddress);
- verifyAddressAlreadyAdded(chatId, solanaAddress);
- Set tokensFromSolScan = solScanService.getTokensByAddress(solanaAddress)
- .stream()
- .map(tokenMapper::toModel)
- .collect(Collectors.toSet());
- Set managedTokens = tokensFromSolScan.stream()
- .map(t -> tokenRepository.findByTokenName(t.getTokenName())
- .orElseGet(() -> tokenRepository.save(t)))
- .collect(Collectors.toSet());
- MonitoredAddress address = new MonitoredAddress();
- address.setAddress(solanaAddress);
- address.setChatId(chatId);
- address.setTokens(managedTokens);
- addressRepository.save(address);
- sendMessage(chatId, "address\n" + solanaAddress + "\nis added to monitoring");
- }
-
- private void handleListAddresses(long chatId) {
- List addresses = addressRepository.findByChatId(chatId);
- verifyUserHasAddresses(chatId, addresses);
- AtomicInteger count = new AtomicInteger(1);
- String addressList = addresses
- .stream()
- .map(adr -> count.getAndIncrement() + ". " + adr.getAddress())
- .collect(Collectors.joining("\n"));
- sendMessage(chatId, "List of monitored addresses:\n" + addressList);
- }
-
- private void handleRemoveAddress(long chatId, String message) {
- String solanaAddress = getSolanaAddressFromMessage(message);
- verifySolanaAddress(chatId, solanaAddress);
- addressRepository.deleteByAddressAndChatId(solanaAddress, chatId);
- sendMessage(
- chatId, "address\n" + solanaAddress + "\nis removed from monitoring");
- }
-
- public void sendNotification(long chatId, String notification) {
- sendMessage(chatId, notification);
- }
-
- private String getSolanaAddressFromMessage(String message) {
- return message.substring(4).trim();
- }
-
- private void verifySolanaAddress(long chatId, String address) {
- if (address == null || !address.matches(SOLANA_ADDRESS_PATTERN)) {
- sendMessage(chatId, "Invalid Solana Address");
- throw new InvalidAddressException("Invalid Solana Address");
- }
- }
-
- private void verifyAddressAlreadyAdded(long chatId, String address) {
- if (addressRepository.existsByAddressAndChatId(address, chatId)) {
- sendMessage(chatId,"You already have this address in your list");
- throw new AddressAlreadyExistsException("You already have this address in your list");
- }
- }
-
- private void verifyUserHasAddresses(long chatId, List addresses) {
- if (addresses.isEmpty()) {
- sendMessage(chatId, "You have no monitored addresses");
- throw new UserNotHaveAnyMonitoredAddressesException("You have no monitored addresses");
- }
- }
-
- private void verifyUserHasCertainAddress(long chatId, String address) {
- if (!addressRepository.existsByAddressAndChatId(address, chatId)) {
- sendMessage(chatId,
- "You need to add this address to monitored address first '/add your_address'");
- throw new AddressNotMonitoredException(
- "You need to add this address to monitored address first '/add your_address'");
- }
- }
-}
diff --git a/src/main/java/ivan/solscanbot/service/MonitoringService.java b/src/main/java/ivan/solscanbot/service/MonitoringService.java
index 5f39bd7..cce99c9 100644
--- a/src/main/java/ivan/solscanbot/service/MonitoringService.java
+++ b/src/main/java/ivan/solscanbot/service/MonitoringService.java
@@ -1,62 +1,162 @@
package ivan.solscanbot.service;
-import ivan.solscanbot.dto.external.TokenMetaResponseDto;
+import com.google.common.util.concurrent.RateLimiter;
+import ivan.solscanbot.bot.TelegramBot;
+import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto;
+import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto;
import ivan.solscanbot.dto.internal.BalanceActivity;
import ivan.solscanbot.dto.internal.MonitoredAddress;
import ivan.solscanbot.mapper.ActivityMapper;
import ivan.solscanbot.repository.ActivityRepository;
import ivan.solscanbot.repository.MonitoredAddressRepository;
import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
-import org.jvnet.hk2.annotations.Service;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+@Slf4j
@Service
@RequiredArgsConstructor
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 DeFiMonitorBot deFiMonitorBot;
+ private final TelegramBot telegramBot;
private final SolScanServiceImpl solScanService;
private final ActivityRepository activityRepository;
private final ActivityMapper activityMapper;
- @Scheduled(fixedRate = 15000)
- public void sendTokenList() {
- for (MonitoredAddress address : addressRepository.findAll()) {
+ @Scheduled(fixedDelay = 300000)
+ public void newActivityFound() {
+ RateLimiter rateLimiter = RateLimiter.create(5.0);
+ addressRepository.findAll().forEach(address -> {
try {
- Set activities =
- solScanService.getNewBalanceActivities(address.getAddress())
- .stream()
- .filter(a -> BigDecimal.ZERO.equals(a.getPreBalance()))
- .map(activityMapper::toModel)
- .collect(Collectors.toSet());
+ rateLimiter.acquire();
+ Set activities = fetchAndProcessActivities(address);
if (!activities.isEmpty()) {
- StringBuilder notification = new StringBuilder();
- notification.append("New activity for Solana address\n")
- .append(address.getAddress())
- .append("was found");
- int count = 1;
- for (BalanceActivity activity : activities) {
- TokenMetaResponseDto meta =
- solScanService.getTokenMetaFromAddress(address.getAddress());
- activity.setTokenName(meta.getName())
- .setTokenSymbol(meta.getSymbol());
- notification.append("\n").append(count++)
- .append(". token address: ").append(activity.getTokenAddress())
- .append("\n")
- .append(". token address: ").append(activity.getTokenAddress())
- .append("\n")
- .append("token amount: ").append(activity.getAmount()).append("\n")
- .append("time: ").append(activity.getTime()).append("\n");
- }
activityRepository.saveAll(activities);
- deFiMonitorBot.sendNotification(address.getChatId(), notification.toString());
+ sendTelegramNotification(address, activities);
}
} catch (Exception e) {
- throw new RuntimeException(e);
+ log.error("Error checking activities for address {}", address.getAddress(), e);
+ }
+ });
+ }
+
+ private Set fetchAndProcessActivities(MonitoredAddress address) {
+ Set newActivities =
+ solScanService.getNewBalanceActivities(address.getAddress())
+ .stream()
+ .filter(act -> BigDecimal.ZERO.equals(act.getPreBalance()))
+ .collect(Collectors.toSet());
+
+ List tokenAddresses = newActivities.stream()
+ .map(SingleBalanceActivityResponseDto::getTokenAddress)
+ .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)
+ .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()));
+ try {
+ Map chunkResults =
+ solScanService.getMetaMapFromAddresses(chunk);
+ metaMap.putAll(chunkResults);
+ } catch (Exception e) {
+ log.error("Failed to fetch batch metadata for chunk: {}", chunk, e);
+ fetchTokenMetadata(chunk, metaMap);
}
}
+ return metaMap;
}
+
+ private void fetchTokenMetadata(List tokenAddresses,
+ Map metaMap) {
+ tokenAddresses.forEach(address -> {
+ TokenMetaResponseDto meta = solScanService.getTokenMeta(address);
+ metaMap.put(address, meta);
+ });
+ }
+
+ private BalanceActivity enrichWithTokenMeta(BalanceActivity act, TokenMetaResponseDto meta) {
+ BigDecimal price = Optional.ofNullable(meta.getPrice()).orElse(BigDecimal.ZERO);
+ int decimals = meta.getDecimals() > 0 ? meta.getDecimals() : 9;
+ BigDecimal normalizedAmount = act.getAmount()
+ .divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL32);
+ act.setValueInUsd(normalizedAmount.multiply(price))
+ .setTokenName(meta.getName())
+ .setTokenSymbol(meta.getSymbol());
+ return act;
+ }
+
+ 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 message = String.format("New activity for address: [%s](%s#balanceChanges)\n%s",
+ shortenAddress(address.getAddress()),
+ SOLSCAN_ACCOUNT_URL + address.getAddress(),
+ tokens);
+ telegramBot.sendNotification(address.getChatId(), message);
+ }
+
+ 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/SolScanService.java b/src/main/java/ivan/solscanbot/service/SolScanService.java
index 28bb6a3..3d1b090 100644
--- a/src/main/java/ivan/solscanbot/service/SolScanService.java
+++ b/src/main/java/ivan/solscanbot/service/SolScanService.java
@@ -1,8 +1,10 @@
package ivan.solscanbot.service;
-import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto;
-import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto;
-import ivan.solscanbot.dto.external.TokenMetaResponseDto;
+import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto;
+import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto;
+import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
public interface SolScanService {
@@ -10,5 +12,9 @@ public interface SolScanService {
Set getNewBalanceActivities(String address);
- TokenMetaResponseDto getTokenMetaFromAddress(String address);
+ Map getMetaMapFromAddresses(List addresses);
+
+ TokenMetaResponseDto getTokenMeta(String address);
+
+ boolean newTokenTransfer(String tokenAddress);
}
diff --git a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java
index 46aaa65..2612537 100644
--- a/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java
+++ b/src/main/java/ivan/solscanbot/service/SolScanServiceImpl.java
@@ -1,17 +1,26 @@
package ivan.solscanbot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
-import ivan.solscanbot.dto.external.BalanceActivitiesResponseDto;
-import ivan.solscanbot.dto.external.SingleBalanceActivityResponseDto;
-import ivan.solscanbot.dto.external.SingleTokenPortfolioResponseDto;
-import ivan.solscanbot.dto.external.TokenMetaResponseDto;
-import ivan.solscanbot.dto.external.TokenPortfoliosResponseDto;
+import ivan.solscanbot.dto.external.activity.BalanceActivitiesResponseDto;
+import ivan.solscanbot.dto.external.activity.SingleBalanceActivityResponseDto;
+import ivan.solscanbot.dto.external.meta.DataResponseDto;
+import ivan.solscanbot.dto.external.meta.MultiDataResponseDto;
+import ivan.solscanbot.dto.external.meta.TokenMetaResponseDto;
+import ivan.solscanbot.dto.external.portfolio.PortfolioWrapperDto;
+import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto;
+import ivan.solscanbot.dto.external.transfer.Data;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -23,8 +32,13 @@ public class SolScanServiceImpl implements SolScanService {
"https://pro-api.solscan.io/v2.0/account/portfolio";
private static final String SOL_SCAN_ACTIVITIES_URL =
"https://pro-api.solscan.io/v2.0/account/balance_change";
+ private static final String SOL_SCAN_TOKEN_MULTI_URL =
+ "https://pro-api.solscan.io/v2.0/token/meta/multi";
private static final String SOL_SCAN_TOKEN_URL =
"https://pro-api.solscan.io/v2.0/token/meta";
+ private static final String SOL_SCAN_TOKEN_TRANSFERS =
+ "https://pro-api.solscan.io/v2.0/token/transfer";
+
@Value("${sol.scan.key}")
private String solScanKey;
private final ObjectMapper objectMapper;
@@ -42,9 +56,9 @@ public Set getTokensByAddress(String address) {
try {
HttpResponse response =
httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
- TokenPortfoliosResponseDto tokens =
- objectMapper.readValue(response.body(), TokenPortfoliosResponseDto.class);
- return tokens.getTokens();
+ PortfolioWrapperDto wrapper =
+ objectMapper.readValue(response.body(), PortfolioWrapperDto.class);
+ return wrapper.getData().getTokens();
} catch (Exception e) {
throw new RuntimeException("An error occurred. Please try again later.");
}
@@ -53,7 +67,7 @@ public Set getTokensByAddress(String address) {
@Override
public Set getNewBalanceActivities(String address) {
HttpClient httpClient = HttpClient.newHttpClient();
- long fromTime = Instant.now().getEpochSecond() - 60;
+ long fromTime = Instant.now().getEpochSecond() - 300;
String url = SOL_SCAN_ACTIVITIES_URL + "?address=" + address
+ "&from_time=" + fromTime;
HttpRequest httpRequest = HttpRequest.newBuilder()
@@ -74,8 +88,9 @@ public Set getNewBalanceActivities(String addr
}
@Override
- public TokenMetaResponseDto getTokenMetaFromAddress(String tokenAddress) {
- String apiUrl = SOL_SCAN_TOKEN_URL + tokenAddress;
+ public Map getMetaMapFromAddresses(List tokenAddresses) {
+ String apiUrl = SOL_SCAN_TOKEN_MULTI_URL + "?address[]="
+ + String.join("&address[]=", tokenAddresses);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
@@ -85,9 +100,57 @@ public TokenMetaResponseDto getTokenMetaFromAddress(String tokenAddress) {
try {
HttpResponse response = client.send(
request, HttpResponse.BodyHandlers.ofString());
- return objectMapper.readValue(response.body(), TokenMetaResponseDto.class);
+ MultiDataResponseDto data = objectMapper
+ .readValue(response.body(), MultiDataResponseDto.class);
+ return data.getData().stream()
+ .collect(Collectors.toMap(
+ TokenMetaResponseDto::getAddress,
+ Function.identity(),
+ (existing, replacement) -> existing
+ ));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get multi token data. Please try again later.");
+ }
+ }
+
+ @Override
+ public TokenMetaResponseDto getTokenMeta(String address) {
+ String apiUrl = SOL_SCAN_TOKEN_URL + "?address=" + address;
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(apiUrl))
+ .header("accept", "application/json")
+ .header("token", solScanKey)
+ .build();
+ try {
+ HttpResponse response =
+ client.send(request, HttpResponse.BodyHandlers.ofString());
+ DataResponseDto data =
+ objectMapper.readValue(response.body(), DataResponseDto.class);
+ return data.getData();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get token data. Please try again later.");
+ }
+ }
+
+ @Override
+ public boolean newTokenTransfer(String tokenAddress) {
+ String apiUrl = SOL_SCAN_TOKEN_TRANSFERS + "?address=" + tokenAddress;
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(apiUrl))
+ .header("accept", "application/json")
+ .header("token", solScanKey)
+ .build();
+ try {
+ HttpResponse response =
+ client.send(request, HttpResponse.BodyHandlers.ofString());
+ Data result = objectMapper.readValue(response.body(), Data.class);
+ return result.getData().stream()
+ .anyMatch(transfer -> transfer.getTime()
+ .after(Date.from(Instant.now().minus(1, ChronoUnit.MINUTES))));
} catch (Exception e) {
- throw new RuntimeException("Failed to get token name. Please try again later.");
+ throw new RuntimeException(e);
}
}
}
diff --git a/src/main/java/ivan/solscanbot/service/TelegramService.java b/src/main/java/ivan/solscanbot/service/TelegramService.java
new file mode 100644
index 0000000..3658437
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/service/TelegramService.java
@@ -0,0 +1,13 @@
+package ivan.solscanbot.service;
+
+public interface TelegramService {
+ String getHelp();
+
+ String getPortfolios(long chatId, String messageText);
+
+ String addAddresses(long chatId, String message);
+
+ String listAddresses(long chatId);
+
+ String removeAddresses(long chatId, String message);
+}
diff --git a/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java
new file mode 100644
index 0000000..1571f3d
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/service/TelegramServiceImpl.java
@@ -0,0 +1,218 @@
+package ivan.solscanbot.service;
+
+import ivan.solscanbot.dto.external.portfolio.SingleTokenPortfolioResponseDto;
+import ivan.solscanbot.dto.internal.MonitoredAddress;
+import ivan.solscanbot.dto.internal.Token;
+import ivan.solscanbot.mapper.TokenMapper;
+import ivan.solscanbot.repository.MonitoredAddressRepository;
+import ivan.solscanbot.repository.TokenRepository;
+import ivan.solscanbot.verifier.SolanaAddressVerifier;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class TelegramServiceImpl implements TelegramService {
+ 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 int AMOUNT_OF_ADDRESSES = 3;
+
+ private final MonitoredAddressRepository addressRepository;
+ private final TokenRepository tokenRepository;
+ private final SolScanServiceImpl solScanService;
+ private final TokenMapper tokenMapper;
+ private final SolanaAddressVerifier addressVerifier;
+
+ @Override
+ public String getHelp() {
+ return getHelpMessage();
+ }
+
+ @Override
+ public String getPortfolios(long chatId, String message) {
+ Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message);
+ addressVerifier.verifyUserHasCertainAddresses(chatId, addresses);
+
+ List monitoredAddresses = addresses.stream()
+ .map(addressRepository::findByAddress)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .toList();
+ String formattedAddresses = monitoredAddresses.stream()
+ .map(this::formatSingleMonitoredAddress)
+ .collect(Collectors.joining("\n\n"));
+ return String.format("Tokens in portfolio:\n%s", formattedAddresses);
+ }
+
+ @Override
+ public String addAddresses(long chatId, String message) {
+ Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message);
+ addressVerifier.verifyAddressesAlreadyAdded(chatId, addresses);
+ Set addedAddresses = addresses.stream()
+ .map(address -> addSingleMonitoredAddress(chatId, address))
+ .collect(Collectors.toSet());
+ return String.format("Added following address(-es):\n%s",
+ formatAddressList(addedAddresses));
+ }
+
+ @Override
+ public String listAddresses(long chatId) {
+ List monitoredAddresses = addressRepository.findByChatId(chatId);
+ addressVerifier.verifyUserHasAddresses(monitoredAddresses);
+ Set solanaAddresses = monitoredAddresses.stream()
+ .map(MonitoredAddress::getAddress)
+ .collect(Collectors.toSet());
+ return String.format("Your monitored address(-es):\n%s",
+ formatAddressList(solanaAddresses));
+ }
+
+ @Override
+ @Transactional
+ public String removeAddresses(long chatId, String message) {
+ Set addresses = getSolanaAddressesFromMessage(AMOUNT_OF_ADDRESSES, message);
+ addressVerifier.verifyUserHasCertainAddresses(chatId, addresses);
+ addresses.forEach(address -> {
+ addressRepository.deleteByAddressAndChatId(address, chatId);
+ });
+ return String.format("Removed address(-es):\n%s",
+ formatAddressList(addresses));
+ }
+
+ private String formatAddressList(Set addresses) {
+ AtomicInteger count = new AtomicInteger(1);
+ return addresses.stream().map(
+ adr -> {
+ String url = buildSolscanAddressUrl(adr);
+ return String.format("%d. [%s](%s)",
+ count.getAndIncrement(),
+ shortenAddress(adr),
+ url);
+ })
+ .collect(Collectors.joining("\n")
+ );
+ }
+
+ private String formatSingleMonitoredAddress(MonitoredAddress monitoredAddress) {
+ String addressUrl = buildSolscanAddressUrl(monitoredAddress.getAddress());
+ String tokens = formatTokenList(monitoredAddress);
+ return String.format(
+ "Address link: [%s](%s)\nTokens:\n%s",
+ shortenAddress(monitoredAddress.getAddress()),
+ addressUrl,
+ tokens
+ );
+ }
+
+ private String formatTokenList(MonitoredAddress solanaAddress) {
+ AtomicInteger count = new AtomicInteger(1);
+ return solanaAddress.getTokens().stream()
+ .map(token -> {
+ String tokenUrl = SOLSCAN_TOKEN_URL + token.getTokenAddress() + "#holders";
+ return String.format(
+ "%d. %s: [%s](%s)",
+ count.getAndIncrement(),
+ token.getTokenSymbol(),
+ shortenAddress(token.getTokenAddress()),
+ tokenUrl
+ );
+ })
+ .collect(Collectors.joining("\n"));
+ }
+
+ private String addSingleMonitoredAddress(long chatId, String solanaAddress) {
+ Set tokensFromSolScan
+ = getFilteredTokensFromSolscan(solanaAddress);
+
+ Set managedTokens = mapAndSaveTokensFromSolscan(tokensFromSolScan);
+
+ MonitoredAddress address = new MonitoredAddress();
+ address.setAddress(solanaAddress);
+ address.setChatId(chatId);
+ address.setTokens(managedTokens);
+ addressRepository.save(address);
+ return address.getAddress();
+ }
+
+ private String buildSolscanAddressUrl(String address) {
+ return SOLSCAN_ACCOUNT_URL + address + "#balanceChanges";
+ }
+
+ private String shortenAddress(String address) {
+ return address.length() > 8
+ ? address.substring(0, 4) + "..." + address.substring(address.length() - 4)
+ : address;
+ }
+
+ private Set mapAndSaveTokensFromSolscan(
+ Set tokensFromSolScan) {
+ return tokensFromSolScan.stream()
+ .map(tokenMapper::toModel)
+ .map(t -> tokenRepository.findByTokenAddress(t.getTokenAddress())
+ .orElseGet(() -> tokenRepository.save(t)))
+ .collect(Collectors.toSet());
+ }
+
+ private Set getFilteredTokensFromSolscan(
+ String solanaAddress) {
+ return solScanService.getTokensByAddress(solanaAddress)
+ .stream()
+ .filter(tok -> tok.getTokenSymbol() != null)
+ .filter(tok -> tok.getTokenValue().compareTo(BigDecimal.TEN) > 0)
+ .sorted(Comparator.comparing(SingleTokenPortfolioResponseDto::getTokenValue)
+ .reversed())
+ .limit(10)
+ .collect(Collectors.toSet());
+ }
+
+ private Set getSolanaAddressesFromMessage(int amountOfAddresses, String message) {
+ addressVerifier.verifyAddressIsProvided(message);
+ String normalizedMessage = message.replaceAll("\\s+", " ").trim();
+ String[] addresses = normalizedMessage.substring(
+ normalizedMessage.indexOf(" ") + 1).split("\\s+");
+ addressVerifier.verifyAmountOfAddresses(amountOfAddresses, addresses);
+ addressVerifier.verifyValidSolanaAddresses(addresses);
+ return Arrays.stream(addresses)
+ .filter(addr -> !addr.isEmpty())
+ .collect(Collectors.toSet());
+ }
+
+ private String getHelpMessage() {
+ return """
+ SolScan Bot Help
+
+ This bot helps you monitor Solana addresses and track their token portfolios.
+ Here are the available commands:
+
+ Basic Commands:
+ `/help` - Show this help message
+ `/list` - List all your monitored addresses
+
+ Address Management:
+ `/add ` - Add up to 3 Solana addresses to monitor
+ `/remove ` - Remove addresses from monitoring
+
+ Portfolio Tracking:
+ `/portfolio ` - Show token portfolio
+ of 10 most valuable tokens for specified addresses
+
+ Usage Examples:
+ Add addresses: `/add D8wZ...3j4H G2eF...7k9L`
+ Remove address: `/remove D8wZ...3j4H`
+ Check portfolio: `/portfolio D8wZ...3j4H`
+
+ Notes:
+ • With one request you can '/add', '/remove' or '/portfolio' up to 3 addresses at a time.
+ If you want to manage more addresses send more requests
+ • Addresses are displayed in shortened format (first 4 + last 4 chars)
+ """;
+ }
+}
diff --git a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java
new file mode 100644
index 0000000..31b8b74
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifier.java
@@ -0,0 +1,25 @@
+package ivan.solscanbot.verifier;
+
+import ivan.solscanbot.dto.internal.MonitoredAddress;
+import java.util.List;
+import java.util.Set;
+
+public interface SolanaAddressVerifier {
+ void verifyAmountOfAddresses(int amount, String[] addresses);
+
+ void verifyAddressIsProvided(String message);
+
+ void verifyValidSolanaAddress(String address);
+
+ void verifyUserHasCertainAddress(long chatId, String address);
+
+ void verifyAddressAlreadyAdded(long chatId, String address);
+
+ void verifyValidSolanaAddresses(String[] addresses);
+
+ void verifyUserHasCertainAddresses(long chatId, Set addresses);
+
+ void verifyAddressesAlreadyAdded(long chatId, Set addresses);
+
+ void verifyUserHasAddresses(List addresses);
+}
diff --git a/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java
new file mode 100644
index 0000000..a5394a9
--- /dev/null
+++ b/src/main/java/ivan/solscanbot/verifier/SolanaAddressVerifierImpl.java
@@ -0,0 +1,83 @@
+package ivan.solscanbot.verifier;
+
+import ivan.solscanbot.dto.internal.MonitoredAddress;
+import ivan.solscanbot.exception.AddressAlreadyExistsException;
+import ivan.solscanbot.exception.AddressNotMonitoredException;
+import ivan.solscanbot.exception.ExceedsAmountOfAddressesException;
+import ivan.solscanbot.exception.InvalidAddressException;
+import ivan.solscanbot.exception.UserNotHaveAnyMonitoredAddressesException;
+import ivan.solscanbot.repository.MonitoredAddressRepository;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class SolanaAddressVerifierImpl implements SolanaAddressVerifier {
+ private static final String SOLANA_ADDRESS_PATTERN = "[1-9A-HJ-NP-Za-km-z]{32,44}";
+
+ private final MonitoredAddressRepository addressRepository;
+
+ @Override
+ public void verifyAmountOfAddresses(int amount, String[] addresses) {
+ if (addresses.length > amount) {
+ throw new ExceedsAmountOfAddressesException(
+ "You can only manage up to three addresses in a single request.");
+ }
+ }
+
+ @Override
+ public void verifyAddressIsProvided(String message) {
+ if (message.trim().length() <= 1) {
+ throw new InvalidAddressException("No addresses provided.");
+ }
+ }
+
+ @Override
+ public void verifyValidSolanaAddress(String address) {
+ if (address == null || !address.matches(SOLANA_ADDRESS_PATTERN)) {
+ throw new InvalidAddressException("Invalid Solana Address - " + address);
+ }
+ }
+
+ @Override
+ 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);
+ }
+ }
+
+ @Override
+ public void verifyAddressAlreadyAdded(long chatId, String address) {
+ if (addressRepository.existsByAddressAndChatId(address, chatId)) {
+ throw new AddressAlreadyExistsException(
+ "You already have this address in your list.\n\nAddress: " + address);
+ }
+ }
+
+ @Override
+ public void verifyValidSolanaAddresses(String[] addresses) {
+ Arrays.stream(addresses).forEach(this::verifyValidSolanaAddress);
+ }
+
+ @Override
+ public void verifyUserHasCertainAddresses(long chatId, Set addresses) {
+ addresses.forEach(address -> verifyUserHasCertainAddress(chatId, address));
+ }
+
+ @Override
+ public void verifyAddressesAlreadyAdded(long chatId, Set addresses) {
+ addresses.forEach(address -> verifyAddressAlreadyAdded(chatId, address));
+ }
+
+ @Override
+ public void verifyUserHasAddresses(List addresses) {
+ if (addresses.isEmpty()) {
+ throw new UserNotHaveAnyMonitoredAddressesException("You have no monitored addresses");
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 7709139..accf6bb 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -3,12 +3,11 @@ spring.application.name=solscanbot
spring.thymeleaf.check-template-location=false
spring.jpa.open-in-view=false
-spring.h2.console.enabled=true
-spring.h2.console.path=/h2-console
-spring.datasource.url=jdbc:h2:mem:scanbot
-
+# Optional Telegram and custom config
telegram.bot.token=${TELEGRAM_BOT_TOKEN}
telegram.bot.username=${TELEGRAM_BOT_USERNAME}
sol.scan.url=${SOL_SCAN_URL}
sol.scan.key=${SOL_SCAN_KEY}
+
+# ? Import the .env file for the variables above
spring.config.import=optional:file:.env[.properties]
diff --git a/src/main/resources/db/changelog/changes/create-database.yaml b/src/main/resources/db/changelog/changes/create-database.yaml
new file mode 100644
index 0000000..d26b17a
--- /dev/null
+++ b/src/main/resources/db/changelog/changes/create-database.yaml
@@ -0,0 +1,133 @@
+databaseChangeLog:
+ - changeSet:
+ id: 1
+ author: ivan
+ changes:
+ - createTable:
+ tableName: tokens
+ columns:
+ - column:
+ name: id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: token_symbol
+ type: VARCHAR(255)
+ - column:
+ name: token_address
+ type: VARCHAR(255)
+ constraints:
+ unique: true
+ nullable: false
+ - column:
+ name: is_deleted
+ type: BOOLEAN
+ defaultValueBoolean: false
+
+ - changeSet:
+ id: 2
+ author: ivan
+ changes:
+ - createTable:
+ tableName: monitored_addresses
+ columns:
+ - column:
+ name: id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ nullable: false
+ - column:
+ name: address
+ type: VARCHAR(255)
+ constraints:
+ nullable: false
+ - column:
+ name: chat_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ - column:
+ name: is_deleted
+ type: BOOLEAN
+ defaultValueBoolean: false
+
+ - changeSet:
+ id: 3
+ author: ivan
+ changes:
+ - createTable:
+ tableName: balance_activities
+ columns:
+ - column:
+ name: id
+ type: BIGINT
+ autoIncrement: true
+ 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
+ constraints:
+ nullable: false
+ - column:
+ name: amount
+ type: JAVA.MATH.BIGDECIMAL
+ constraints:
+ nullable: false
+ - column:
+ name: time
+ type: TIMESTAMP
+ constraints:
+ nullable: false
+ - column:
+ name: address_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ foreignKeyName: fk_balance_activity_address
+ references: monitored_addresses(id)
+ - column:
+ name: is_deleted
+ type: BOOLEAN
+ defaultValueBoolean: false
+
+ - changeSet:
+ id: 4
+ author: ivan
+ changes:
+ - createTable:
+ tableName: address_token
+ columns:
+ - column:
+ name: address_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ foreignKeyName: fk_address_token_address
+ references: monitored_addresses(id)
+ - column:
+ name: token_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ foreignKeyName: fk_address_token_token
+ references: tokens(id)
diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml
new file mode 100644
index 0000000..ef93391
--- /dev/null
+++ b/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -0,0 +1,3 @@
+databaseChangeLog:
+ - include:
+ file: db/changelog/changes/create-database.yaml
diff --git a/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java b/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java
index 0f6c821..06e5b76 100644
--- a/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java
+++ b/src/test/java/ivan/solscanbot/SolScanBotApplicationTests.java
@@ -1,13 +1,13 @@
package ivan.solscanbot;
-import ivan.solscanbot.service.DeFiMonitorBot;
+import ivan.solscanbot.bot.TelegramBot;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.telegram.telegrambots.meta.TelegramBotsApi;
@SpringBootTest
-@MockitoBean(types = {DeFiMonitorBot.class, TelegramBotsApi.class})
+@MockitoBean(types = {TelegramBot.class, TelegramBotsApi.class})
class SolScanBotApplicationTests {
@Test
void contextLoads() {
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index adeb4a3..8f57968 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -1,9 +1,3 @@
-spring.datasource.url=jdbc:h2:mem:testdb
-spring.datasource.driverClassName=org.h2.Driver
-spring.datasource.username=sa
-spring.datasource.password=password
-spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
-
telegram.bot.token=bot_test
telegram.bot.username=user_test
sol.scan.url=url_test