Add Jupiter alarm
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Jupiter Perps Price Alarm
|
||||
|
||||
A small Java 17 program that listens to Jupiter Perps' on-chain aggregated oracle account through Solana WebSocket `accountSubscribe`.
|
||||
|
||||
It does **not** poll once per second. Every account update observed by the connected RPC node is decoded immediately. The program reconnects automatically, performs an initial/reconnect state fetch, and can connect to multiple independent RPC endpoints for redundancy.
|
||||
|
||||
## Build and test
|
||||
|
||||
```bash
|
||||
gradle classes
|
||||
gradle run --args='--self-test'
|
||||
```
|
||||
|
||||
## Monitor a SOL short liquidation threshold
|
||||
|
||||
```bash
|
||||
gradle run --args='--asset=SOL --target=175.00 --direction=above'
|
||||
```
|
||||
|
||||
For a long position, liquidation is normally below the current price:
|
||||
|
||||
```bash
|
||||
gradle run --args='--asset=SOL --target=120.00 --direction=below'
|
||||
```
|
||||
|
||||
## Use two RPC WebSocket streams
|
||||
|
||||
A single WebSocket/RPC provider is not a durable event log. For better resilience, provide two independent endpoints:
|
||||
|
||||
```bash
|
||||
export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example'
|
||||
gradle run --args='--asset=SOL --target=175 --direction=above'
|
||||
```
|
||||
|
||||
The same URL is converted from `wss://` to `https://` for initial and reconnect state retrieval. This works with the usual Solana RPC endpoint format, including API-key query parameters.
|
||||
|
||||
## Pushover emergency alarm
|
||||
|
||||
```bash
|
||||
export PUSHOVER_APP_TOKEN='...'
|
||||
export PUSHOVER_USER_KEY='...'
|
||||
gradle run --args='--asset=SOL --target=175 --direction=above'
|
||||
```
|
||||
|
||||
The program sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`.
|
||||
|
||||
## Important limitations
|
||||
|
||||
- `processed` is intentionally used for minimum delay, but a processed update may belong to a fork that is later abandoned.
|
||||
- Solana PubSub is not guaranteed delivery. Two independent RPC streams reduce, but do not eliminate, the risk of missing an update.
|
||||
- The alarm reports the Jupiter Perps oracle price. It does not prove that your position was liquidated. For that, also subscribe to your Jupiter position account or relevant program transactions.
|
||||
- This is an alerting aid, not a substitute for placing an on-platform stop-loss or reducing leverage.
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AlarmAction {
|
||||
void trigger(OraclePrice price, BigDecimal target, PriceDirection direction);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public final class CompositeAlarmAction implements AlarmAction {
|
||||
|
||||
public CompositeAlarmAction(List<AlarmAction> actions) {
|
||||
this.actions = List.copyOf(actions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
|
||||
for (AlarmAction action : actions) {
|
||||
try {
|
||||
action.trigger(price, target, direction);
|
||||
} catch (RuntimeException exception) {
|
||||
System.err.println("Alarm action failed: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final List<AlarmAction> actions;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public final class ConsoleAlarmAction implements AlarmAction {
|
||||
@Override
|
||||
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
|
||||
System.err.println();
|
||||
System.err.println("============================================================");
|
||||
System.err.printf(
|
||||
"ALARM: %s is %s USD; target %s %s USD%n",
|
||||
price.asset(),
|
||||
price.priceUsd().toPlainString(),
|
||||
direction,
|
||||
target.toPlainString()
|
||||
);
|
||||
System.err.printf(
|
||||
"Oracle time: %s, slot: %d, source: %s%n",
|
||||
price.oracleTime(),
|
||||
price.slot(),
|
||||
price.source()
|
||||
);
|
||||
System.err.println("============================================================");
|
||||
System.err.println();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class DovesAgPriceFeedDecoder {
|
||||
|
||||
public static OraclePrice decode(
|
||||
JupiterPerpsAsset asset,
|
||||
byte[] accountData,
|
||||
long slot,
|
||||
String source
|
||||
) {
|
||||
if (accountData.length < ACCOUNT_SIZE) {
|
||||
throw new IllegalArgumentException(
|
||||
"Doves AG account is too short: " + accountData.length +
|
||||
" bytes; expected at least " + ACCOUNT_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
byte[] discriminator = Arrays.copyOfRange(accountData, 0, 8);
|
||||
if (!Arrays.equals(discriminator, AG_PRICE_FEED_DISCRIMINATOR)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unexpected Anchor discriminator. The oracle layout may have changed."
|
||||
);
|
||||
}
|
||||
|
||||
BigInteger rawPrice = readUnsignedLongLittleEndian(accountData, PRICE_OFFSET);
|
||||
int exponent = accountData[EXPONENT_OFFSET];
|
||||
long timestamp = ByteBuffer.wrap(accountData, TIMESTAMP_OFFSET, Long.BYTES)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.getLong();
|
||||
|
||||
BigDecimal priceUsd = new BigDecimal(rawPrice).scaleByPowerOfTen(exponent);
|
||||
|
||||
return new OraclePrice(
|
||||
asset,
|
||||
rawPrice,
|
||||
exponent,
|
||||
priceUsd,
|
||||
Instant.ofEpochSecond(timestamp),
|
||||
slot,
|
||||
source
|
||||
);
|
||||
}
|
||||
|
||||
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++) {
|
||||
positiveBigEndian[Long.BYTES - index] = data[offset + index];
|
||||
}
|
||||
return new BigInteger(positiveBigEndian);
|
||||
}
|
||||
|
||||
private static void putUnsignedLongLittleEndian(
|
||||
byte[] data,
|
||||
int offset,
|
||||
BigInteger value
|
||||
) {
|
||||
BigInteger remaining = value;
|
||||
for (int index = 0; index < Long.BYTES; index++) {
|
||||
data[offset + index] = remaining.byteValue();
|
||||
remaining = remaining.shiftRight(8);
|
||||
}
|
||||
}
|
||||
|
||||
private DovesAgPriceFeedDecoder() {
|
||||
}
|
||||
|
||||
// Anchor discriminator: sha256("account:AgPriceFeed")[0..8]
|
||||
private static final byte[] AG_PRICE_FEED_DISCRIMINATOR = {
|
||||
0x70, (byte) 0xF9, (byte) 0x8B, (byte) 0xD9,
|
||||
(byte) 0xD7, (byte) 0xD0, (byte) 0xF9, 0x36
|
||||
};
|
||||
|
||||
// Layout from the Doves IDL:
|
||||
// discriminator 8
|
||||
// mint 32, edgeFeed 32, clFeed 32, pythFeed 32, pythFeedId 32
|
||||
// price u64, expo i8, timestamp i64, config(u32,u32,u64), bump u8
|
||||
private static final int PRICE_OFFSET = 168;
|
||||
private static final int EXPONENT_OFFSET = 176;
|
||||
private static final int TIMESTAMP_OFFSET = 177;
|
||||
private static final int ACCOUNT_SIZE = 202;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
public enum JupiterPerpsAsset {
|
||||
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
|
||||
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
|
||||
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC"),
|
||||
USDC("6Jp2xZUTWdDD2ZyUPRzeMdc6AFQ5K3pFgZxk2EijfjnM"),
|
||||
USDT("Fgc93D641F8N2d1xLjQ4jmShuD3GE3BsCXA56KBQbF5u");
|
||||
|
||||
JupiterPerpsAsset(String oracleAccount) {
|
||||
this.oracleAccount = oracleAccount;
|
||||
}
|
||||
|
||||
public String oracleAccount() {
|
||||
return oracleAccount;
|
||||
}
|
||||
|
||||
private final String oracleAccount;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
public final class Main {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Config config;
|
||||
try {
|
||||
config = Config.parse(args, System.getenv());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
System.err.println(exception.getMessage());
|
||||
printUsage();
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.selfTest()) {
|
||||
DovesAgPriceFeedDecoder.selfTest();
|
||||
System.out.println("Decoder self-test passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<AlarmAction> actions = new ArrayList<>();
|
||||
actions.add(new ConsoleAlarmAction());
|
||||
|
||||
if (config.pushoverToken() != null && config.pushoverUserKey() != null) {
|
||||
actions.add(new PushoverAlarmAction(
|
||||
config.pushoverToken(),
|
||||
config.pushoverUserKey()
|
||||
));
|
||||
System.out.println("Pushover emergency alarm is enabled.");
|
||||
} else {
|
||||
System.out.println(
|
||||
"Pushover is disabled. Set PUSHOVER_APP_TOKEN and " +
|
||||
"PUSHOVER_USER_KEY to enable it."
|
||||
);
|
||||
}
|
||||
|
||||
AlarmAction action = new CompositeAlarmAction(actions);
|
||||
PriceAlarm alarm = new PriceAlarm(
|
||||
config.asset(),
|
||||
config.target(),
|
||||
config.direction(),
|
||||
action
|
||||
);
|
||||
|
||||
List<OracleWebSocketClient> clients = config.webSocketEndpoints().stream()
|
||||
.map(endpoint -> new OracleWebSocketClient(endpoint, config.asset(), alarm::accept))
|
||||
.toList();
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||
() -> clients.forEach(OracleWebSocketClient::close),
|
||||
"shutdown"
|
||||
));
|
||||
|
||||
System.out.printf(
|
||||
"Monitoring Jupiter Perps %s oracle. Alarm when price is %s %s USD.%n",
|
||||
config.asset(),
|
||||
config.direction(),
|
||||
config.target().toPlainString()
|
||||
);
|
||||
System.out.println("Oracle account: " + config.asset().oracleAccount());
|
||||
System.out.println("RPC streams: " + config.webSocketEndpoints().size());
|
||||
|
||||
clients.forEach(OracleWebSocketClient::start);
|
||||
new CountDownLatch(1).await();
|
||||
}
|
||||
|
||||
private static void printUsage() {
|
||||
System.err.println("""
|
||||
Usage:
|
||||
gradle run --args='--asset=SOL --target=175.00 --direction=above'
|
||||
|
||||
Options:
|
||||
--asset=SOL|ETH|BTC Default: SOL
|
||||
--target=<USD price> Required
|
||||
--direction=above|below Default: above
|
||||
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
|
||||
--self-test Test the binary decoder and exit
|
||||
|
||||
Environment:
|
||||
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
|
||||
PUSHOVER_APP_TOKEN Pushover application token
|
||||
PUSHOVER_USER_KEY Pushover user/group key
|
||||
|
||||
For a SOL short liquidation warning, use --direction=above.
|
||||
""");
|
||||
}
|
||||
|
||||
private record Config(
|
||||
JupiterPerpsAsset asset,
|
||||
BigDecimal target,
|
||||
PriceDirection direction,
|
||||
List<URI> webSocketEndpoints,
|
||||
String pushoverToken,
|
||||
String pushoverUserKey,
|
||||
boolean selfTest
|
||||
) {
|
||||
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))
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
parts -> parts[0],
|
||||
parts -> parts[1],
|
||||
(first, second) -> second
|
||||
));
|
||||
|
||||
JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf(
|
||||
options.getOrDefault("asset", "SOL").toUpperCase(Locale.ROOT)
|
||||
);
|
||||
|
||||
BigDecimal target = null;
|
||||
if (!selfTest) {
|
||||
String targetText = options.get("target");
|
||||
if (targetText == null || targetText.isBlank()) {
|
||||
throw new IllegalArgumentException("Missing required --target=<USD price>");
|
||||
}
|
||||
target = new BigDecimal(targetText);
|
||||
if (target.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Target price must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
PriceDirection direction = PriceDirection.valueOf(
|
||||
options.getOrDefault("direction", "above").toUpperCase(Locale.ROOT)
|
||||
);
|
||||
|
||||
String endpointText = options.get("ws");
|
||||
if (endpointText == null || endpointText.isBlank()) {
|
||||
endpointText = environment.getOrDefault(
|
||||
"SOLANA_WS_URLS",
|
||||
"wss://api.mainnet-beta.solana.com"
|
||||
);
|
||||
}
|
||||
|
||||
List<URI> endpoints = Arrays.stream(endpointText.split(","))
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.map(URI::create)
|
||||
.toList();
|
||||
|
||||
if (endpoints.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one WebSocket endpoint is required");
|
||||
}
|
||||
|
||||
return new Config(
|
||||
asset,
|
||||
target,
|
||||
direction,
|
||||
endpoints,
|
||||
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
|
||||
blankToNull(environment.get("PUSHOVER_USER_KEY")),
|
||||
selfTest
|
||||
);
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
private Main() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
|
||||
public record OraclePrice(
|
||||
JupiterPerpsAsset asset,
|
||||
BigInteger rawPrice,
|
||||
int exponent,
|
||||
BigDecimal priceUsd,
|
||||
Instant oracleTime,
|
||||
long slot,
|
||||
String source
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.WebSocket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class OracleWebSocketClient implements AutoCloseable {
|
||||
|
||||
public OracleWebSocketClient(
|
||||
URI webSocketEndpoint,
|
||||
JupiterPerpsAsset asset,
|
||||
Consumer<OraclePrice> priceConsumer
|
||||
) {
|
||||
this.webSocketEndpoint = Objects.requireNonNull(webSocketEndpoint);
|
||||
this.httpEndpoint = toHttpEndpoint(webSocketEndpoint);
|
||||
this.asset = Objects.requireNonNull(asset);
|
||||
this.priceConsumer = Objects.requireNonNull(priceConsumer);
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(
|
||||
runnable,
|
||||
"oracle-ws-" + this.asset.name().toLowerCase() + "-" +
|
||||
this.webSocketEndpoint.getHost()
|
||||
);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduler.scheduleAtFixedRate(this::sendHeartbeat, 20, 20, TimeUnit.SECONDS);
|
||||
connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
running.set(false);
|
||||
WebSocket webSocket = currentWebSocket.getAndSet(null);
|
||||
if (webSocket != null) {
|
||||
webSocket.abort();
|
||||
}
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println("Connecting to " + webSocketEndpoint);
|
||||
httpClient.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.buildAsync(webSocketEndpoint, new Listener())
|
||||
.whenComplete((webSocket, error) -> {
|
||||
if (error != null) {
|
||||
System.err.printf(
|
||||
"WebSocket connection failed for %s: %s%n",
|
||||
webSocketEndpoint,
|
||||
error.getMessage()
|
||||
);
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void subscribe(WebSocket webSocket) {
|
||||
String request = """
|
||||
{"jsonrpc":"2.0","id":1,"method":"accountSubscribe","params":["%s",{"encoding":"base64","commitment":"processed"}]}
|
||||
""".formatted(asset.oracleAccount()).trim();
|
||||
|
||||
webSocket.sendText(request, true)
|
||||
.whenComplete((ignored, error) -> {
|
||||
if (error != null) {
|
||||
System.err.println("Subscription request failed: " + error.getMessage());
|
||||
} else {
|
||||
System.out.printf(
|
||||
"Subscribed to %s oracle account %s with processed commitment.%n",
|
||||
asset,
|
||||
asset.oracleAccount()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchCurrentState() {
|
||||
String body = """
|
||||
{"jsonrpc":"2.0","id":2,"method":"getAccountInfo","params":["%s",{"encoding":"base64","commitment":"processed"}]}
|
||||
""".formatted(asset.oracleAccount()).trim();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(httpEndpoint)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.whenComplete((response, error) -> {
|
||||
if (error != null) {
|
||||
System.err.printf(
|
||||
"Initial oracle fetch failed for %s: %s%n",
|
||||
httpEndpoint,
|
||||
error.getMessage()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode() != 200) {
|
||||
System.err.printf(
|
||||
"Initial oracle fetch returned HTTP %d from %s%n",
|
||||
response.statusCode(),
|
||||
httpEndpoint
|
||||
);
|
||||
return;
|
||||
}
|
||||
processJson(response.body());
|
||||
});
|
||||
}
|
||||
|
||||
private void processJson(String json) {
|
||||
if (json.contains("\"error\"")) {
|
||||
System.err.println("Solana RPC error from " + webSocketEndpoint + ": " + json);
|
||||
return;
|
||||
}
|
||||
|
||||
Matcher dataMatcher = DATA_PATTERN.matcher(json);
|
||||
if (!dataMatcher.find()) {
|
||||
return; // Usually the accountSubscribe acknowledgement.
|
||||
}
|
||||
|
||||
long slot = -1L;
|
||||
Matcher slotMatcher = SLOT_PATTERN.matcher(json);
|
||||
if (slotMatcher.find()) {
|
||||
slot = Long.parseLong(slotMatcher.group(1));
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] accountData = Base64.getDecoder().decode(dataMatcher.group(1));
|
||||
OraclePrice price = DovesAgPriceFeedDecoder.decode(
|
||||
asset,
|
||||
accountData,
|
||||
slot,
|
||||
webSocketEndpoint.toString()
|
||||
);
|
||||
priceConsumer.accept(price);
|
||||
} catch (RuntimeException exception) {
|
||||
System.err.printf(
|
||||
"Could not decode oracle data from %s: %s%n",
|
||||
webSocketEndpoint,
|
||||
exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendHeartbeat() {
|
||||
WebSocket webSocket = currentWebSocket.get();
|
||||
if (running.get() && webSocket != null && !webSocket.isOutputClosed()) {
|
||||
webSocket.sendPing(ByteBuffer.wrap(new byte[]{1}))
|
||||
.exceptionally(error -> {
|
||||
System.err.println("WebSocket heartbeat failed: " + error.getMessage());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleReconnect() {
|
||||
if (!running.get() || !reconnectScheduled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
long delay = reconnectDelaySeconds;
|
||||
reconnectDelaySeconds = Math.min(reconnectDelaySeconds * 2, 30);
|
||||
System.err.printf("Reconnecting to %s in %d seconds.%n", webSocketEndpoint, delay);
|
||||
|
||||
scheduler.schedule(() -> {
|
||||
reconnectScheduled.set(false);
|
||||
connect();
|
||||
}, delay, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static URI toHttpEndpoint(URI webSocketEndpoint) {
|
||||
String scheme = switch (webSocketEndpoint.getScheme()) {
|
||||
case "wss" -> "https";
|
||||
case "ws" -> "http";
|
||||
default -> throw new IllegalArgumentException(
|
||||
"WebSocket endpoint must use ws:// or wss://"
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
return new URI(
|
||||
scheme,
|
||||
webSocketEndpoint.getUserInfo(),
|
||||
webSocketEndpoint.getHost(),
|
||||
webSocketEndpoint.getPort(),
|
||||
webSocketEndpoint.getPath(),
|
||||
webSocketEndpoint.getQuery(),
|
||||
webSocketEndpoint.getFragment()
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalArgumentException("Could not derive HTTP RPC endpoint", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Listener implements WebSocket.Listener {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
currentWebSocket.set(webSocket);
|
||||
reconnectDelaySeconds = 1;
|
||||
reconnectScheduled.set(false);
|
||||
subscribe(webSocket);
|
||||
fetchCurrentState();
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(
|
||||
WebSocket webSocket,
|
||||
CharSequence data,
|
||||
boolean last
|
||||
) {
|
||||
synchronized (textBuffer) {
|
||||
textBuffer.append(data);
|
||||
if (last) {
|
||||
String json = textBuffer.toString();
|
||||
textBuffer.setLength(0);
|
||||
processJson(json);
|
||||
}
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onBinary(
|
||||
WebSocket webSocket,
|
||||
ByteBuffer data,
|
||||
boolean last
|
||||
) {
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onPing(WebSocket webSocket, ByteBuffer message) {
|
||||
webSocket.request(1);
|
||||
return webSocket.sendPong(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onPong(WebSocket webSocket, ByteBuffer message) {
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(
|
||||
WebSocket webSocket,
|
||||
int statusCode,
|
||||
String reason
|
||||
) {
|
||||
currentWebSocket.compareAndSet(webSocket, null);
|
||||
System.err.printf(
|
||||
"WebSocket closed by %s: code=%d reason=%s%n",
|
||||
webSocketEndpoint,
|
||||
statusCode,
|
||||
reason
|
||||
);
|
||||
scheduleReconnect();
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
currentWebSocket.compareAndSet(webSocket, null);
|
||||
System.err.printf(
|
||||
"WebSocket error from %s: %s%n",
|
||||
webSocketEndpoint,
|
||||
error.getMessage()
|
||||
);
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
}
|
||||
|
||||
private final URI webSocketEndpoint;
|
||||
private final URI httpEndpoint;
|
||||
private final JupiterPerpsAsset asset;
|
||||
private final Consumer<OraclePrice> priceConsumer;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
private final ScheduledExecutorService scheduler;
|
||||
private final AtomicReference<WebSocket> currentWebSocket = new AtomicReference<>();
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
private final AtomicBoolean reconnectScheduled = new AtomicBoolean();
|
||||
|
||||
private volatile long reconnectDelaySeconds = 1;
|
||||
|
||||
private static final Pattern DATA_PATTERN = Pattern.compile(
|
||||
"\\\"data\\\"\\s*:\\s*\\[\\s*\\\"([A-Za-z0-9+/=]+)\\\"\\s*,\\s*\\\"base64\\\"\\s*]"
|
||||
);
|
||||
private static final Pattern SLOT_PATTERN = Pattern.compile(
|
||||
"\\\"slot\\\"\\s*:\\s*(\\d+)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class PriceAlarm {
|
||||
|
||||
public PriceAlarm(
|
||||
JupiterPerpsAsset asset,
|
||||
BigDecimal target,
|
||||
PriceDirection direction,
|
||||
AlarmAction action
|
||||
) {
|
||||
this.asset = asset;
|
||||
this.target = target;
|
||||
this.direction = direction;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public synchronized void accept(OraclePrice price) {
|
||||
String eventKey = price.rawPrice() + ":" + price.exponent() + ":" +
|
||||
price.oracleTime().getEpochSecond();
|
||||
if (!recentEvents.add(eventKey)) {
|
||||
return;
|
||||
}
|
||||
trimRecentEvents();
|
||||
|
||||
long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds();
|
||||
System.out.printf(
|
||||
"%s %s=%s USD oracleAge=%ds slot=%d source=%s%n",
|
||||
Instant.now(),
|
||||
asset,
|
||||
price.priceUsd().toPlainString(),
|
||||
ageSeconds,
|
||||
price.slot(),
|
||||
price.source()
|
||||
);
|
||||
|
||||
if (direction.reached(price.priceUsd(), target) && triggered.compareAndSet(false, true)) {
|
||||
action.trigger(price, target, direction);
|
||||
}
|
||||
}
|
||||
|
||||
private void trimRecentEvents() {
|
||||
while (recentEvents.size() > MAX_RECENT_EVENTS) {
|
||||
Iterator<String> iterator = recentEvents.iterator();
|
||||
iterator.next();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private final JupiterPerpsAsset asset;
|
||||
private final BigDecimal target;
|
||||
private final PriceDirection direction;
|
||||
private final AlarmAction action;
|
||||
private final AtomicBoolean triggered = new AtomicBoolean();
|
||||
private final Set<String> recentEvents = new LinkedHashSet<>();
|
||||
|
||||
private static final int MAX_RECENT_EVENTS = 512;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public enum PriceDirection {
|
||||
ABOVE {
|
||||
@Override
|
||||
public boolean reached(BigDecimal price, BigDecimal target) {
|
||||
return price.compareTo(target) >= 0;
|
||||
}
|
||||
},
|
||||
BELOW {
|
||||
@Override
|
||||
public boolean reached(BigDecimal price, BigDecimal target) {
|
||||
return price.compareTo(target) <= 0;
|
||||
}
|
||||
};
|
||||
|
||||
public abstract boolean reached(BigDecimal price, BigDecimal target);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.r35157.jupiterperpsalarm;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class PushoverAlarmAction implements AlarmAction {
|
||||
|
||||
public PushoverAlarmAction(String applicationToken, String userKey) {
|
||||
this.applicationToken = applicationToken;
|
||||
this.userKey = userKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
|
||||
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(),
|
||||
direction,
|
||||
target.toPlainString(),
|
||||
price.oracleTime(),
|
||||
price.slot()
|
||||
);
|
||||
|
||||
String body = form("token", applicationToken) + "&" +
|
||||
form("user", userKey) + "&" +
|
||||
form("title", title) + "&" +
|
||||
form("message", message) + "&" +
|
||||
"priority=2&retry=30&expire=10800&sound=persistent";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", "jupiter-perps-price-alarm/1.0")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.whenComplete((response, error) -> {
|
||||
if (error != null) {
|
||||
System.err.println("Pushover failed: " + error.getMessage());
|
||||
return;
|
||||
}
|
||||
if (response.statusCode() != 200 || !response.body().contains("\"status\":1")) {
|
||||
System.err.printf(
|
||||
"Pushover rejected the alarm: HTTP %d: %s%n",
|
||||
response.statusCode(),
|
||||
response.body()
|
||||
);
|
||||
} else {
|
||||
System.out.println("Pushover emergency alarm sent.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String form(String name, String value) {
|
||||
return URLEncoder.encode(name, StandardCharsets.UTF_8) + "=" +
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private final String applicationToken;
|
||||
private final String userKey;
|
||||
|
||||
private static final URI PUSHOVER_URI =
|
||||
URI.create("https://api.pushover.net/1/messages.json");
|
||||
}
|
||||
Reference in New Issue
Block a user