63 Commits

Author SHA256 Message Date
minimons fc1598ec4a X: Add new JupiterPerpsPositionInfo class 2026-07-04 19:07:14 +02:00
minimons b989ab441d X: Add a lot of new fields to 'JupiterPerpsPostion' 2026-07-04 17:07:25 +02:00
minimons 24a05ff382 X: Move non-conflicting interface classes from API project 2026-07-04 13:35:02 +02:00
minimons 0931dbe99a X: Update publishGitHub script 2026-07-03 12:06:36 +02:00
minimons 932b2589e4 21: Decode 'sizeUsd' 2026-06-29 17:04:05 +02:00
minimons 3e61f35c98 21: Add sizeUsd to JupiterPerpsPosition 2026-06-29 17:00:51 +02:00
minimons 3a739bde57 19: Decode 'collateralUsd' 2026-06-29 15:30:10 +02:00
minimons 244a6f19fe 20: Add actual refreshing logic to loop 2026-06-28 18:40:05 +02:00
minimons ff4f5a0356 20: Adding refresher file filtering 2026-06-28 18:33:20 +02:00
minimons f86652df7d 20: More refresher logic 2026-06-28 18:30:58 +02:00
minimons 5e5f8ebd51 20: Prepare background refresher thread 2026-06-28 18:19:20 +02:00
minimons d216c06ba1 20: Add watcher wire-up 2026-06-28 18:14:13 +02:00
minimons c1cbaf0c52 20: Add new JupiterPerpsEntryPriceVariableRefreshWatcher class 2026-06-28 18:06:50 +02:00
minimons 76446f8b73 20: Clean-up before update 2026-06-28 11:48:08 +02:00
minimons dac1b62628 20: Update variable map with new data 2026-06-28 11:45:04 +02:00
minimons 0aff69429a 20: Map mint addresses to asset symbols 2026-06-28 11:40:13 +02:00
minimons 384fad01bf 20: Fetch open Perps positions JupiterPerpsEntryPriceVariableRefresher 2026-06-28 11:36:05 +02:00
minimons 520a0bcd92 20: Inject JupiterPerpsService into JupiterPerpsEntryPriceVariableRefresher 2026-06-28 11:30:03 +02:00
minimons 896b5235d0 20: Add new JupiterPerpsEntryPriceVariableRefresher class 2026-06-28 11:08:14 +02:00
minimons 403d7af6e9 20: CompositeAlarmAction compilation fix 2026-06-27 23:32:51 +02:00
minimons 90fd1694fb 20: Fix compilatoin of PushoverAlarmAction 2026-06-27 23:27:47 +02:00
minimons 1fb712b61d 20: Fix compilation of ConsoleAlarmAction 2026-06-27 23:25:04 +02:00
minimons 1355422597 20: Use resolved node in PriceAlarm trigger 2026-06-27 21:12:26 +02:00
minimons c6ba8dc009 20: Change AlarmAction to use resolved data 2026-06-27 21:07:17 +02:00
minimons a5e5470c2b 20: Create new ResolvedPriceAlarm and resolve note 2026-06-27 21:03:21 +02:00
minimons 2953b07609 20: Switch variables with brackets 2026-06-27 20:54:02 +02:00
minimons 0d14bb3538 20: Store variables keys without brackets 2026-06-27 20:48:28 +02:00
minimons 41bd1898c2 20: Change AlarmConfiguration to use ConcurrentHashMap 2026-06-27 20:43:09 +02:00
minimons 873086eeaf 20: Handle missing variables gracefully 2026-06-27 20:35:10 +02:00
minimons d33289d8ce 20: Wire-up resolver 2026-06-27 20:29:24 +02:00
minimons b0eb4e6d93 20: Make AlarmConfigurationParser return AlarmConfiguration 2026-06-27 20:20:03 +02:00
minimons 2d359f59e4 20: Add new AlarmConfiguration class 2026-06-27 20:15:41 +02:00
minimons 3fc769a687 20: Initialize AlarmVariableResolver 2026-06-27 20:12:10 +02:00
minimons 4edd5af9d7 20: Inject AlarmVariableResolver 2026-06-27 20:07:14 +02:00
minimons 7a06d87f4a 20: Use resolver in PriceAlarm 2026-06-27 20:01:47 +02:00
minimons 05b0b6cb6a 20: Add AlarmVariableResolver class 2026-06-27 19:57:02 +02:00
minimons 4a5450b4b0 20: Do not resolve while reading config 2026-06-27 19:54:12 +02:00
minimons 1410c959e6 20: Fix target price compilation issues 2026-06-27 19:49:36 +02:00
minimons 6e75cf3725 20: Update PriceAlarm to use parser 2026-06-27 19:40:46 +02:00
minimons c8c443fdc8 20: Move target price parsing to new AlarmTargetParser class 2026-06-27 19:27:25 +02:00
minimons 545b79de3b 20: Do not resolve target price 2026-06-27 19:14:01 +02:00
minimons 97ecb7572a 20: Update PriceAlarmDefinition to support expressions 2026-06-27 19:05:59 +02:00
minimons 940e1ece94 20: Change config file 2026-06-27 19:05:59 +02:00
minimons a212791ba9 X: Add publishGitHub 2026-06-27 19:05:59 +02:00
minimons d9f773c1f9 X: Updated for some manual testing in Main 2026-06-26 22:27:57 +02:00
minimons 2d68b83cf4 17: Add mint address field in JupiterPerpsPosition 2026-06-26 22:27:57 +02:00
minimons 7e6e9978a2 18: Add a codec class for Base58 2026-06-26 20:55:37 +02:00
minimons 35052a045a X: Rename AnchorIdlJupiterPerpsPositionServiceImpl 2026-06-26 19:26:24 +02:00
minimons ce74550825 X: Rename JupiterPerpsPositionService 2026-06-26 19:26:24 +02:00
minimons a5c9c78fdd X: Move JupiterPerpsPositionService to API instead of implementation 2026-06-26 19:26:24 +02:00
minimons c4f67fa0fc 16: Add 'side' to AnchorIdlJupiterPerpsPositionDecoder 2026-06-26 19:26:24 +02:00
minimons c8ca946fcc X: Move JupiterPerpsPosition to API from implementation 2026-06-26 18:08:26 +02:00
minimons 760ba0e823 X: Add some manuel testing code 2026-06-26 17:02:41 +02:00
minimons 1cb78fa3db 15: Add getOpenPositions() to Jupiter Perps API 2026-06-26 17:02:40 +02:00
minimons 09bd70b348 14: Add getProgramAccounts(...) API method 2026-06-26 14:14:06 +02:00
minimons cd573b1ce0 X: Some dependency cleanup 2026-06-25 19:08:52 +02:00
minimons 96d1289730 13: Create initial Jupiter Perps position API 2026-06-25 15:30:30 +02:00
minimons 539068978f 12: Add grace period support for persistent alarms 2026-06-23 18:26:23 +02:00
minimons 48f087dc6e 11: Remove temporary SelfTest code 2026-06-23 12:57:03 +02:00
minimons be73fe9ee6 10: Support constants in alarm configuration 2026-06-23 12:19:48 +02:00
minimons 7dcfb9977c 7: Make JupiterPerpsAlarm send the note on the push notification 2026-06-23 08:15:25 +02:00
minimons 0a6a7b4a3b 9: Support percentage expressions in alarm targets 2026-06-21 18:48:54 +02:00
minimons b74e4ad286 8: Add 'example' to configuration files 2026-06-21 17:16:27 +02:00
41 changed files with 1410 additions and 201 deletions
+3 -1
View File
@@ -5,4 +5,6 @@
build
logs/*.log
logs/*.log.gz
logs/*.log.gz
conf/*.conf
conf/*.xml
+1
View File
@@ -44,6 +44,7 @@ dependencies {
runtimeOnly("org.apache.logging.log4j:log4j-core: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.fazecast:jSerialComm:2.11.4")
implementation("com.google.code.gson:gson:2.14.0")
-4
View File
@@ -1,4 +0,0 @@
# ID Asset Direction Target TRIGGER SEVERITY NOTE
################################################################################################################
1 SOL BELOW 170.0 ONETIME INFO "EMERGENCY: Risiko for Perps Solana long LIKVIDERING!"
2 SOL BELOW 71.4 ONETIME CRITICAL "CRITICAL: Risiko for Solana Raydium LÅN LIKVIDERING!"
+56
View File
@@ -0,0 +1,56 @@
# General configurations
#####################################################
{{JUPITER_PERPS_WALLET}} 8abcYourWalletAddressHere
# Constant Name Value
####################################
{{SOL_LONG_ENTRY_PRICE}} 73.67
{{SOL_LONG_LIQ_PRICE}} 70.28
{{SOL_SHORT_ENTRY_PRICE}} 70.47
{{SOL_SHORT_LIQ_PRICE}} 75.01
{{ETH_LONG_ENTRY_PRICE}} 1589.63
{{ETH_LONG_LIQ_PRICE}} 1461.11
{{ETH_SHORT_ENTRY_PRICE}} 1545.29
{{ETH_SHORT_LIQ_PRICE}} 1698.42
{{BTC_LONG_ENTRY_PRICE}} 61236.03
{{BTC_LONG_LIQ_PRICE}} 40984.70
{{BTC_SHORT_ENTRY_PRICE}} 59451.94
{{BTC_SHORT_LIQ_PRICE}} 65084.53
# Id Asset Direction Target Trigger Severity Note
###############################################################################################################################
# SOL SHORT
1 SOL ABOVE {{SOL_SHORT_LIQ_PRICE}}-1.25% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko SOL Short!"
2 SOL ABOVE {{SOL_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short SOL now"
3 SOL BELOW {{SOL_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close SOL Short"
# SOL LONG
4 SOL BELOW {{SOL_LONG_LIQ_PRICE}}+1.25% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko SOL Long!"
5 SOL BELOW {{SOL_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long SOL now"
6 SOL ABOVE {{SOL_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close SOL Long"
# ETH SHORT
7 ETH ABOVE {{ETH_SHORT_LIQ_PRICE}}-1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko ETH Short!"
8 ETH ABOVE {{ETH_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short ETH now"
9 ETH BELOW {{ETH_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close ETH Short"
# ETH LONG
10 ETH BELOW {{ETH_LONG_LIQ_PRICE}}+1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko ETH Long!"
11 ETH BELOW {{ETH_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long ETH now"
12 ETH ABOVE {{ETH_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close ETH Long"
# BTC SHORT
13 BTC ABOVE {{BTC_SHORT_LIQ_PRICE}}-1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko BTC Short!"
14 BTC ABOVE {{BTC_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short BTC now"
15 BTC BELOW {{BTC_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close BTC Short"
# BTC LONG
16 BTC BELOW {{BTC_LONG_LIQ_PRICE}}+1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko BTC Long!"
17 BTC BELOW {{BTC_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long BTC now"
18 BTC ABOVE {{BTC_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close BTC Long"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <github-snapshot-branch>" >&2
exit 1
fi
BRANCH="$1"
SOURCE="$HOME/projects/com_r35157_nenjim-hubd-impl_ref"
TARGET="$HOME/projects/com_r35157_nenjim-hubd-impl_ref_github_snapshot"
cd "$TARGET"
git fetch origin
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git switch "$BRANCH"
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
git switch --track "origin/$BRANCH"
else
git switch --create "$BRANCH"
fi
rsync -a --delete \
--exclude '.git' \
--exclude 'conf/*.conf' \
--exclude 'conf/*.xml' \
"$SOURCE/" \
"$TARGET/"
git add -A
if git diff --cached --quiet; then
echo "No snapshot changes to publish on branch '$BRANCH'."
exit 0
fi
git commit -m "Mirror snapshot"
git push -u origin "$BRANCH"
@@ -2,5 +2,5 @@ package com.r35157.jupiterperpsalarm.impl.ref;
@FunctionalInterface
public interface AlarmAction {
void trigger(OraclePrice price, PriceAlarmDefinition alarm);
void trigger(OraclePrice price, ResolvedPriceAlarm alarm);
}
@@ -0,0 +1,19 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public record AlarmConfiguration(
List<PriceAlarmDefinition> definitions,
Map<String, String> variables
) {
public AlarmConfiguration {
Objects.requireNonNull(definitions, "definitions");
Objects.requireNonNull(variables, "variables");
definitions = List.copyOf(definitions);
variables = new ConcurrentHashMap<>(variables);
}
}
@@ -3,28 +3,31 @@ package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.*;
public final class AlarmConfigurationParser {
public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
public static AlarmConfiguration parse(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
List<PriceAlarmDefinition> alarms = new ArrayList<>();
Map<String, String> constants = new LinkedHashMap<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = lines.get(lineNumber - 1);
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
if(trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
try {
alarms.add(parseLine(line));
if(isConstantDefinition(trimmed)) {
parseConstantDefinition(constants, line);
} else {
alarms.add(parseLine(line));
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
@@ -37,7 +40,7 @@ public final class AlarmConfigurationParser {
throw new IllegalArgumentException("No alarms found in " + path);
}
return List.copyOf(alarms);
return new AlarmConfiguration(alarms, constants);
}
static PriceAlarmDefinition parseLine(String line) {
@@ -53,10 +56,10 @@ public final class AlarmConfigurationParser {
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
);
BigDecimal target = new BigDecimal(cursor.nextToken("target"));
String targetExpression = cursor.nextToken("target");
AlarmTrigger trigger = AlarmTrigger.valueOf(
cursor.nextToken("trigger").toUpperCase(Locale.ROOT)
TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger")
);
AlarmSeverity severity = AlarmSeverity.valueOf(
@@ -76,13 +79,58 @@ public final class AlarmConfigurationParser {
id,
asset,
direction,
target,
trigger,
targetExpression,
triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(),
severity,
note
);
}
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 final class Cursor {
private Cursor(String line) {
this.line = line;
@@ -167,6 +215,76 @@ public final class AlarmConfigurationParser {
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);
constants.put(parseVariableName(name), value);
}
private static String parseVariableName(String variableName) {
if (!variableName.startsWith("{{") || !variableName.endsWith("}}")) {
throw new IllegalArgumentException("Invalid variable name: " + variableName);
}
String parsedVariableName = variableName.substring(2, variableName.length() - 2);
if (parsedVariableName.isBlank()) {
throw new IllegalArgumentException("Variable name cannot be blank: " + variableName);
}
return parsedVariableName;
}
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() {
}
}
@@ -0,0 +1,60 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public final class AlarmTargetParser {
private AlarmTargetParser() {
}
public static BigDecimal parse(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 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 void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Target must be zero or positive: " + originalTargetStr
);
}
}
}
@@ -0,0 +1,30 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.Map;
import java.util.Objects;
public final class AlarmVariableResolver {
public AlarmVariableResolver(Map<String, String> variables) {
this.variables = Objects.requireNonNull(variables, "variables");
}
public String resolve(String text) {
Objects.requireNonNull(text, "text");
String resolvedText = text;
for (Map.Entry<String, String> entry : variables.entrySet()) {
String placeholder = "{{" + entry.getKey() + "}}";
resolvedText = resolvedText.replace(placeholder, entry.getValue());
}
if (resolvedText.contains("{{") || resolvedText.contains("}}")) {
throw new IllegalArgumentException("Unresolved variable in text: " + text);
}
return resolvedText;
}
private final Map<String, String> variables;
}
@@ -12,6 +12,7 @@ public final class AssetPriceAlarmMonitor {
public AssetPriceAlarmMonitor(
JupiterPerpsAsset asset,
List<PriceAlarmDefinition> definitions,
AlarmVariableResolver variableResolver,
AlarmAction action
) {
this.asset = asset;
@@ -22,7 +23,7 @@ public final class AssetPriceAlarmMonitor {
"Alarm asset " + definition.asset() + " does not match monitor " + asset
);
}
return new PriceAlarm(definition, action);
return new PriceAlarm(definition, variableResolver, action);
})
.toList();
@@ -9,7 +9,7 @@ public final class CompositeAlarmAction implements AlarmAction {
}
@Override
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
for (AlarmAction action : actions) {
try {
action.trigger(price, alarm);
@@ -2,7 +2,7 @@ package com.r35157.jupiterperpsalarm.impl.ref;
public final class ConsoleAlarmAction implements AlarmAction {
@Override
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
System.err.println();
System.err.println("============================================================");
System.err.printf(
@@ -48,25 +48,6 @@ 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) {
byte[] positiveBigEndian = new byte[Long.BYTES + 1];
for (int index = 0; index < Long.BYTES; index++) {
@@ -1,5 +1,10 @@
package com.r35157.jupiterperpsalarm.impl.ref;
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 java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -13,6 +18,7 @@ public final class JupiterPerpsAlarmImpl {
public static void main(String[] args) throws Exception {
Config config;
try {
config = Config.parse(args, System.getenv());
} catch (IllegalArgumentException exception) {
@@ -21,20 +27,35 @@ public final class JupiterPerpsAlarmImpl {
throw new IllegalStateException(errMsg, exception);
}
if (config.selfTest()) {
SelfTest.run();
System.out.println("All self-tests passed.");
return;
}
List<PriceAlarmDefinition> definitions;
AlarmConfiguration alarmConfiguration;
try {
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
alarmConfiguration = AlarmConfigurationParser.parse(config.alarmConfiguration());
definitions = alarmConfiguration.definitions();
} catch (Exception exception) {
String errMsg = "Could not load alarm configuration: " + exception.getMessage() + "!";
throw new IllegalStateException(errMsg, exception);
}
AlarmVariableResolver variableResolver = new AlarmVariableResolver(
alarmConfiguration.variables()
);
SolanaBlockChain solanaBlockChain = new SolanaBlockChainImpl();
JupiterPerpsService jupiterPerpsService = new AnchorIdlJupiterPerpsServiceImpl(solanaBlockChain);
JupiterPerpsEntryPriceVariableRefresher entryPriceVariableRefresher =
new JupiterPerpsEntryPriceVariableRefresher(alarmConfiguration.variables(), jupiterPerpsService);
entryPriceVariableRefresher.refresh();
JupiterPerpsEntryPriceVariableRefreshWatcher entryPriceVariableRefreshWatcher =
new JupiterPerpsEntryPriceVariableRefreshWatcher(
config.alarmConfiguration().getParent(),
entryPriceVariableRefresher
);
entryPriceVariableRefreshWatcher.start();
List<AlarmAction> actions = new ArrayList<>();
actions.add(new ConsoleAlarmAction());
@@ -58,9 +79,10 @@ public final class JupiterPerpsAlarmImpl {
Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>(
JupiterPerpsAsset.class
);
definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put(
asset,
new AssetPriceAlarmMonitor(asset, assetDefinitions, action)
new AssetPriceAlarmMonitor(asset, assetDefinitions, variableResolver, action)
));
List<OracleWebSocketClient> clients = new ArrayList<>();
@@ -91,7 +113,7 @@ public final class JupiterPerpsAlarmImpl {
assetDefinitions.forEach(definition -> System.out.printf(
" %s %s USD, %s, severity=%s%n",
definition.direction(),
definition.target().toPlainString(),
definition.targetExpression(),
definition.trigger(),
definition.severity()
));
@@ -126,7 +148,6 @@ public final class JupiterPerpsAlarmImpl {
Options:
--config=<path> Default: price-alarms.conf
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
--self-test Test parser, alarm semantics and oracle decoder
Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path
@@ -140,11 +161,9 @@ public final class JupiterPerpsAlarmImpl {
Path alarmConfiguration,
List<URI> webSocketEndpoints,
String pushoverToken,
String pushoverUserKey,
boolean selfTest
String pushoverUserKey
) {
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)
.filter(argument -> argument.startsWith("--") && argument.contains("="))
.map(argument -> argument.substring(2).split("=", 2))
@@ -184,8 +203,7 @@ public final class JupiterPerpsAlarmImpl {
Path.of(configurationText),
endpoints,
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
blankToNull(environment.get("PUSHOVER_USER_KEY")),
selfTest
blankToNull(environment.get("PUSHOVER_USER_KEY"))
);
}
@@ -0,0 +1,92 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.nio.file.*;
import java.util.Objects;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.FileSystems;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
public final class JupiterPerpsEntryPriceVariableRefreshWatcher {
public JupiterPerpsEntryPriceVariableRefreshWatcher(
Path confDirectory,
JupiterPerpsEntryPriceVariableRefresher refresher
) {
this.confDirectory = Objects.requireNonNull(confDirectory, "confDirectory");
this.refresher = Objects.requireNonNull(refresher, "refresher");
}
public void start() {
Thread thread = new Thread(
this::run,
"jupiter-perps-entry-price-variable-refresh-watcher"
);
thread.setDaemon(true);
thread.start();
}
private void run() {
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
confDirectory.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE
);
System.out.println(
"Jupiter Perps entry price variable refresh watcher started for directory: "
+ confDirectory
);
while (true) {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
Path path = (Path) event.context();
if (!REFRESH_TRIGGER_FILE_NAME.equals(path.getFileName().toString())) {
continue;
}
Path triggerFile = confDirectory.resolve(path);
System.out.println("Refresh trigger file detected: " + triggerFile);
try {
refresher.refresh();
} finally {
Files.deleteIfExists(triggerFile);
}
}
if (!key.reset()) {
System.err.println(
"Jupiter Perps entry price variable refresh watcher stopped: watch key is no longer valid"
);
return;
}
}
} catch (IOException exception) {
System.err.println(
"Could not start Jupiter Perps entry price variable refresh watcher: "
+ exception.getMessage()
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
System.err.println(
"Jupiter Perps entry price variable refresh watcher interrupted"
);
}
}
private static final String REFRESH_TRIGGER_FILE_NAME =
"jupiter-perps-alarm-var.refresh";
private final Path confDirectory;
private final JupiterPerpsEntryPriceVariableRefresher refresher;
}
@@ -0,0 +1,86 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public final class JupiterPerpsEntryPriceVariableRefresher {
public JupiterPerpsEntryPriceVariableRefresher(
Map<String, String> variables,
JupiterPerpsService jupiterPerpsService
) {
this.variables = Objects.requireNonNull(variables, "variables");
this.jupiterPerpsService = Objects.requireNonNull(jupiterPerpsService, "jupiterPerpsService");
}
public void refresh() {
ΩSolanaWalletIdΩ wallet = variables.get("JUPITER_PERPS_WALLET");
if (wallet == null || wallet.isBlank()) {
System.err.println("Cannot refresh Jupiter Perps entry price variables: JUPITER_PERPS_WALLET is not configured");
return;
}
try {
Set<JupiterPerpsPosition> positions = jupiterPerpsService.getOpenPositions(wallet);
System.out.println(
"Fetched " + positions.size()
+ " open Jupiter Perps positions for wallet: " + wallet
);
removeEntryPriceVariables();
for (JupiterPerpsPosition position : positions) {
String variableName = createEntryPriceVariableName(position);
System.out.println(
"Jupiter Perps position maps to variable "
+ variableName
+ " = "
+ position.entryPrice()
);
variables.put(variableName, position.entryPrice().toPlainString());
}
} catch (IOException | InterruptedException exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
System.err.println(
"Could not refresh Jupiter Perps entry price variables: "
+ exception.getMessage()
);
}
}
private void removeEntryPriceVariables() {
variables.remove("SOL_LONG_ENTRY_PRICE");
variables.remove("SOL_SHORT_ENTRY_PRICE");
variables.remove("BTC_LONG_ENTRY_PRICE");
variables.remove("BTC_SHORT_ENTRY_PRICE");
variables.remove("ETH_LONG_ENTRY_PRICE");
variables.remove("ETH_SHORT_ENTRY_PRICE");
}
private static String createEntryPriceVariableName(JupiterPerpsPosition position) {
String asset = switch (position.tradedTokenMint()) {
case "So11111111111111111111111111111111111111112" -> "SOL";
case "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh" -> "BTC";
case "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs" -> "ETH";
default -> throw new IllegalArgumentException(
"Unsupported Jupiter Perps traded token mint: " + position.tradedTokenMint()
);
};
return asset + "_" + position.direction() + "_ENTRY_PRICE";
}
private final Map<String, String> variables;
private final JupiterPerpsService jupiterPerpsService;
}
@@ -1,9 +1,17 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.time.Instant;
public final class PriceAlarm {
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
public PriceAlarm(
PriceAlarmDefinition definition,
AlarmVariableResolver variableResolver,
AlarmAction action
) {
this.definition = definition;
this.variableResolver = variableResolver;
this.action = action;
}
@@ -14,9 +22,23 @@ public final class PriceAlarm {
);
}
BigDecimal target;
try {
target = AlarmTargetParser.parse(
variableResolver.resolve(definition.targetExpression())
);
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve target for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
boolean reached = definition.direction().reached(
price.priceUsd(),
definition.target()
target
);
boolean enteredTriggeredSide = previousReached == null
@@ -25,16 +47,28 @@ public final class PriceAlarm {
previousReached = reached;
if (!enteredTriggeredSide) {
if (!reached) {
previousReached = false;
return;
}
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) {
if (definition.trigger() == AlarmTrigger.ONETIME) {
if (!enteredTriggeredSide || triggerCount > 0) {
return;
}
trigger(price, target);
return;
}
triggerCount++;
action.trigger(price, definition);
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price, target);
}
return;
}
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
}
public PriceAlarmDefinition definition() {
@@ -45,9 +79,58 @@ public final class PriceAlarm {
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, BigDecimal target) {
triggerCount++;
lastTriggeredAt = Instant.now();
String note;
try {
note = variableResolver.resolve(definition.note());
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve note for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
definition.id(),
definition.asset(),
definition.direction(),
target,
definition.trigger(),
definition.triggerGracePeriod(),
definition.severity(),
note
);
action.trigger(price, resolvedAlarm);
}
private final PriceAlarmDefinition definition;
private final AlarmVariableResolver variableResolver;
private final AlarmAction action;
private Instant lastTriggeredAt;
private Boolean previousReached;
private long triggerCount;
}
@@ -2,27 +2,34 @@ package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
import java.util.Objects;
public record PriceAlarmDefinition(
int id,
JupiterPerpsAsset asset,
PriceDirection direction,
BigDecimal target,
String targetExpression,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(targetExpression, "targetExpression");
Objects.requireNonNull(trigger, "trigger");
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note");
if (target.signum() < 0) {
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
if (targetExpression.isBlank()) {
throw new IllegalArgumentException("Target expression cannot be blank");
}
if (triggerGracePeriod < 0) {
throw new IllegalArgumentException(
"Trigger grace period cannot be negative: " + triggerGracePeriod
);
}
}
}
@@ -18,25 +18,15 @@ public final class PushoverAlarmAction implements AlarmAction {
}
@Override
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
String title = "Jupiter Perps " + price.asset() + " 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()
);
String message = createMessage(price, alarm);
// 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) + "&" +
form("user", userKey) + "&" +
form("title", title) + "&" +
form("message", message) + "&" +
createPushoverSeverityParameters(alarm.severity());;
createPushoverSeverityParameters(alarm.severity());
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
.timeout(Duration.ofSeconds(15))
@@ -79,6 +69,21 @@ public final class PushoverAlarmAction implements AlarmAction {
};
}
private static String createMessage(OraclePrice price, ResolvedPriceAlarm 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 String applicationToken;
private final String userKey;
@@ -0,0 +1,16 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
public record ResolvedPriceAlarm(
int id,
JupiterPerpsAsset asset,
PriceDirection direction,
BigDecimal target,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
) {
}
@@ -1,121 +0,0 @@
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() {
}
}
@@ -0,0 +1,2 @@
package com.r35157.jupiterperpsalarm.impl.ref;
@@ -0,0 +1,5 @@
package com.r35157.libs.codec;
public interface Base58Codec {
String encode(byte[] input);
}
@@ -0,0 +1,70 @@
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];
}
@@ -0,0 +1,47 @@
package com.r35157.libs.jupiter.perps;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.math.BigDecimal;
/**
* Represents a Jupiter Perps position.
*
* <p>A Jupiter Perps position is represented on-chain by a Solana account owned by
* the Jupiter Perps program. This record contains the public API view of such a
* position.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @param tradedTokenMint the mint address of the token being traded
* @param direction whether the position is long or short
* @param value the amount the position is worth if closed now
* @param size the leveraged amount used to open the contracts
* @param pnl the amount in usd in profit or loss on this position
* @param pnlPercent the profit and loss represented as a percentage
* @param leverage TODO
* @param entryPrice the entry price of the position, denominated in USDC
* @param marketPrice the current spot price of the token
* @param collateral the amount of USD representing the collateral for this position
* @param totalFees the total amount of fees (TODO: is that including pending/due fees)
* @param borrowFeesDue the amount of USD that is currently outstanding
* @param closeFeePending the fee in USD for closing the account (TODO: multiple accounts - when adding collateral?)
* @param accountRent refundable amount locked for Solana account renting
*/
public record JupiterPerpsPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
ΩSPLMintAddressΩ tradedTokenMint,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ value,
ΩUSDCAmountΩ size,
ΩUSDCAmountΩ pnl,
BigDecimal pnlPercent,
BigDecimal leverage,
ΩUSDCPriceΩ entryPrice,
ΩUSDCPriceΩ marketPrice,
ΩUSDCAmountΩ collateral,
ΩUSDCAmountΩ totalFees,
ΩUSDCAmountΩ borrowFeesDue,
ΩUSDCAmountΩ closeFeePending,
ΩSolanaAmountΩ accountRent
) {
}
@@ -0,0 +1,9 @@
package com.r35157.libs.jupiter.perps;
/**
* Direction of a Jupiter Perps position.
*/
public enum JupiterPerpsPositionDirection {
LONG,
SHORT
}
@@ -0,0 +1,45 @@
package com.r35157.libs.jupiter.perps;
import java.io.IOException;
import java.util.Set;
/**
* Service for reading Jupiter Perps data.
*
* <p>This service is read-only. It does not open, close, modify, or sign transactions
* for Jupiter Perpetual Contracts.</p>
*
* <p>NOTICE: The first supported operation is reading a known Jupiter Perps position account
* and returning its decoded position data.</p>
*/
public interface JupiterPerpsService {
/**
* Reads a Jupiter Perps position from a known position account.
*
* <p>The supplied account must be the Solana account that stores the Jupiter Perps
* position state. It is not the wallet address, token account, custody account, pool
* account, or position request account.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @return the decoded Jupiter Perps position
* @throws IOException if the position account could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching
* the position account
*/
JupiterPerpsPosition getPosition(ΩJupiterPerpsPositionAccountΩ positionAccount)
throws IOException, InterruptedException;
/**
* Finds open Jupiter Perps positions owned by a wallet.
*
* <p>This method returns decoded Jupiter Perps position objects. It does not return
* raw Solana accounts or account ids.</p>
*
* @param owner the wallet address that owns the Jupiter Perps positions
* @return the open Jupiter Perps positions owned by the wallet
* @throws IOException if the position accounts could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching positions
*/
Set<JupiterPerpsPosition> getOpenPositions(ΩSolanaWalletIdΩ owner)
throws IOException, InterruptedException;
}
@@ -0,0 +1,50 @@
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();
}
@@ -0,0 +1,159 @@
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 com.r35157.libs.valuetypes.basic.MoneyAmount;
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Base64;
import static java.math.BigDecimal.ZERO;
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);
long rawCollateralUsd = ByteBuffer
.wrap(data, COLLATERAL_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ collateralUsd = BigDecimal
.valueOf(rawCollateralUsd)
.movePointLeft(6);
long rawSizeUsd = ByteBuffer
.wrap(data, SIZE_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ sizeUsd = BigDecimal
.valueOf(rawSizeUsd)
.movePointLeft(6);
ΩUSDCAmountΩ value = ZERO; // TODO - Dummy
ΩUSDCAmountΩ pnl = ZERO; // TODO - Dummy
BigDecimal pnlPercent = ZERO; // TODO - Dummy
BigDecimal leverage = ZERO; // TODO - Dummy
ΩUSDCPriceΩ marketPrice = ZERO; // TODO - Dummy
ΩUSDCAmountΩ totalFees = ZERO; // TODO - Dummy
ΩUSDCAmountΩ borrowFeesDue = ZERO; // TODO - Dummy
ΩUSDCAmountΩ closeFeePending = ZERO; // TODO - Dummy
ΩSolanaAmountΩ accountRent = new MoneyAmount(ZERO, WellKnownCurrencyTypes.SOLANA.getCurrencyType()); // TODO - Dummy
JupiterPerpsPosition pos = new JupiterPerpsPosition(
positionAccount,
tradedTokenMint,
direction,
value,
sizeUsd,
pnl,
pnlPercent,
leverage,
entryPrice,
marketPrice,
collateralUsd,
totalFees,
borrowFeesDue,
closeFeePending,
accountRent
);
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;
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;
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 int SIZE_USD_OFFSET = PRICE_OFFSET + U64_LENGTH;
private static final int COLLATERAL_USD_OFFSET = SIZE_USD_OFFSET + U64_LENGTH;
private static final Base58Codec base58 = new Base58CodecImpl();
}
@@ -0,0 +1,99 @@
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,13 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import java.math.BigDecimal;
public record JupiterPerpsPositionInfo(
ΩUSDCPriceΩ entryPrice,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ sizeUsd,
ΩUSDCAmountΩ collateralUsd
) {
}
@@ -143,4 +143,25 @@ public interface SolanaBlockChain {
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Fetches accounts owned by a Solana program using server-side account data filters.
*
* <p>This method uses Solana's {@code getProgramAccounts} RPC call. The supplied filters
* are sent to the RPC node, so matching is performed server-side instead of fetching all
* accounts owned by the program and filtering them locally.</p>
*
* <p>The initial supported filter type is {@link SolanaProgramAccountMemcmpFilter}, which
* matches bytes at a specific offset in the account data.</p>
*
* @param programId the Solana program id that owns the accounts to search
* @param filters the memcmp filters to apply when searching program accounts
* @return the matching program accounts
* @throws IOException if the program accounts could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching program accounts
*/
Set<SolanaAccountInfo> getProgramAccounts(
ΩSolanaProgramIdΩ programId,
Set<SolanaProgramAccountMemcmpFilter> filters
) throws IOException, InterruptedException;
}
@@ -0,0 +1,17 @@
package com.r35157.libs.solana;
/**
* Filter used when fetching accounts owned by a Solana program.
*
* <p>The initial supported filter type is {@code memcmp}, which asks the
* Solana RPC node to only return accounts where the account data at a specific
* byte offset matches a base58 encoded value.</p>
*
* @param offset the byte offset in the account data where comparison starts
* @param bytes the base58 encoded bytes to match
*/
public record SolanaProgramAccountMemcmpFilter(
int offset,
String bytes
) {
}
@@ -331,6 +331,113 @@ 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 {
waitBeforeRemoteCall();
@@ -0,0 +1,52 @@
package com.r35157.libs.solana.valuetypes;
import com.r35157.libs.valuetypes.basic.CurrencyType;
import java.util.UUID;
/**
* Defines well-known currency types used by the Solana integration.
*
* <p>Each enum value wraps a {@link CurrencyType} with a stable identifier and a
* human-readable currency name. These predefined values are intended for common
* currencies that the Solana-related modules need to reference consistently.</p>
*/
public enum WellKnownCurrencyTypes {
/**
* Native Solana currency.
*/
SOLANA(new CurrencyType(
UUID.fromString("019e0116-fce5-792f-a647-fa6da4dffec5"),
"Solana",
"SOL")
),
/**
* Syrup USDC token currency.
*/
SYRUPUSDC(new CurrencyType(
UUID.fromString("019e1d51-0600-7956-8231-f3b7058a91c2"),
"SyrupUSDC",
"SyrupUSDC")
);
/**
* Creates a well-known currency type entry.
*
* @param currencyType the currency type represented by this enum value
*/
WellKnownCurrencyTypes(CurrencyType currencyType) {
this.currencyType = currencyType;
}
/**
* Returns the currency type represented by this enum value.
*
* @return the represented currency type
*/
public CurrencyType getCurrencyType() {
return currencyType;
}
private final CurrencyType currencyType;
}
@@ -0,0 +1,8 @@
package com.r35157.libs.valuetypes.basic;
import java.math.BigDecimal;
public record MoneyPrice(
ΩPriceΩ price,
CurrencyType currencyType
) { }
@@ -1,5 +1,10 @@
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.NenjimHub;
import org.slf4j.Logger;
@@ -12,12 +17,21 @@ import com.r35157.nenjim.hubd.journal.JournalManager;
import com.r35157.nenjim.hubd.impl.ref.JournalManagerImpl;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
public class Main {
// 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 {
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();
/* try {