Compare commits
2 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| d51661c84c | |||
| b319355624 |
@@ -44,7 +44,6 @@ dependencies {
|
|||||||
runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0")
|
runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0")
|
||||||
runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0")
|
runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0")
|
||||||
|
|
||||||
implementation("com.r35157.nenjim:hubd-api:0.1-dev")
|
|
||||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.6")
|
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.6")
|
||||||
implementation("com.fazecast:jSerialComm:2.11.4")
|
implementation("com.fazecast:jSerialComm:2.11.4")
|
||||||
implementation("com.google.code.gson:gson:2.14.0")
|
implementation("com.google.code.gson:gson:2.14.0")
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
# Id Asset Direction Target Trigger Severity Note
|
# ID Asset Direction Target TRIGGER SEVERITY NOTE
|
||||||
######################################################################################################################
|
################################################################################################################
|
||||||
1 SOL ABOVE 97.03-1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana short LIKVIDERING!"
|
1 SOL BELOW 170.0 ONETIME INFO "EMERGENCY: Risiko for Perps Solana long LIKVIDERING!"
|
||||||
2 SOL BELOW 48.72+1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana long LIKVIDERING!"
|
2 SOL BELOW 71.4 ONETIME CRITICAL "CRITICAL: Risiko for Solana Raydium LÅN LIKVIDERING!"
|
||||||
3 BTC ABOVE 85032.87-1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Bitcoin short LIKVIDERING!"
|
|
||||||
4 BTC BELOW 42779.40+1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Bitcoin long LIKVIDERING!"
|
|
||||||
5 ETH ABOVE 2296.13-1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Ethereum short LIKVIDERING!"
|
|
||||||
6 ETH BELOW 1155.19+1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Ethereum long LIKVIDERING!"
|
|
||||||
|
|||||||
+9
-168
@@ -6,30 +6,25 @@ import java.io.IOException;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
public final class AlarmConfigurationParser {
|
public final class AlarmConfigurationParser {
|
||||||
|
|
||||||
public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
|
public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
|
||||||
List<String> lines = Files.readAllLines(path);
|
List<String> lines = Files.readAllLines(path);
|
||||||
List<PriceAlarmDefinition> alarms = new ArrayList<>();
|
List<PriceAlarmDefinition> alarms = new ArrayList<>();
|
||||||
Map<String, String> constants = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
|
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
|
||||||
String line = lines.get(lineNumber - 1);
|
String line = lines.get(lineNumber - 1);
|
||||||
String trimmed = line.trim();
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
||||||
if(trimmed.isEmpty() || trimmed.startsWith("#")) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if(isConstantDefinition(trimmed)) {
|
alarms.add(parseLine(line));
|
||||||
parseConstantDefinition(constants, line);
|
|
||||||
} else {
|
|
||||||
String resolvedLine = replaceConstants(line, constants);
|
|
||||||
alarms.add(parseLine(resolvedLine));
|
|
||||||
}
|
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
path + ":" + lineNumber + ": " + exception.getMessage(),
|
path + ":" + lineNumber + ": " + exception.getMessage(),
|
||||||
@@ -58,10 +53,10 @@ public final class AlarmConfigurationParser {
|
|||||||
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
|
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
|
||||||
);
|
);
|
||||||
|
|
||||||
BigDecimal target = parseTarget(cursor.nextToken("target"));
|
BigDecimal target = new BigDecimal(cursor.nextToken("target"));
|
||||||
|
|
||||||
TriggerConfiguration triggerConfiguration = parseTrigger(
|
AlarmTrigger trigger = AlarmTrigger.valueOf(
|
||||||
cursor.nextToken("trigger")
|
cursor.nextToken("trigger").toUpperCase(Locale.ROOT)
|
||||||
);
|
);
|
||||||
|
|
||||||
AlarmSeverity severity = AlarmSeverity.valueOf(
|
AlarmSeverity severity = AlarmSeverity.valueOf(
|
||||||
@@ -82,108 +77,12 @@ public final class AlarmConfigurationParser {
|
|||||||
asset,
|
asset,
|
||||||
direction,
|
direction,
|
||||||
target,
|
target,
|
||||||
triggerConfiguration.trigger(),
|
trigger,
|
||||||
triggerConfiguration.gracePeriod(),
|
|
||||||
severity,
|
severity,
|
||||||
note
|
note
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BigDecimal parseTargetPercentage(
|
|
||||||
String targetStr,
|
|
||||||
int operatorIndex,
|
|
||||||
boolean add
|
|
||||||
) {
|
|
||||||
BigDecimal base = new BigDecimal(targetStr.substring(0, operatorIndex));
|
|
||||||
String percentStr = targetStr.substring(operatorIndex + 1, targetStr.length() - 1);
|
|
||||||
BigDecimal percent = new BigDecimal(percentStr);
|
|
||||||
|
|
||||||
BigDecimal delta = base
|
|
||||||
.multiply(percent)
|
|
||||||
.divide(BigDecimal.valueOf(100));
|
|
||||||
|
|
||||||
BigDecimal target = add ? base.add(delta) : base.subtract(delta);
|
|
||||||
|
|
||||||
validateTarget(base, targetStr);
|
|
||||||
validateTarget(percent, targetStr);
|
|
||||||
validateTarget(target, targetStr);
|
|
||||||
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TriggerConfiguration parseTrigger(String triggerText) {
|
|
||||||
String normalized = triggerText.toUpperCase(Locale.ROOT);
|
|
||||||
|
|
||||||
if (normalized.equals("ONETIME")) {
|
|
||||||
return new TriggerConfiguration(AlarmTrigger.ONETIME, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.equals("PERSISTENT")) {
|
|
||||||
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.startsWith("PERSISTENT:")) {
|
|
||||||
String graceText = normalized.substring("PERSISTENT:".length());
|
|
||||||
|
|
||||||
if (graceText.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Missing persistent grace period: " + triggerText);
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩsecondsΩ gracePeriodSeconds;
|
|
||||||
try {
|
|
||||||
gracePeriodSeconds = Integer.parseInt(graceText);
|
|
||||||
} catch (NumberFormatException exception) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Invalid persistent grace period: " + triggerText,
|
|
||||||
exception
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gracePeriodSeconds < 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Persistent grace period cannot be negative: " + triggerText
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, gracePeriodSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.startsWith("ONETIME:")) {
|
|
||||||
throw new IllegalArgumentException("ONETIME cannot have a grace period: " + triggerText);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new IllegalArgumentException("Unknown trigger: " + triggerText);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateTarget(BigDecimal target, String originalTargetStr) {
|
|
||||||
if (target.compareTo(BigDecimal.ZERO) < 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Target must be zero or positive: " + originalTargetStr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal parseTarget(String targetStr) {
|
|
||||||
String trimmedTargetStr = targetStr.trim();
|
|
||||||
|
|
||||||
if (trimmedTargetStr.endsWith("%")) {
|
|
||||||
int plusIndex = trimmedTargetStr.indexOf('+');
|
|
||||||
int minusIndex = trimmedTargetStr.indexOf('-', 1);
|
|
||||||
|
|
||||||
if (plusIndex >= 0) {
|
|
||||||
return parseTargetPercentage(trimmedTargetStr, plusIndex, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minusIndex >= 0) {
|
|
||||||
return parseTargetPercentage(trimmedTargetStr, minusIndex, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal target = new BigDecimal(trimmedTargetStr);
|
|
||||||
validateTarget(target, targetStr);
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class Cursor {
|
private static final class Cursor {
|
||||||
private Cursor(String line) {
|
private Cursor(String line) {
|
||||||
this.line = line;
|
this.line = line;
|
||||||
@@ -268,64 +167,6 @@ public final class AlarmConfigurationParser {
|
|||||||
private int position;
|
private int position;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isConstantDefinition(String trimmedLine) {
|
|
||||||
return trimmedLine.startsWith("{{");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void parseConstantDefinition(
|
|
||||||
Map<String, String> constants,
|
|
||||||
String line
|
|
||||||
) {
|
|
||||||
Cursor cursor = new Cursor(line);
|
|
||||||
|
|
||||||
String name = cursor.nextToken("constant name");
|
|
||||||
String value = cursor.nextToken("constant value");
|
|
||||||
|
|
||||||
cursor.skipWhitespace();
|
|
||||||
if (!cursor.atEnd() && cursor.current() != '#') {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Unexpected text after constant value: " + cursor.remaining()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateConstantName(name);
|
|
||||||
|
|
||||||
if (constants.putIfAbsent(name, value) != null) {
|
|
||||||
throw new IllegalArgumentException("Duplicate constant: " + name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String replaceConstants(
|
|
||||||
String line,
|
|
||||||
Map<String, String> constants
|
|
||||||
) {
|
|
||||||
String result = line;
|
|
||||||
|
|
||||||
for (Map.Entry<String, String> entry : constants.entrySet()) {
|
|
||||||
result = result.replace(entry.getKey(), entry.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.contains("{{") || result.contains("}}")) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Unknown or malformed constant in line: " + line
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateConstantName(String name) {
|
|
||||||
if (!name.matches("\\{\\{[A-Z0-9_]+}}")) {
|
|
||||||
throw new IllegalArgumentException("Invalid constant name: " + name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record TriggerConfiguration(
|
|
||||||
AlarmTrigger trigger,
|
|
||||||
ΩsecondsΩ gracePeriod
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private AlarmConfigurationParser() {
|
private AlarmConfigurationParser() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ public final class DovesAgPriceFeedDecoder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void selfTest() {
|
||||||
|
byte[] data = new byte[ACCOUNT_SIZE];
|
||||||
|
System.arraycopy(AG_PRICE_FEED_DISCRIMINATOR, 0, data, 0, 8);
|
||||||
|
|
||||||
|
putUnsignedLongLittleEndian(data, PRICE_OFFSET, new BigInteger("123456789"));
|
||||||
|
data[EXPONENT_OFFSET] = (byte) -6;
|
||||||
|
ByteBuffer.wrap(data, TIMESTAMP_OFFSET, Long.BYTES)
|
||||||
|
.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.putLong(1_700_000_000L);
|
||||||
|
|
||||||
|
OraclePrice decoded = decode(JupiterPerpsAsset.SOL, data, 42L, "self-test");
|
||||||
|
if (decoded.priceUsd().compareTo(new BigDecimal("123.456789")) != 0) {
|
||||||
|
throw new IllegalStateException("Decoder self-test failed: " + decoded.priceUsd());
|
||||||
|
}
|
||||||
|
if (!decoded.oracleTime().equals(Instant.ofEpochSecond(1_700_000_000L))) {
|
||||||
|
throw new IllegalStateException("Timestamp self-test failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static BigInteger readUnsignedLongLittleEndian(byte[] data, int offset) {
|
private static BigInteger readUnsignedLongLittleEndian(byte[] data, int offset) {
|
||||||
byte[] positiveBigEndian = new byte[Long.BYTES + 1];
|
byte[] positiveBigEndian = new byte[Long.BYTES + 1];
|
||||||
for (int index = 0; index < Long.BYTES; index++) {
|
for (int index = 0; index < Long.BYTES; index++) {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ public final class JupiterPerpsAlarmImpl {
|
|||||||
throw new IllegalStateException(errMsg, exception);
|
throw new IllegalStateException(errMsg, exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.selfTest()) {
|
||||||
|
SelfTest.run();
|
||||||
|
System.out.println("All self-tests passed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
List<PriceAlarmDefinition> definitions;
|
List<PriceAlarmDefinition> definitions;
|
||||||
try {
|
try {
|
||||||
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
|
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
|
||||||
@@ -120,6 +126,7 @@ public final class JupiterPerpsAlarmImpl {
|
|||||||
Options:
|
Options:
|
||||||
--config=<path> Default: price-alarms.conf
|
--config=<path> Default: price-alarms.conf
|
||||||
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
|
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
|
||||||
|
--self-test Test parser, alarm semantics and oracle decoder
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
PRICE_ALARMS_CONFIG Alternative default configuration path
|
PRICE_ALARMS_CONFIG Alternative default configuration path
|
||||||
@@ -133,9 +140,11 @@ public final class JupiterPerpsAlarmImpl {
|
|||||||
Path alarmConfiguration,
|
Path alarmConfiguration,
|
||||||
List<URI> webSocketEndpoints,
|
List<URI> webSocketEndpoints,
|
||||||
String pushoverToken,
|
String pushoverToken,
|
||||||
String pushoverUserKey
|
String pushoverUserKey,
|
||||||
|
boolean selfTest
|
||||||
) {
|
) {
|
||||||
private static Config parse(String[] args, Map<String, String> environment) {
|
private static Config parse(String[] args, Map<String, String> environment) {
|
||||||
|
boolean selfTest = Arrays.asList(args).contains("--self-test");
|
||||||
Map<String, String> options = Arrays.stream(args)
|
Map<String, String> options = Arrays.stream(args)
|
||||||
.filter(argument -> argument.startsWith("--") && argument.contains("="))
|
.filter(argument -> argument.startsWith("--") && argument.contains("="))
|
||||||
.map(argument -> argument.substring(2).split("=", 2))
|
.map(argument -> argument.substring(2).split("=", 2))
|
||||||
@@ -175,7 +184,8 @@ public final class JupiterPerpsAlarmImpl {
|
|||||||
Path.of(configurationText),
|
Path.of(configurationText),
|
||||||
endpoints,
|
endpoints,
|
||||||
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
|
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
|
||||||
blankToNull(environment.get("PUSHOVER_USER_KEY"))
|
blankToNull(environment.get("PUSHOVER_USER_KEY")),
|
||||||
|
selfTest
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
public final class PriceAlarm {
|
public final class PriceAlarm {
|
||||||
|
|
||||||
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
|
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
|
||||||
@@ -27,28 +25,16 @@ public final class PriceAlarm {
|
|||||||
|
|
||||||
previousReached = reached;
|
previousReached = reached;
|
||||||
|
|
||||||
if (!reached) {
|
if (!enteredTriggeredSide) {
|
||||||
previousReached = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.trigger() == AlarmTrigger.ONETIME) {
|
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) {
|
||||||
if (!enteredTriggeredSide || triggerCount > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger(price);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
|
triggerCount++;
|
||||||
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
|
action.trigger(price, definition);
|
||||||
trigger(price);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PriceAlarmDefinition definition() {
|
public PriceAlarmDefinition definition() {
|
||||||
@@ -59,32 +45,9 @@ public final class PriceAlarm {
|
|||||||
return triggerCount;
|
return triggerCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean persistentGracePeriodHasPassed() {
|
|
||||||
if (lastTriggeredAt == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩsecondsΩ gracePeriod = definition.triggerGracePeriod();
|
|
||||||
|
|
||||||
if (gracePeriod == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !Instant.now().isBefore(
|
|
||||||
lastTriggeredAt.plusSeconds(gracePeriod)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void trigger(OraclePrice price) {
|
|
||||||
triggerCount++;
|
|
||||||
lastTriggeredAt = Instant.now();
|
|
||||||
action.trigger(price, definition);
|
|
||||||
}
|
|
||||||
|
|
||||||
private final PriceAlarmDefinition definition;
|
private final PriceAlarmDefinition definition;
|
||||||
private final AlarmAction action;
|
private final AlarmAction action;
|
||||||
|
|
||||||
private Instant lastTriggeredAt;
|
|
||||||
private Boolean previousReached;
|
private Boolean previousReached;
|
||||||
private long triggerCount;
|
private long triggerCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public record PriceAlarmDefinition(
|
|||||||
PriceDirection direction,
|
PriceDirection direction,
|
||||||
BigDecimal target,
|
BigDecimal target,
|
||||||
AlarmTrigger trigger,
|
AlarmTrigger trigger,
|
||||||
ΩsecondsΩ triggerGracePeriod,
|
|
||||||
AlarmSeverity severity,
|
AlarmSeverity severity,
|
||||||
String note
|
String note
|
||||||
) {
|
) {
|
||||||
@@ -25,11 +24,5 @@ public record PriceAlarmDefinition(
|
|||||||
if (target.signum() < 0) {
|
if (target.signum() < 0) {
|
||||||
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
|
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (triggerGracePeriod < 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Trigger grace period cannot be negative: " + triggerGracePeriod
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,18 @@ public final class PushoverAlarmAction implements AlarmAction {
|
|||||||
@Override
|
@Override
|
||||||
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
|
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
|
||||||
String title = "Jupiter Perps " + price.asset() + " alarm";
|
String title = "Jupiter Perps " + price.asset() + " alarm";
|
||||||
String message = createMessage(price, alarm);
|
String message = String.format(
|
||||||
|
"%s is %s USD. Target: %s %s USD. Oracle time: %s. Slot: %d.",
|
||||||
|
price.asset(),
|
||||||
|
price.priceUsd().toPlainString(),
|
||||||
|
alarm.direction(),
|
||||||
|
alarm.target().toPlainString(),
|
||||||
|
price.oracleTime(),
|
||||||
|
price.slot()
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Note is intentionally parsed and retained in the model, but are not used by Pushover yet.
|
||||||
|
// https://git.r35157.com/r35157/com_r35157_nenjim-hubd-impl_ref/issues/7
|
||||||
String body = form("token", applicationToken) + "&" +
|
String body = form("token", applicationToken) + "&" +
|
||||||
form("user", userKey) + "&" +
|
form("user", userKey) + "&" +
|
||||||
form("title", title) + "&" +
|
form("title", title) + "&" +
|
||||||
@@ -69,21 +79,6 @@ public final class PushoverAlarmAction implements AlarmAction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String createMessage(OraclePrice price, PriceAlarmDefinition alarm) {
|
|
||||||
return String.format(
|
|
||||||
"%d - %s: %s%n%n%s is %s USD.%nTarget: %s %s USD.%nOracle time: %s.%nSlot: %d.",
|
|
||||||
alarm.id(),
|
|
||||||
alarm.severity(),
|
|
||||||
alarm.note(),
|
|
||||||
price.asset(),
|
|
||||||
price.priceUsd().toPlainString(),
|
|
||||||
alarm.direction(),
|
|
||||||
alarm.target().toPlainString(),
|
|
||||||
price.oracleTime(),
|
|
||||||
price.slot()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||||
private final String applicationToken;
|
private final String applicationToken;
|
||||||
private final String userKey;
|
private final String userKey;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class SelfTest {
|
||||||
|
|
||||||
|
public static void run() {
|
||||||
|
DovesAgPriceFeedDecoder.selfTest();
|
||||||
|
configurationParserTest();
|
||||||
|
oneTimeAlarmTest();
|
||||||
|
persistentAlarmTest();
|
||||||
|
initialTriggerTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void configurationParserTest() {
|
||||||
|
PriceAlarmDefinition alarm = AlarmConfigurationParser.parseLine(
|
||||||
|
"SOL ABOVE 75.7 ONETIME CRITICAL \"ALARM: Risiko for likvidering!\""
|
||||||
|
);
|
||||||
|
|
||||||
|
require(alarm.asset() == JupiterPerpsAsset.SOL, "Asset parsing failed");
|
||||||
|
require(alarm.direction() == PriceDirection.ABOVE, "Direction parsing failed");
|
||||||
|
require(alarm.target().compareTo(new BigDecimal("75.7")) == 0, "Target parsing failed");
|
||||||
|
require(alarm.trigger() == AlarmTrigger.ONETIME, "Trigger parsing failed");
|
||||||
|
require(alarm.severity() == AlarmSeverity.CRITICAL, "Severity parsing failed");
|
||||||
|
require(alarm.note().equals("ALARM: Risiko for likvidering!"), "Note parsing failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void oneTimeAlarmTest() {
|
||||||
|
List<BigDecimal> triggeredPrices = new ArrayList<>();
|
||||||
|
PriceAlarm alarm = new PriceAlarm(
|
||||||
|
definition(PriceDirection.ABOVE, "100", AlarmTrigger.ONETIME),
|
||||||
|
(price, definition) -> triggeredPrices.add(price.priceUsd())
|
||||||
|
);
|
||||||
|
|
||||||
|
accept(alarm, "90");
|
||||||
|
accept(alarm, "100");
|
||||||
|
accept(alarm, "110");
|
||||||
|
accept(alarm, "90");
|
||||||
|
accept(alarm, "101");
|
||||||
|
|
||||||
|
require(triggeredPrices.equals(List.of(new BigDecimal("100"))), "ONETIME semantics failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void persistentAlarmTest() {
|
||||||
|
List<BigDecimal> triggeredPrices = new ArrayList<>();
|
||||||
|
PriceAlarm alarm = new PriceAlarm(
|
||||||
|
definition(PriceDirection.BELOW, "100", AlarmTrigger.PERSISTENT),
|
||||||
|
(price, definition) -> triggeredPrices.add(price.priceUsd())
|
||||||
|
);
|
||||||
|
|
||||||
|
accept(alarm, "110");
|
||||||
|
accept(alarm, "99");
|
||||||
|
accept(alarm, "98");
|
||||||
|
accept(alarm, "101");
|
||||||
|
accept(alarm, "100");
|
||||||
|
|
||||||
|
require(
|
||||||
|
triggeredPrices.equals(List.of(new BigDecimal("99"), new BigDecimal("100"))),
|
||||||
|
"PERSISTENT crossing semantics failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void initialTriggerTest() {
|
||||||
|
List<BigDecimal> triggeredPrices = new ArrayList<>();
|
||||||
|
PriceAlarm alarm = new PriceAlarm(
|
||||||
|
definition(PriceDirection.ABOVE, "100", AlarmTrigger.PERSISTENT),
|
||||||
|
(price, definition) -> triggeredPrices.add(price.priceUsd())
|
||||||
|
);
|
||||||
|
|
||||||
|
accept(alarm, "105");
|
||||||
|
accept(alarm, "106");
|
||||||
|
|
||||||
|
require(
|
||||||
|
triggeredPrices.equals(List.of(new BigDecimal("105"))),
|
||||||
|
"Initial satisfied alarm did not trigger exactly once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PriceAlarmDefinition definition(
|
||||||
|
PriceDirection direction,
|
||||||
|
String target,
|
||||||
|
AlarmTrigger trigger
|
||||||
|
) {
|
||||||
|
return new PriceAlarmDefinition(
|
||||||
|
123,
|
||||||
|
JupiterPerpsAsset.SOL,
|
||||||
|
direction,
|
||||||
|
new BigDecimal(target),
|
||||||
|
trigger,
|
||||||
|
AlarmSeverity.CRITICAL,
|
||||||
|
"ignored for now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void accept(PriceAlarm alarm, String price) {
|
||||||
|
BigDecimal decimal = new BigDecimal(price);
|
||||||
|
alarm.accept(new OraclePrice(
|
||||||
|
JupiterPerpsAsset.SOL,
|
||||||
|
decimal.unscaledValue().abs(),
|
||||||
|
-decimal.scale(),
|
||||||
|
decimal,
|
||||||
|
Instant.ofEpochSecond(1_700_000_000L),
|
||||||
|
1L,
|
||||||
|
"self-test"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new IllegalStateException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SelfTest() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
|
||||||
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.r35157.libs.codec.impl.ref;
|
|
||||||
|
|
||||||
import com.r35157.libs.codec.Base58Codec;
|
|
||||||
|
|
||||||
public class Base58CodecImpl implements Base58Codec {
|
|
||||||
|
|
||||||
public String encode(byte[] input) {
|
|
||||||
if (input.length == 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] copy = input.clone();
|
|
||||||
|
|
||||||
int zeros = 0;
|
|
||||||
while (zeros < copy.length && copy[zeros] == 0) {
|
|
||||||
zeros++;
|
|
||||||
}
|
|
||||||
|
|
||||||
char[] encoded = new char[copy.length * 2];
|
|
||||||
int outputStart = encoded.length;
|
|
||||||
|
|
||||||
int inputStart = zeros;
|
|
||||||
while (inputStart < copy.length) {
|
|
||||||
int remainder = divmod58(
|
|
||||||
copy,
|
|
||||||
inputStart
|
|
||||||
);
|
|
||||||
|
|
||||||
if (copy[inputStart] == 0) {
|
|
||||||
inputStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded[--outputStart] = ALPHABET[remainder];
|
|
||||||
}
|
|
||||||
|
|
||||||
while (outputStart < encoded.length && encoded[outputStart] == ENCODED_ZERO) {
|
|
||||||
outputStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (zeros-- > 0) {
|
|
||||||
encoded[--outputStart] = ENCODED_ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new String(
|
|
||||||
encoded,
|
|
||||||
outputStart,
|
|
||||||
encoded.length - outputStart
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int divmod58(byte[] number, int startAt) {
|
|
||||||
int remainder = 0;
|
|
||||||
|
|
||||||
for (int i = startAt; i < number.length; i++) {
|
|
||||||
int digit = number[i] & 0xff;
|
|
||||||
int temp = remainder * 256 + digit;
|
|
||||||
|
|
||||||
number[i] = (byte) (temp / 58);
|
|
||||||
remainder = temp % 58;
|
|
||||||
}
|
|
||||||
|
|
||||||
return remainder;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final char[] ALPHABET =
|
|
||||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
|
|
||||||
|
|
||||||
private static final char ENCODED_ZERO =
|
|
||||||
ALPHABET[0];
|
|
||||||
}
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
package com.r35157.libs.jupiter.perps.impl.anchoridl;
|
|
||||||
|
|
||||||
import com.r35157.libs.codec.Base58Codec;
|
|
||||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
|
||||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
|
||||||
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
class AnchorIdlJupiterPerpsCustodyDecoder {
|
|
||||||
|
|
||||||
ΩSPLMintAddressΩ decodeMint(
|
|
||||||
SolanaAccountInfo custodyAccountInfo
|
|
||||||
) {
|
|
||||||
byte[] data = Base64.getDecoder().decode(custodyAccountInfo.dataBase64());
|
|
||||||
|
|
||||||
if (data.length < MINT_OFFSET + PUBLIC_KEY_LENGTH) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Jupiter Perps custody account data is too short: " + data.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return readPublicKey(data, MINT_OFFSET);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ΩSPLMintAddressΩ readPublicKey(
|
|
||||||
byte[] data,
|
|
||||||
int offset
|
|
||||||
) {
|
|
||||||
byte[] publicKeyBytes = new byte[PUBLIC_KEY_LENGTH];
|
|
||||||
|
|
||||||
System.arraycopy(
|
|
||||||
data,
|
|
||||||
offset,
|
|
||||||
publicKeyBytes,
|
|
||||||
0,
|
|
||||||
PUBLIC_KEY_LENGTH
|
|
||||||
);
|
|
||||||
|
|
||||||
return base58.encode(publicKeyBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final int ANCHOR_DISCRIMINATOR_LENGTH = 8;
|
|
||||||
private static final int PUBLIC_KEY_LENGTH = 32;
|
|
||||||
|
|
||||||
private static final int MINT_OFFSET =
|
|
||||||
ANCHOR_DISCRIMINATOR_LENGTH
|
|
||||||
+ PUBLIC_KEY_LENGTH; // pool
|
|
||||||
|
|
||||||
private static final Base58Codec base58 = new Base58CodecImpl();
|
|
||||||
}
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
package com.r35157.libs.jupiter.perps.impl.anchoridl;
|
|
||||||
|
|
||||||
import com.r35157.libs.codec.Base58Codec;
|
|
||||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
|
|
||||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.ByteOrder;
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
class AnchorIdlJupiterPerpsPositionDecoder {
|
|
||||||
|
|
||||||
JupiterPerpsPosition decode(
|
|
||||||
ΩJupiterPerpsPositionAccountΩ positionAccount,
|
|
||||||
SolanaAccountInfo accountInfo,
|
|
||||||
ΩSPLMintAddressΩ tradedTokenMint
|
|
||||||
) {
|
|
||||||
byte[] data = Base64.getDecoder().decode(accountInfo.dataBase64());
|
|
||||||
|
|
||||||
if (data.length < PRICE_OFFSET + U64_LENGTH) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Jupiter Perps position account data is too short: " + data.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
JupiterPerpsPositionDirection direction =
|
|
||||||
decodeDirection(data[SIDE_OFFSET]);
|
|
||||||
|
|
||||||
long rawEntryPrice = ByteBuffer
|
|
||||||
.wrap(data, PRICE_OFFSET, U64_LENGTH)
|
|
||||||
.order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
.getLong();
|
|
||||||
|
|
||||||
ΩUSDCPriceΩ entryPrice = BigDecimal
|
|
||||||
.valueOf(rawEntryPrice)
|
|
||||||
.movePointLeft(6);
|
|
||||||
|
|
||||||
JupiterPerpsPosition pos = new JupiterPerpsPosition(
|
|
||||||
positionAccount,
|
|
||||||
entryPrice,
|
|
||||||
direction,
|
|
||||||
tradedTokenMint
|
|
||||||
);
|
|
||||||
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩSolanaAddressΩ decodeCustodyAccount(SolanaAccountInfo accountInfo) {
|
|
||||||
byte[] data = Base64.getDecoder().decode(accountInfo.dataBase64());
|
|
||||||
|
|
||||||
if (data.length < CUSTODY_OFFSET + PUBLIC_KEY_LENGTH) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Jupiter Perps position account data is too short: " + data.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return readPublicKey(
|
|
||||||
data,
|
|
||||||
CUSTODY_OFFSET
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JupiterPerpsPositionDirection decodeDirection(
|
|
||||||
byte rawSide
|
|
||||||
) {
|
|
||||||
// Jupiter Perps position side values are encoded as 1 = LONG, 2 = SHORT.
|
|
||||||
JupiterPerpsPositionDirection direction = switch (rawSide) {
|
|
||||||
case 1 -> JupiterPerpsPositionDirection.LONG;
|
|
||||||
case 2 -> JupiterPerpsPositionDirection.SHORT;
|
|
||||||
default -> throw new IllegalArgumentException(
|
|
||||||
"Unknown Jupiter Perps position side: " + rawSide
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ΩSolanaAddressΩ readPublicKey(
|
|
||||||
byte[] data,
|
|
||||||
int offset
|
|
||||||
) {
|
|
||||||
byte[] publicKeyBytes = new byte[PUBLIC_KEY_LENGTH];
|
|
||||||
|
|
||||||
System.arraycopy(
|
|
||||||
data,
|
|
||||||
offset,
|
|
||||||
publicKeyBytes,
|
|
||||||
0,
|
|
||||||
PUBLIC_KEY_LENGTH
|
|
||||||
);
|
|
||||||
|
|
||||||
return base58.encode(publicKeyBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final int ANCHOR_DISCRIMINATOR_LENGTH = 8;
|
|
||||||
private static final int PUBLIC_KEY_LENGTH = 32;
|
|
||||||
private static final int I64_LENGTH = 8;
|
|
||||||
private static final int SIDE_ENUM_LENGTH = 1;
|
|
||||||
private static final int U64_LENGTH = 8;
|
|
||||||
|
|
||||||
private static final int OWNER_OFFSET = ANCHOR_DISCRIMINATOR_LENGTH;
|
|
||||||
private static final int POOL_OFFSET = OWNER_OFFSET + PUBLIC_KEY_LENGTH;
|
|
||||||
private static final int CUSTODY_OFFSET = POOL_OFFSET + PUBLIC_KEY_LENGTH;
|
|
||||||
private static final int COLLATERAL_CUSTODY_OFFSET = CUSTODY_OFFSET + PUBLIC_KEY_LENGTH; // custody
|
|
||||||
private static final int OPEN_TIME_OFFSET = COLLATERAL_CUSTODY_OFFSET + PUBLIC_KEY_LENGTH;
|
|
||||||
private static final int UPDATE_TIME_OFFSET = OPEN_TIME_OFFSET + I64_LENGTH; // openTime
|
|
||||||
private static final int SIDE_OFFSET = UPDATE_TIME_OFFSET + I64_LENGTH;
|
|
||||||
private static final int PRICE_OFFSET = SIDE_OFFSET + SIDE_ENUM_LENGTH;
|
|
||||||
|
|
||||||
private static final Base58Codec base58 = new Base58CodecImpl();
|
|
||||||
}
|
|
||||||
-99
@@ -1,99 +0,0 @@
|
|||||||
package com.r35157.libs.jupiter.perps.impl.anchoridl;
|
|
||||||
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
|
||||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
|
||||||
import com.r35157.libs.solana.SolanaBlockChain;
|
|
||||||
import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
|
|
||||||
|
|
||||||
public AnchorIdlJupiterPerpsServiceImpl(
|
|
||||||
SolanaBlockChain solanaBlockChain
|
|
||||||
) {
|
|
||||||
this.solanaBlockChain = solanaBlockChain;
|
|
||||||
this.positionDecoder = new AnchorIdlJupiterPerpsPositionDecoder();
|
|
||||||
this.custodyDecoder = new AnchorIdlJupiterPerpsCustodyDecoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public JupiterPerpsPosition getPosition(ΩJupiterPerpsPositionAccountΩ positionAccount)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionAccount);
|
|
||||||
|
|
||||||
if (accountInfo == null) {
|
|
||||||
throw new IllegalArgumentException("Jupiter Perps position account does not exist: " + positionAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!JUPITER_PERPS_PROGRAM_ID.equals(accountInfo.owner())) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Account is not owned by Jupiter Perps program: " + positionAccount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩSPLMintAddressΩ tradedTokenMint = getTradedTokenMint(accountInfo);
|
|
||||||
JupiterPerpsPosition pos = positionDecoder.decode(positionAccount, accountInfo, tradedTokenMint);
|
|
||||||
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Set<JupiterPerpsPosition> getOpenPositions(ΩSolanaWalletIdΩ owner)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
Set<SolanaAccountInfo> accountInfos = solanaBlockChain.getProgramAccounts(
|
|
||||||
JUPITER_PERPS_PROGRAM_ID,
|
|
||||||
Set.of(new SolanaProgramAccountMemcmpFilter(
|
|
||||||
POSITION_OWNER_OFFSET,
|
|
||||||
owner
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
Set<JupiterPerpsPosition> positions = new HashSet<>();
|
|
||||||
|
|
||||||
for (SolanaAccountInfo accountInfo : accountInfos) {
|
|
||||||
ΩSolanaAddressΩ address = accountInfo.address();
|
|
||||||
ΩSolanaProgramIdΩ programId = accountInfo.owner();
|
|
||||||
|
|
||||||
if (!JUPITER_PERPS_PROGRAM_ID.equals(programId)) {
|
|
||||||
String errorMsg = "Account '" + address + "' is not owned by Jupiter Perps program '" +
|
|
||||||
programId + "'";
|
|
||||||
throw new IllegalArgumentException(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩSPLMintAddressΩ tradedTokenMint = getTradedTokenMint(accountInfo);
|
|
||||||
|
|
||||||
JupiterPerpsPosition position = positionDecoder.decode(address, accountInfo, tradedTokenMint);
|
|
||||||
positions.add(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Set.copyOf(positions);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ΩSPLMintAddressΩ getTradedTokenMint(SolanaAccountInfo positionAccountInfo)
|
|
||||||
throws IOException, InterruptedException
|
|
||||||
{
|
|
||||||
ΩSolanaAddressΩ custodyAccount = positionDecoder.decodeCustodyAccount(positionAccountInfo);
|
|
||||||
SolanaAccountInfo custodyAccountInfo = solanaBlockChain.getAccountInfo(custodyAccount);
|
|
||||||
|
|
||||||
if (custodyAccountInfo == null) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Jupiter Perps custody account does not exist: " + custodyAccount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ΩSPLMintAddressΩ mintAddress = custodyDecoder.decodeMint(custodyAccountInfo);
|
|
||||||
return mintAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final ΩJupiterPerpsProgramIdΩ JUPITER_PERPS_PROGRAM_ID =
|
|
||||||
"PERPHjGBqRHArX4DySjwM6UJHiR3sWAatqfdBS2qQJu";
|
|
||||||
private static final int POSITION_OWNER_OFFSET = 8;
|
|
||||||
|
|
||||||
private final SolanaBlockChain solanaBlockChain;
|
|
||||||
private final AnchorIdlJupiterPerpsPositionDecoder positionDecoder;
|
|
||||||
private final AnchorIdlJupiterPerpsCustodyDecoder custodyDecoder;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||||
|
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides read-oriented access to the Solana blockchain.
|
||||||
|
*
|
||||||
|
* <p>This interface exposes the Solana operations needed by higher-level
|
||||||
|
* integrations. It can fetch native SOL balances, SPL token holdings, NFT-like
|
||||||
|
* token holding candidates, account information and program derived addresses.</p>
|
||||||
|
*
|
||||||
|
* <p>The interface is intentionally generic and does not contain Raydium-specific
|
||||||
|
* logic. Higher-level integrations are expected to interpret Solana accounts,
|
||||||
|
* token holdings and derived addresses according to their own domain rules.</p>
|
||||||
|
*/
|
||||||
|
public interface SolanaBlockChain {
|
||||||
|
/**
|
||||||
|
* Fetches the native SOL balance for a Solana address.
|
||||||
|
*
|
||||||
|
* @param address the Solana address to inspect
|
||||||
|
* @return the native SOL balance for the address
|
||||||
|
* @throws IOException if the balance could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
|
||||||
|
*/
|
||||||
|
ΩSolanaAmountΩ getBalanceInSolana(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the native SOL balance for a Solana address in lamports.
|
||||||
|
*
|
||||||
|
* <p>Lamports are the smallest unit of native SOL.</p>
|
||||||
|
*
|
||||||
|
* @param address the Solana address to inspect
|
||||||
|
* @return the native SOL balance for the address in lamports
|
||||||
|
* @throws IOException if the balance could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
|
||||||
|
*/
|
||||||
|
ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches SPL token holdings owned by a Solana address for a specific token program.
|
||||||
|
*
|
||||||
|
* <p>The supplied token program decides which token accounts are inspected. For example,
|
||||||
|
* callers may query the original SPL Token Program or the Token-2022 Program depending
|
||||||
|
* on which token accounts they need to discover.</p>
|
||||||
|
*
|
||||||
|
* @param ownerAddress the Solana owner address whose token holdings should be inspected
|
||||||
|
* @param splProgramId the SPL token program to query
|
||||||
|
* @return a map of SPL mint addresses to token holding information
|
||||||
|
* @throws IOException if the token holdings could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching token holdings
|
||||||
|
*/
|
||||||
|
Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
SolanaSPLTokenProgram splProgramId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches NFT-like token mint address candidates owned by a Solana address for a specific token program.
|
||||||
|
*
|
||||||
|
* <p>This method identifies token holdings that look like NFTs within the supplied token
|
||||||
|
* program. A returned address is only a candidate. Higher-level integrations are responsible
|
||||||
|
* for deciding whether the returned address has domain-specific meaning.</p>
|
||||||
|
*
|
||||||
|
* // TODO This method currently identifies candidates from the owner's token holdings only.
|
||||||
|
* // A token with zero decimals and an owner balance of one is not guaranteed to be a real NFT,
|
||||||
|
* // because the mint's total supply may still be greater than one. A future implementation
|
||||||
|
* // should verify the mint supply, for example by using Solana getTokenSupply, before treating
|
||||||
|
* // the result as a confirmed NFT.
|
||||||
|
*
|
||||||
|
* @param ownerAddress the Solana owner address whose NFT-like holdings should be inspected
|
||||||
|
* @param splProgram the SPL token program to query
|
||||||
|
* @return the NFT-like Solana mint address candidates owned by the address
|
||||||
|
* @throws IOException if the NFT candidate addresses could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching NFT candidate addresses
|
||||||
|
*/
|
||||||
|
Set<ΩSolanaNFTAddressΩ> getSolanaNFTCandidateAddresses(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
SolanaSPLTokenProgram splProgram
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a Solana program derived address for a program id and a set of seeds.
|
||||||
|
*
|
||||||
|
* <p>The seeds describe the logical inputs used to derive the address. The implementation
|
||||||
|
* is responsible for converting each seed into the byte representation required by Solana.</p>
|
||||||
|
*
|
||||||
|
* @param programId the Solana program id used to derive the address
|
||||||
|
* @param seeds the seeds used when deriving the program address
|
||||||
|
* @return the derived Solana address together with its bump value
|
||||||
|
*/
|
||||||
|
SolanaProgramDerivedAddress findProgramAddress(
|
||||||
|
ΩSolanaProgramIdΩ programId,
|
||||||
|
List<SolanaProgramAddressSeed> seeds
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches account information for a Solana account address.
|
||||||
|
*
|
||||||
|
* <p>If the account does not exist, this method returns {@code null}. If the account exists,
|
||||||
|
* the returned value contains the account address, the owning Solana program id and the
|
||||||
|
* account data encoded as Base64.</p>
|
||||||
|
*
|
||||||
|
* @param accountAddress the Solana account address to inspect
|
||||||
|
* @return account information, or {@code null} if the account does not exist
|
||||||
|
* @throws IOException if the account information could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching account information
|
||||||
|
*/
|
||||||
|
SolanaAccountInfo getAccountInfo(ΩSolanaAddressΩ accountAddress)
|
||||||
|
throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a raw 32-byte Solana address into its textual Solana address representation.
|
||||||
|
*
|
||||||
|
* <p>This method is intended for callers that have obtained raw Solana address bytes from
|
||||||
|
* account data and need the normal string representation used elsewhere in the API.</p>
|
||||||
|
*
|
||||||
|
* @param addressBytes the raw 32-byte Solana address
|
||||||
|
* @return the encoded Solana address
|
||||||
|
* @throws IllegalArgumentException if the supplied byte array is not a valid Solana address length
|
||||||
|
*/
|
||||||
|
ΩSolanaAddressΩ encodeSolanaAddress(byte[] addressBytes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the total supply of an SPL token mint for a specific token program.
|
||||||
|
*
|
||||||
|
* <p>The supplied token program identifies which SPL token program owns the mint,
|
||||||
|
* for example the original SPL Token Program or the Token-2022 Program.</p>
|
||||||
|
*
|
||||||
|
* @param mintAddress the SPL mint address whose supply should be fetched
|
||||||
|
* @param splProgram the SPL token program to query
|
||||||
|
* @return the SPL token supply for the mint
|
||||||
|
* @throws IOException if the token supply could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching token supply
|
||||||
|
*/
|
||||||
|
SPLTokenSupply getSPLTokenSupply(
|
||||||
|
ΩSPLMintAddressΩ mintAddress,
|
||||||
|
SolanaSPLTokenProgram splProgram
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
}
|
||||||
@@ -331,113 +331,6 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public Set<SolanaAccountInfo> getProgramAccounts(
|
|
||||||
ΩSolanaProgramIdΩ programId,
|
|
||||||
Set<SolanaProgramAccountMemcmpFilter> filters
|
|
||||||
) throws IOException, InterruptedException {
|
|
||||||
String jsonBody = createGetProgramAccountsBody(programId, filters);
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(RPC_URL))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = sendThrottled(request);
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("RPC call failed: HTTP " + response.statusCode() + "\n" + response.body());
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode root = objectMapper.readTree(response.body());
|
|
||||||
|
|
||||||
if (root.has("error")) {
|
|
||||||
throw new IOException("Solana RPC error: " + root.get("error").toPrettyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode result = root.path("result");
|
|
||||||
|
|
||||||
if (!result.isArray()) {
|
|
||||||
throw new IOException("getProgramAccounts response did not contain result array!");
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<SolanaAccountInfo> accountInfos = new HashSet<>();
|
|
||||||
|
|
||||||
for (JsonNode accountNode : result) {
|
|
||||||
JsonNode pubkeyNode = accountNode.path("pubkey");
|
|
||||||
JsonNode account = accountNode.path("account");
|
|
||||||
|
|
||||||
if (!pubkeyNode.isTextual()) {
|
|
||||||
throw new IOException("getProgramAccounts response contained account without textual pubkey!");
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode ownerNode = account.path("owner");
|
|
||||||
if (!ownerNode.isTextual()) {
|
|
||||||
throw new IOException("getProgramAccounts response contained account without textual owner!");
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode dataNode = account.path("data");
|
|
||||||
if (!dataNode.isArray() || dataNode.isEmpty() || !dataNode.get(0).isTextual()) {
|
|
||||||
throw new IOException("getProgramAccounts response contained account without base64 data!");
|
|
||||||
}
|
|
||||||
|
|
||||||
accountInfos.add(new SolanaAccountInfo(
|
|
||||||
pubkeyNode.asText(),
|
|
||||||
ownerNode.asText(),
|
|
||||||
dataNode.get(0).asText()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Set.copyOf(accountInfos);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String createGetProgramAccountsBody(
|
|
||||||
ΩSolanaProgramIdΩ programId,
|
|
||||||
Set<SolanaProgramAccountMemcmpFilter> filters
|
|
||||||
) throws IOException {
|
|
||||||
StringBuilder filtersJson = new StringBuilder();
|
|
||||||
|
|
||||||
boolean first = true;
|
|
||||||
for (SolanaProgramAccountMemcmpFilter filter : filters) {
|
|
||||||
if (!first) {
|
|
||||||
filtersJson.append(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
filtersJson.append("""
|
|
||||||
{
|
|
||||||
"memcmp": {
|
|
||||||
"offset": %d,
|
|
||||||
"bytes": "%s"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".formatted(
|
|
||||||
filter.offset(),
|
|
||||||
filter.bytes()
|
|
||||||
));
|
|
||||||
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return """
|
|
||||||
{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "getProgramAccounts",
|
|
||||||
"params": [
|
|
||||||
"%s",
|
|
||||||
{
|
|
||||||
"commitment": "finalized",
|
|
||||||
"encoding": "base64",
|
|
||||||
"filters": [
|
|
||||||
%s
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""".formatted(programId, filtersJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized HttpResponse<String> sendThrottled(HttpRequest request) throws IOException, InterruptedException {
|
private synchronized HttpResponse<String> sendThrottled(HttpRequest request) throws IOException, InterruptedException {
|
||||||
waitBeforeRemoteCall();
|
waitBeforeRemoteCall();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package com.r35157.nenjim.hubd.impl.ref;
|
package com.r35157.nenjim.hubd.impl.ref;
|
||||||
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
|
|
||||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
|
||||||
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
|
|
||||||
import com.r35157.libs.solana.SolanaBlockChain;
|
|
||||||
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
|
|
||||||
import com.r35157.nenjim.hubd.ctx.Context;
|
import com.r35157.nenjim.hubd.ctx.Context;
|
||||||
import com.r35157.nenjim.hubd.NenjimHub;
|
import com.r35157.nenjim.hubd.NenjimHub;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -17,21 +12,12 @@ import com.r35157.nenjim.hubd.journal.JournalManager;
|
|||||||
import com.r35157.nenjim.hubd.impl.ref.JournalManagerImpl;
|
import com.r35157.nenjim.hubd.impl.ref.JournalManagerImpl;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
|
|
||||||
// TODO: Consider if we really need a Main class or we just need to move the main method to NenjimHubImpl?
|
// TODO: Consider if we really need a Main class or we just need to move the main method to NenjimHubImpl?
|
||||||
static void main(String[] args) throws Exception {
|
static void main(String[] args) throws Exception {
|
||||||
NenjimHubImpl nenjimHub = new NenjimHubImpl();
|
NenjimHubImpl nenjimHub = new NenjimHubImpl();
|
||||||
|
|
||||||
/*
|
|
||||||
SolanaBlockChain sbc = new SolanaBlockChainImpl();
|
|
||||||
JupiterPerpsService jupiter = new AnchorIdlJupiterPerpsServiceImpl(sbc);
|
|
||||||
ΩSolanaWalletIdΩ walletId = "vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf";
|
|
||||||
Set<JupiterPerpsPosition> positions = jupiter.getOpenPositions(walletId);
|
|
||||||
int a=0;
|
|
||||||
*/
|
|
||||||
nenjimHub.awaitShutdown();
|
nenjimHub.awaitShutdown();
|
||||||
|
|
||||||
/* try {
|
/* try {
|
||||||
|
|||||||
Reference in New Issue
Block a user