Compare commits
81 Commits
0.0.0
...
0.1-preprod
| Author | SHA256 | Date | |
|---|---|---|---|
| d9f773c1f9 | |||
| 2d68b83cf4 | |||
| 7e6e9978a2 | |||
| 35052a045a | |||
| ce74550825 | |||
| a5c9c78fdd | |||
| c4f67fa0fc | |||
| c8ca946fcc | |||
| 760ba0e823 | |||
| 1cb78fa3db | |||
| 09bd70b348 | |||
| cd573b1ce0 | |||
| 96d1289730 | |||
| 539068978f | |||
| 48f087dc6e | |||
| be73fe9ee6 | |||
| 7dcfb9977c | |||
| 0a6a7b4a3b | |||
| b74e4ad286 | |||
| 74b0b1dc64 | |||
| ca03200847 | |||
| dee96cf704 | |||
| 781d008923 | |||
| f57c00b834 | |||
| 804b640af5 | |||
| ea7cdbaf46 | |||
| 35fa64fa23 | |||
| fe6e29a45e | |||
| ff0b81c432 | |||
| f44bdc4b8b | |||
| affe6bba7f | |||
| 5b62bd8d12 | |||
| b8bf70a178 | |||
| 439febc110 | |||
| ed3d37f231 | |||
| 0f21578fc5 | |||
| f89401ac72 | |||
| f4d6c4bfad | |||
| 435a88cfaa | |||
| e1d691f2a7 | |||
| d784cd2fd5 | |||
| 125255cdf7 | |||
| a65ac072e7 | |||
| 3b24731630 | |||
| f99f378d88 | |||
| 76f11b5399 | |||
| 9317c5e45b | |||
| af6114b051 | |||
| 4f788c87c1 | |||
| e0d7a91b0d | |||
| 902b6ffd91 | |||
| f9c5391688 | |||
| f1a5c87ad3 | |||
| f94b08fa9c | |||
| 9a7281907c | |||
| b48d809360 | |||
| eed626e95d | |||
| a73d6d2eb5 | |||
| 0282da6f5c | |||
| 25a96126ed | |||
| f2e3889d8f | |||
| b02484aded | |||
| cd06fdcdd0 | |||
| 1f1165da1c | |||
| 214a9b7322 | |||
| ce76ce8977 | |||
| bc661c9e54 | |||
| f9a6183ad1 | |||
| 77121d7251 | |||
| e93f06784c | |||
| 8f907324ca | |||
| a2fae04892 | |||
| d3e859a094 | |||
| cd8d00bd8d | |||
| f7c9cf908e | |||
| 12e9571b2d | |||
| 861310d7b4 | |||
| 8788604336 | |||
| 3fb271e710 | |||
| 7db2a439bf | |||
| 3bd4708e17 |
@@ -3,3 +3,8 @@
|
|||||||
|
|
||||||
# Ignore Gradle build output directory
|
# Ignore Gradle build output directory
|
||||||
build
|
build
|
||||||
|
|
||||||
|
logs/*.log
|
||||||
|
logs/*.log.gz
|
||||||
|
conf/*.conf
|
||||||
|
conf/*.xml
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
# Jupiter Perps Price Alarm
|
||||||
|
|
||||||
|
A small Java program that listens to Jupiter Perps' on-chain aggregated oracle accounts 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.
|
||||||
|
|
||||||
|
The program supports any number of alarms for SOL, ETH, and BTC. Only one oracle stream is created per configured asset and RPC endpoint, regardless of how many alarms use that asset.
|
||||||
|
|
||||||
|
## Java version
|
||||||
|
|
||||||
|
The project uses the current JDK compiler but generates Java 17-compatible class files:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.release = 17
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alarm configuration
|
||||||
|
|
||||||
|
The default file is `price-alarms.conf`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Asset Direction Target TRIGGER SEVERITY NOTE
|
||||||
|
##################################################################################################
|
||||||
|
SOL ABOVE 75.7 ONETIME 2 "ALARM: Risiko for Perps Solana short LIKVIDERING!"
|
||||||
|
SOL BELOW 60.8 ONETIME 2 "ALARM: Risiko for Perps Solana long LIKVIDERING!"
|
||||||
|
SOL BELOW 71.4 ONETIME 2 "ALARM: Risiko for Solana Raydium LÅN LIKVIDERING!"
|
||||||
|
ETH ABOVE 1848.41 ONETIME 2 "ALARM: Risiko for Perps Ethereum short LIKVIDERING!"
|
||||||
|
ETH BELOW 1789 ONETIME 1 "OK: Perps Ethereum short er lukket!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported values:
|
||||||
|
|
||||||
|
- Asset: `SOL`, `ETH`, or `BTC`
|
||||||
|
- Direction: `ABOVE` or `BELOW`
|
||||||
|
- Trigger: `ONETIME` or `PERSISTENT`
|
||||||
|
- Target: positive decimal USD price
|
||||||
|
- Severity: zero or positive integer
|
||||||
|
- Note: quoted text; escaped quotes can be written as `\"`
|
||||||
|
|
||||||
|
`SEVERITY` and `NOTE` are parsed and retained in `PriceAlarmDefinition`, but are intentionally not used by the alarm actions yet.
|
||||||
|
|
||||||
|
## Trigger behavior
|
||||||
|
|
||||||
|
On the first received price after program start:
|
||||||
|
|
||||||
|
- An already satisfied alarm triggers immediately.
|
||||||
|
- An unsatisfied alarm waits for the price to cross into its triggered side.
|
||||||
|
|
||||||
|
Alarm state is retained across WebSocket reconnects within the same process. If the price moves from the safe side to the triggered side during a connection outage, the first price received after reconnect will therefore trigger the alarm.
|
||||||
|
|
||||||
|
After that:
|
||||||
|
|
||||||
|
- `ONETIME` triggers only once during the current program run.
|
||||||
|
- `PERSISTENT` triggers each time the price crosses from the safe side into the triggered side.
|
||||||
|
- Remaining on the triggered side does not repeatedly fire the alarm.
|
||||||
|
|
||||||
|
`ONETIME` state is currently kept in memory. Restarting the process arms the alarm again.
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle classes
|
||||||
|
gradle run --args='--self-test'
|
||||||
|
```
|
||||||
|
|
||||||
|
The self-test covers:
|
||||||
|
|
||||||
|
- binary oracle decoding
|
||||||
|
- configuration parsing
|
||||||
|
- initial satisfied alarm behavior
|
||||||
|
- `ONETIME` behavior
|
||||||
|
- `PERSISTENT` crossing behavior
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Using the default `price-alarms.conf` in the working directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle run
|
||||||
|
```
|
||||||
|
|
||||||
|
Using another file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle run --args='--config=/path/to/price-alarms.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
The path can also be selected through:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PRICE_ALARMS_CONFIG='/path/to/price-alarms.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
With two configured assets and two RPC endpoints, the program opens four WebSocket connections.
|
||||||
|
|
||||||
|
## Pushover emergency alarm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PUSHOVER_APP_TOKEN='...'
|
||||||
|
export PUSHOVER_USER_KEY='...'
|
||||||
|
gradle run
|
||||||
|
```
|
||||||
|
|
||||||
|
The current implementation 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 a specific position was liquidated.
|
||||||
|
- This is an alerting aid, not a substitute for placing an on-platform stop-loss or reducing leverage.
|
||||||
+105
-6
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("java")
|
id("java")
|
||||||
|
id("application")
|
||||||
id("maven-publish")
|
id("maven-publish")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ if (version == "UNSET" && gradle.startParameter.taskNames.any { it.startsWith("p
|
|||||||
throw GradleException("You must set -Pversion=... (use publish.sh / publishCICD.sh)")
|
throw GradleException("You must set -Pversion=... (use publish.sh / publishCICD.sh)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("com.r35157.nenjim.hubd.impl.ref.Main")
|
||||||
|
applicationDefaultJvmArgs = listOf("--enable-preview")
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
|
|
||||||
@@ -27,18 +33,111 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val detag by configurations.creating {
|
||||||
|
isCanBeConsumed = false
|
||||||
|
isCanBeResolved = true
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.jetbrains:annotations:26.0.1")
|
detag("com.r35157.tools:detag-impl_ref:0.1.0")
|
||||||
implementation("log4j:log4j:1.2.17")
|
compileOnly("org.jetbrains:annotations:26.1.0")
|
||||||
implementation("com.r35157.nenjim:hubd-api:0.0.0")
|
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")
|
||||||
|
implementation("commons-codec:commons-codec:1.22.0")
|
||||||
|
implementation("org.apache.commons:commons-collections4:4.5.0")
|
||||||
|
implementation("org.apache.commons:commons-lang3:3.20.0")
|
||||||
|
implementation("org.slf4j:slf4j-api:2.0.18")
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain { languageVersion.set(JavaLanguageVersion.of(24)) }
|
toolchain { languageVersion.set(JavaLanguageVersion.of(25)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<JavaCompile> {
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
options.release.set(24)
|
options.release.set(25)
|
||||||
|
|
||||||
|
options.compilerArgs.addAll(
|
||||||
|
listOf(
|
||||||
|
"--enable-preview",
|
||||||
|
//"-Xlint:deprecation",
|
||||||
|
//"-Xlint:unchecked",
|
||||||
|
"-Xmaxerrs", "1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val generatedDetagMain = layout.buildDirectory.dir("generated/sources/detag/main/java")
|
||||||
|
|
||||||
|
val cleanGeneratedDetagMain by tasks.registering(Delete::class) {
|
||||||
|
delete(generatedDetagMain)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detagMain by tasks.registering(JavaExec::class) {
|
||||||
|
group = "build"
|
||||||
|
description = "Generates Java sources from .tjava files"
|
||||||
|
|
||||||
|
classpath = detag
|
||||||
|
mainClass.set("com.r35157.tools.detag.impl.ref.Main")
|
||||||
|
|
||||||
|
val configFile = layout.projectDirectory.file("../detag.conf")
|
||||||
|
val sourceRoot = layout.projectDirectory.dir("src/main/tjava")
|
||||||
|
|
||||||
|
inputs.file(configFile)
|
||||||
|
inputs.dir(sourceRoot)
|
||||||
|
outputs.dir(generatedDetagMain)
|
||||||
|
|
||||||
|
dependsOn(cleanGeneratedDetagMain)
|
||||||
|
|
||||||
|
args(
|
||||||
|
"--config", configFile.asFile.absolutePath,
|
||||||
|
"--source-root", sourceRoot.asFile.absolutePath,
|
||||||
|
"--out", generatedDetagMain.get().asFile.absolutePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
// Human-written Detag source files. IntelliJ should treat this as a source root.
|
||||||
|
// Gradle's Java compiler will still only compile .java files directly from sourceSets,
|
||||||
|
// so the .tjava files are not compiled directly.
|
||||||
|
java.srcDir("src/main/tjava")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<JavaCompile>("compileJava") {
|
||||||
|
dependsOn(detagMain)
|
||||||
|
|
||||||
|
// Compiler input generated from src/main/tjava.
|
||||||
|
// Do not add this directory to sourceSets, or IntelliJ will see duplicate classes:
|
||||||
|
// MyClass.tjava + build/generated/.../MyClass.java.
|
||||||
|
source(generatedDetagMain)
|
||||||
|
}
|
||||||
|
|
||||||
|
val libsDir = layout.buildDirectory.dir("libs")
|
||||||
|
|
||||||
|
tasks.register<Sync>("prepareLibs") {
|
||||||
|
group = "distribution"
|
||||||
|
description = "Copies runtime deps to build/libs without deleting the app jar"
|
||||||
|
|
||||||
|
val jarTask = tasks.named<Jar>("jar")
|
||||||
|
dependsOn(jarTask)
|
||||||
|
|
||||||
|
into(libsDir)
|
||||||
|
|
||||||
|
// Kun deps (transitivt)
|
||||||
|
from(configurations.runtimeClasspath)
|
||||||
|
|
||||||
|
// Bevar jar-filen som jar-tasken allerede har lagt i build/libs
|
||||||
|
preserve {
|
||||||
|
include(jarTask.get().archiveFileName.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
||||||
|| git symbolic-ref --short -q HEAD \
|
|| git symbolic-ref --short -q HEAD \
|
||||||
|| git rev-parse --short HEAD)
|
|| git rev-parse --short HEAD)
|
||||||
|
|
||||||
echo "Building $VERSION..."
|
GITHASH=$(git rev-parse --short=8 HEAD)
|
||||||
./gradlew -Pversion=$VERSION jar
|
|
||||||
|
export VERSION_LONG=${VERSION}_${GITHASH}
|
||||||
|
|
||||||
|
echo "Building 'NenjimHub v${VERSION_LONG}'..."
|
||||||
|
|
||||||
|
./gradlew \
|
||||||
|
-Pversion=${VERSION} \
|
||||||
|
jar \
|
||||||
|
prepareLibs
|
||||||
@@ -1 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
./gradlew clean ; rm -rf build
|
./gradlew clean ; rm -rf build
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Id Asset Direction Target Trigger Severity Note
|
||||||
|
######################################################################################################################
|
||||||
|
1 SOL ABOVE 97.03-1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana short LIKVIDERING!"
|
||||||
|
2 SOL BELOW 48.72+1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana long 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!"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Iteration interval in seconds
|
||||||
|
3600
|
||||||
|
|
||||||
|
# SYRUP owned by Evelyn
|
||||||
|
# = 1270,19474 + 85,22 + 27,00540296 + 256,873447 + 83,7839
|
||||||
|
1723.07749
|
||||||
|
|
||||||
|
# Solana address for Evelyn IOU
|
||||||
|
vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf
|
||||||
|
|
||||||
|
# Solana address for Evelyn IOU Burner
|
||||||
|
5kjVTc1rebYHg56NyTxSSN1XMKqT2E81eWiaSx1CmzmV
|
||||||
|
|
||||||
|
# Raydium Pool Id
|
||||||
|
CndJzCXspuk39cwGAA43h3EtHJeHVZ81syt5bTh9dG3E
|
||||||
|
|
||||||
|
# Curve width in percent in both directions
|
||||||
|
0.05
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Configuration status="WARN">
|
||||||
|
<Appenders>
|
||||||
|
<RollingFile name="File"
|
||||||
|
fileName="logs/NenjimHub.log"
|
||||||
|
filePattern="logs/NenjimHub-%d{yyyy-MM-dd}-%i.log.gz">
|
||||||
|
<PatternLayout pattern="%d{ISO8601} %-5p %c - %m%n"/>
|
||||||
|
|
||||||
|
<Policies>
|
||||||
|
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
||||||
|
<SizeBasedTriggeringPolicy size="50 MB"/>
|
||||||
|
</Policies>
|
||||||
|
|
||||||
|
<DefaultRolloverStrategy max="14"/>
|
||||||
|
</RollingFile>
|
||||||
|
</Appenders>
|
||||||
|
|
||||||
|
<Loggers>
|
||||||
|
<Root level="info">
|
||||||
|
<AppenderRef ref="File"/>
|
||||||
|
</Root>
|
||||||
|
</Loggers>
|
||||||
|
</Configuration>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# MintA = Solana
|
||||||
|
# MintB = SyrupUSDC
|
||||||
|
#
|
||||||
|
# NFTId ; AddedMintA ; AddedMintB ; BorrowedMintA ; BorrowedMintB
|
||||||
|
89LikiLxkj1m9dU2SVLnzj2Dky3qrFNUbwZYyrVNSzpt;0;5;0;0 # 63.681428 - 64.064646
|
||||||
|
X7bKgeVRaRuM2QrkcxGbGiuzz9YzR25mxpg43CbLLPB;0;5;0;0 # 64.064646 - 64.450170
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
jars=(libs/*.jar)
|
||||||
|
if (( ${#jars[@]} == 0 )); then
|
||||||
|
echo "ERROR: No JARs found in libs/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLASSPATH=$(IFS=:; echo "${jars[*]}")
|
||||||
|
|
||||||
|
DEBUG_PORT="${DEBUG_PORT:-5005}"
|
||||||
|
DEBUG_SUSPEND="${DEBUG_SUSPEND:-y}"
|
||||||
|
|
||||||
|
echo "Starting NenjimHub in DEBUG mode on port ${DEBUG_PORT} (suspend=${DEBUG_SUSPEND})"
|
||||||
|
|
||||||
|
exec java \
|
||||||
|
--enable-preview \
|
||||||
|
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND},address=*:${DEBUG_PORT}" \
|
||||||
|
-Dlog4j.configurationFile=conf/log4j2.xml \
|
||||||
|
-cp "$CLASSPATH" \
|
||||||
|
com.r35157.nenjim.hubd.impl.ref.Main
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
org.gradle.java.installations.auto-detect=true
|
org.gradle.java.installations.auto-detect=true
|
||||||
org.gradle.java.installations.fromEnv=JAVA_HOME
|
org.gradle.java.installations.fromEnv=JAVA_HOME
|
||||||
org.gradle.java.installations.paths=/usr/local/software/java/jfx-24
|
org.gradle.java.installations.paths=/usr/local/software/java/jfx-25
|
||||||
org.gradle.java.installations.auto-download=false
|
org.gradle.java.installations.auto-download=false
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
||||||
|| git symbolic-ref --short -q HEAD \
|
|| git symbolic-ref --short -q HEAD \
|
||||||
|| git rev-parse --short HEAD)
|
|| git rev-parse --short HEAD)
|
||||||
|
|
||||||
echo "Building and publishing $VERSION..."
|
echo "Publishing artifact to local Maven repo ($VERSION)..."
|
||||||
./gradlew -Pversion=$VERSION publishToMavenLocal
|
./gradlew -Pversion=$VERSION publishToMavenLocal
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
|
||||||
|| git symbolic-ref --short -q HEAD \
|
|
||||||
|| git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
echo "Building and publishing (CI/CD) $VERSION..."
|
|
||||||
./gradlew -Pversion=$VERSION publish
|
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
publish.sh
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
jars=(libs/*.jar)
|
||||||
|
if (( ${#jars[@]} == 0 )); then
|
||||||
|
echo "ERROR: No JARs found in libs/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLASSPATH=$(IFS=:; echo "${jars[*]}")
|
||||||
|
|
||||||
|
exec java \
|
||||||
|
--enable-preview \
|
||||||
|
-Dlog4j.configurationFile=conf/log4j2.xml \
|
||||||
|
-cp "$CLASSPATH" \
|
||||||
|
com.r35157.nenjim.hubd.impl.ref.Main
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
rootProject.name = "hubd-impl-ref"
|
rootProject.name = "hubd-impl_ref"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.r35157.nenjim.hubd.impl.ref;
|
|
||||||
|
|
||||||
import com.r35157.nenjim.hubd.SomeInterface;
|
|
||||||
|
|
||||||
public class SomeImpl implements SomeInterface {
|
|
||||||
public String concat(String x, String y) {
|
|
||||||
return x + y;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void divideByZero() throws ArithmeticException {
|
|
||||||
int a = 0/0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fanitas.evelyn.core;
|
||||||
|
|
||||||
|
import com.r35157.libs.basic.Pair;
|
||||||
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
public interface DesiredPositionCalculator {
|
||||||
|
|
||||||
|
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> calculateRebalancingProposal(
|
||||||
|
ΩSyrupPriceΩ currentPrice,
|
||||||
|
ΩSolanaAmountΩ totalReadyAmountMintA,
|
||||||
|
ΩSyrupAmountΩ totalReadyAmountMintB
|
||||||
|
);
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateTotalDistributedSums();
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateLockedSums(ΩSyrupPriceΩ currentPrice);
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateRedistributableSums(
|
||||||
|
ΩSyrupPriceΩ currentPrice,
|
||||||
|
ΩSolanaAmountΩ inactiveInAccountMintA,
|
||||||
|
ΩSolanaAmountΩ reservedForBurnMintA,
|
||||||
|
ΩSyrupAmountΩ inactiveInAccountMintB,
|
||||||
|
ΩSyrupAmountΩ reservedForBurnMintB
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.fanitas.evelyn.core;
|
||||||
|
|
||||||
|
public interface Evelyn {
|
||||||
|
void executeService() throws Exception;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.fanitas.evelyn.core;
|
||||||
|
|
||||||
|
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface State {
|
||||||
|
void update() throws IOException, InterruptedException;
|
||||||
|
ΩmilliSecondsΩ getIterationInterval();
|
||||||
|
ΩSolanaAmountΩ getSyrupOwnedByEvelyn();
|
||||||
|
ΩSolanaAddressΩ getSolanaAddressForEvelynIOU();
|
||||||
|
ΩSolanaAddressΩ getSolanaAddressForEvelynIOUBurner();
|
||||||
|
ΩRaydiumLiquidityPoolIdΩ getRaydiumPoolId();
|
||||||
|
ΩCurveWidthΩ getCurveWidth();
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> getLiquidityPositions();
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package com.fanitas.evelyn.core.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.libs.basic.Pair;
|
||||||
|
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||||
|
import com.fanitas.evelyn.core.DesiredPositionCalculator;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.*;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static com.fanitas.evelyn.math.BigDecimalUtils.*;
|
||||||
|
import static java.math.BigDecimal.ONE;
|
||||||
|
import static java.math.BigDecimal.ZERO;
|
||||||
|
|
||||||
|
public class DesiredPositionCalculatorImpl implements DesiredPositionCalculator {
|
||||||
|
|
||||||
|
public DesiredPositionCalculatorImpl(
|
||||||
|
ΩCurveWidthΩ curveWidth,
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions
|
||||||
|
) {
|
||||||
|
this.curveWidth = curveWidth;
|
||||||
|
this.liquidityProviderPositions = liquidityProviderPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> calculateRebalancingProposal(
|
||||||
|
ΩSyrupPriceΩ currentPrice,
|
||||||
|
ΩSolanaAmountΩ totalReadyAmountMintA,
|
||||||
|
ΩSyrupAmountΩ totalReadyAmountMintB
|
||||||
|
) {
|
||||||
|
List<ΩSyrupPriceΩ> intervalStarts = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||||
|
List<ΩSyrupPriceΩ> intervalEnds = getSortedPositionIntervalToValues(liquidityProviderPositions);
|
||||||
|
|
||||||
|
int indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice = lookupIndexOfLastLessThanOrEqual(
|
||||||
|
intervalStarts,
|
||||||
|
currentPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
ΩSyrupPriceΩ lastLessThanOrEqual = intervalEnds.get(indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice);
|
||||||
|
HashMap<ΩRaydiumLiquidityPoolPositionIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> desiredPositions = new HashMap<>();
|
||||||
|
|
||||||
|
boolean beforeNonZeroPos = true;
|
||||||
|
|
||||||
|
for (int i = 0; i < intervalStarts.size(); i++) {
|
||||||
|
ΩSyrupPriceΩ iStart = intervalStarts.get(i);
|
||||||
|
ΩSyrupPriceΩ iEnd = intervalEnds.get(i);
|
||||||
|
|
||||||
|
ΩSolanaAmountΩ desiredSolanaPosSize = null; // TODO: Support this
|
||||||
|
ΩSyrupAmountΩ desiredSyrupPosSize = calculateDesiredPositionSizeForInterval(
|
||||||
|
currentPrice,
|
||||||
|
totalReadyAmountMintB,
|
||||||
|
iStart,
|
||||||
|
iEnd,
|
||||||
|
lastLessThanOrEqual
|
||||||
|
);
|
||||||
|
|
||||||
|
int desiredPositionSign = desiredSyrupPosSize.amount().compareTo(ZERO);
|
||||||
|
|
||||||
|
if (desiredPositionSign > 0) {
|
||||||
|
beforeNonZeroPos = false;
|
||||||
|
} else if (!beforeNonZeroPos && desiredPositionSign == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> desiredPositionSizes =
|
||||||
|
new Pair<>(desiredSolanaPosSize, desiredSyrupPosSize);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated position = getPositionByStartPriceA(iStart);
|
||||||
|
// TODO: What if null is return above?
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ poolPositionId = position.nftId();
|
||||||
|
|
||||||
|
desiredPositions.put(poolPositionId, desiredPositionSizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return desiredPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateTotalDistributedSums() {
|
||||||
|
Collection<RaydiumLiquidityPoolPositionConcentrated> c = liquidityProviderPositions.values();
|
||||||
|
|
||||||
|
if(c.isEmpty()) {
|
||||||
|
// TODO: I do not like this - I prefer 0 - but without any positions, do I then know the currencyType?
|
||||||
|
throw new IllegalStateException("No positions in pool!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This smells - setting to null. Oh dear!
|
||||||
|
CurrencyType ctA = null;
|
||||||
|
ΩAmountΩ amountA = ZERO;
|
||||||
|
CurrencyType ctB = null;
|
||||||
|
ΩAmountΩ amountB = ZERO;
|
||||||
|
|
||||||
|
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||||
|
ctA = position.amountMintA().currencyType(); // TODO: Too redundant - please rethink
|
||||||
|
amountA = amountA.add(position.accountingInfo().addedMintA());
|
||||||
|
ctB = position.amountMintB().currencyType(); // TODO: Too redundant - please rethink
|
||||||
|
amountB = amountB.add(position.accountingInfo().addedMintB());
|
||||||
|
}
|
||||||
|
|
||||||
|
MoneyAmount ma = new MoneyAmount(amountA, ctA);
|
||||||
|
MoneyAmount mb = new MoneyAmount(amountB, ctB);
|
||||||
|
|
||||||
|
return new Pair<>(ma, mb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateLockedSums(ΩSyrupPriceΩ currentPrice) {
|
||||||
|
List<ΩSyrupPriceΩ> ascendingPrices = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||||
|
ΩAmountΩ sumA = ZERO;
|
||||||
|
ΩAmountΩ sumB = ZERO;
|
||||||
|
|
||||||
|
// TODO: This smells - setting to null. Oh dear!
|
||||||
|
CurrencyType ctA = null;
|
||||||
|
CurrencyType ctB = null;
|
||||||
|
|
||||||
|
int index = lookupIndexOfFirstGreaterThanOrEqual(ascendingPrices, currentPrice);
|
||||||
|
if(index >= 0) {
|
||||||
|
AssetPrice startPriceOfStartPos = ascendingPrices.get(index);
|
||||||
|
|
||||||
|
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||||
|
if (position.priceRange().from().compareTo(startPriceOfStartPos) >= 0) {
|
||||||
|
ctA = position.amountMintA().currencyType();
|
||||||
|
sumA = sumA.add(position.amountMintA().amount());
|
||||||
|
ctB = position.amountMintB().currencyType();
|
||||||
|
sumB = sumB.add(position.amountMintB().amount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MoneyAmount ma = new MoneyAmount(sumA, ctA);
|
||||||
|
MoneyAmount mb = new MoneyAmount(sumB, ctB);
|
||||||
|
|
||||||
|
return new Pair<>(ma, mb);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateRedistributableSums(
|
||||||
|
ΩSyrupPriceΩ currentPrice,
|
||||||
|
ΩSolanaAmountΩ inactiveInAccountMintA,
|
||||||
|
ΩSolanaAmountΩ reservedForBurnMintA,
|
||||||
|
ΩSyrupAmountΩ inactiveInAccountMintB,
|
||||||
|
ΩSyrupAmountΩ reservedForBurnMintB
|
||||||
|
) {
|
||||||
|
List<AssetPrice> ascendingValues = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||||
|
ΩAmountΩ redistSumA = ZERO;
|
||||||
|
ΩAmountΩ redistSumB = ZERO;
|
||||||
|
CurrencyType ctA = null;
|
||||||
|
CurrencyType ctB = null;
|
||||||
|
|
||||||
|
int index = lookupIndexOfLastLessThan(ascendingValues, currentPrice);
|
||||||
|
if(index >= 0) {
|
||||||
|
AssetPrice startPriceOfStartPos = ascendingValues.get(index);
|
||||||
|
|
||||||
|
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||||
|
if (position.priceRange().from().compareTo(startPriceOfStartPos) <= 0) {
|
||||||
|
ctA = position.amountMintA().currencyType();
|
||||||
|
ctB = position.amountMintB().currencyType();
|
||||||
|
redistSumA = redistSumA.add(position.amountMintA().amount());
|
||||||
|
redistSumB = redistSumB.add(position.amountMintB().amount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redistSumA = add(inactiveInAccountMintA, redistSumA);
|
||||||
|
redistSumA = subtract(redistSumA, reservedForBurnMintA);
|
||||||
|
redistSumB = add(inactiveInAccountMintB, redistSumB);
|
||||||
|
redistSumB = subtract(redistSumB, reservedForBurnMintB);
|
||||||
|
MoneyAmount ma = new MoneyAmount(redistSumA, ctA);
|
||||||
|
MoneyAmount mb = new MoneyAmount(redistSumB, ctB);
|
||||||
|
|
||||||
|
return new Pair<>(ma, mb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount add(ΩAmountΩ a, ΩAmountΩ b, CurrencyType ct) {
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.add(b), ct);
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount add(MoneyAmount a, MoneyAmount b) {
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.amount().add(b.amount()), a.currencyType());
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ add(MoneyAmount a, ΩAmountΩ b) {
|
||||||
|
return a.amount().add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) {
|
||||||
|
return a.subtract(b.amount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private @Nullable RaydiumLiquidityPoolPositionConcentrated getPositionByStartPriceA(@NotNull AssetPrice startPriceA) {
|
||||||
|
for(RaydiumLiquidityPoolPositionConcentrated candidate : liquidityProviderPositions.values()) {
|
||||||
|
if (candidate.priceRange().from().compareTo(startPriceA) == 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩSyrupAmountΩ calculateDesiredPositionSizeForInterval(
|
||||||
|
@NotNull ΩSyrupPriceΩ currentPrice,
|
||||||
|
@NotNull ΩSolanaAmountΩ totalSyrupToDistribution,
|
||||||
|
@NotNull ΩSyrupPriceΩ intervalPriceFrom,
|
||||||
|
@NotNull ΩSyrupPriceΩ intervalPriceTo,
|
||||||
|
@NotNull ΩSyrupPriceΩ lookupPrice
|
||||||
|
) {
|
||||||
|
ΩPriceΩ curveStartPrice = currentPrice.price().multiply(
|
||||||
|
ONE.subtract(curveWidth, MC),
|
||||||
|
MC
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean intervalIsCompletelyBeforeCurve =
|
||||||
|
intervalPriceTo.price().compareTo(curveStartPrice) <= 0;
|
||||||
|
|
||||||
|
boolean intervalIsCompletelyAfterLookupLimit =
|
||||||
|
intervalPriceFrom.price().compareTo(lookupPrice.price()) >= 0;
|
||||||
|
|
||||||
|
if (intervalIsCompletelyBeforeCurve || intervalIsCompletelyAfterLookupLimit) {
|
||||||
|
return new ΩSyrupAmountΩ(ZERO, WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType());
|
||||||
|
}
|
||||||
|
|
||||||
|
ΩPriceΩ effectiveIntervalFrom = max(intervalPriceFrom.price(), curveStartPrice);
|
||||||
|
ΩPriceΩ effectiveIntervalTo = min(intervalPriceTo.price(), lookupPrice.price());
|
||||||
|
|
||||||
|
BigDecimal sigma = currentPrice.price().multiply(curveWidth, MC)
|
||||||
|
.divide(THREE, MC);
|
||||||
|
|
||||||
|
BigDecimal sqrtTwo = sqrt(TWO, MC);
|
||||||
|
BigDecimal denominator = sigma.multiply(sqrtTwo, MC);
|
||||||
|
|
||||||
|
ΩPriceΩ normalizedEffectiveTo = effectiveIntervalTo.subtract(currentPrice.price(), MC)
|
||||||
|
.divide(denominator, MC);
|
||||||
|
|
||||||
|
ΩPriceΩ normalizedEffectiveFrom = effectiveIntervalFrom.subtract(currentPrice.price(), MC)
|
||||||
|
.divide(denominator, MC);
|
||||||
|
|
||||||
|
ΩPriceΩ normalizedLookupPrice = lookupPrice.price().subtract(currentPrice.price(), MC)
|
||||||
|
.divide(denominator, MC);
|
||||||
|
|
||||||
|
ΩPriceΩ normalizedCurveStart = curveStartPrice.subtract(currentPrice.price(), MC)
|
||||||
|
.divide(denominator, MC);
|
||||||
|
|
||||||
|
BigDecimal intervalWeight = new BigDecimal(erf(normalizedEffectiveTo) - erf(normalizedEffectiveFrom));
|
||||||
|
BigDecimal totalWeightInActiveCurve = new BigDecimal(erf(normalizedLookupPrice) - erf(normalizedCurveStart));
|
||||||
|
|
||||||
|
ΩSyrupAmountΩ dps = new ΩSyrupAmountΩ(
|
||||||
|
totalSyrupToDistribution.amount()
|
||||||
|
.multiply(intervalWeight, MC)
|
||||||
|
.divide(totalWeightInActiveCurve, MC),
|
||||||
|
WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType()
|
||||||
|
);
|
||||||
|
|
||||||
|
return dps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lookupIndexOfFirstGreaterThan(
|
||||||
|
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||||
|
@NotNull ΩSyrupPriceΩ searchPrice
|
||||||
|
) {
|
||||||
|
int index = -1;
|
||||||
|
|
||||||
|
for (int i = ascendingPrices.size() - 1; i >= 0; i--) {
|
||||||
|
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||||
|
if (p.compareTo(searchPrice.price()) <= 0) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == -1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"No position has a greater start price than a position containing '" + searchPrice + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lookupIndexOfFirstGreaterThanOrEqual(
|
||||||
|
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||||
|
@NotNull ΩSyrupPriceΩ searchPrice
|
||||||
|
) {
|
||||||
|
int index = -1;
|
||||||
|
|
||||||
|
for (int i = ascendingPrices.size() - 1; i >= 0; i--) {
|
||||||
|
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||||
|
if (p.compareTo(searchPrice.price()) <= 0) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == -1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"No position has a greater start price than a position containing '" + searchPrice + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lookupIndexOfLastLessThanOrEqual(
|
||||||
|
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||||
|
@NotNull ΩSyrupPriceΩ searchPrice
|
||||||
|
) {
|
||||||
|
int index = -1;
|
||||||
|
for (int i = 0; i < ascendingPrices.size() - 1; i++) {
|
||||||
|
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||||
|
if (p.compareTo(searchPrice.price()) < 0) {
|
||||||
|
index = i - 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lookupIndexOfLastLessThan(
|
||||||
|
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||||
|
@NotNull ΩSyrupPriceΩ searchPrice
|
||||||
|
) {
|
||||||
|
int index = -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < ascendingPrices.size() - 1; i++) {
|
||||||
|
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||||
|
if (p.compareTo(searchPrice.price()) < 0) {
|
||||||
|
index = i - 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NotNull List<ΩSyrupPriceΩ> getSortedPositionIntervalFromValues(
|
||||||
|
@NotNull Map<ΩRaydiumLiquidityPoolPositionNftIdΩ,
|
||||||
|
@NotNull RaydiumLiquidityPoolPositionConcentrated> liquidityPositions
|
||||||
|
) {
|
||||||
|
List<ΩSyrupPriceΩ> result = new ArrayList<>(liquidityPositions.size());
|
||||||
|
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) {
|
||||||
|
result.add(position.priceRange().from());
|
||||||
|
}
|
||||||
|
result.sort(ΩSyrupPriceΩ::compareTo);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NotNull List<ΩSyrupPriceΩ> getSortedPositionIntervalToValues(
|
||||||
|
@NotNull Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions
|
||||||
|
) {
|
||||||
|
List<ΩSyrupPriceΩ> result = new ArrayList<>(liquidityPositions.size());
|
||||||
|
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) {
|
||||||
|
result.add(position.priceRange().to());
|
||||||
|
}
|
||||||
|
result.sort(ΩSyrupPriceΩ::compareTo);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final MathContext MC = new MathContext(20, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
private final ΩCurveWidthΩ curveWidth;
|
||||||
|
private final Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package com.fanitas.evelyn.core.impl.ref;
|
||||||
|
|
||||||
|
import com.fanitas.evelyn.core.*;
|
||||||
|
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||||
|
|
||||||
|
import com.r35157.libs.basic.Pair;
|
||||||
|
import com.r35157.libs.basic.Triblet;
|
||||||
|
import com.r35157.libs.raydium.Raydium;
|
||||||
|
import com.r35157.libs.raydium.RaydiumLiquidityPoolPrice;
|
||||||
|
import com.r35157.libs.solana.SPLTokenHolding;
|
||||||
|
import com.r35157.libs.solana.SolanaBlockChain;
|
||||||
|
import com.r35157.libs.solana.SolanaConstants;
|
||||||
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
import com.r35157.libs.valuetypes.basic.TradingPair;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
|
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SOLANA;
|
||||||
|
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SYRUPUSDC;
|
||||||
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
||||||
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
||||||
|
|
||||||
|
public class EvelynImpl implements Evelyn {
|
||||||
|
|
||||||
|
public EvelynImpl(
|
||||||
|
State state,
|
||||||
|
DesiredPositionCalculator desiredPositionCalculator,
|
||||||
|
Raydium raydium,
|
||||||
|
SolanaBlockChain solanaChain
|
||||||
|
) {
|
||||||
|
this.state = state;
|
||||||
|
this.desiredPositionCalculator = desiredPositionCalculator;
|
||||||
|
this.raydium = raydium;
|
||||||
|
this.solanaChain = solanaChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void executeService() throws Exception {
|
||||||
|
handleRebalancingProposal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SPLTokenHolding getSPLHolding(ΩSolanaAddressΩ ownerAddress, ΩSPLMintAddressΩ splMintAddress) throws Exception {
|
||||||
|
Map<ΩSPLMintAddressΩ, SPLTokenHolding> holdings = new HashMap<>();
|
||||||
|
|
||||||
|
holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, SPL_TOKEN_PROGRAM));
|
||||||
|
holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, TOKEN_2022_PROGRAM));
|
||||||
|
|
||||||
|
SPLTokenHolding result = holdings.get(splMintAddress);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount add(MoneyAmount a, ΩAmountΩ b) {
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.amount().add(b), a.currencyType());
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount add(MoneyAmount a, MoneyAmount b) {
|
||||||
|
if(a.currencyType() != b.currencyType()) {
|
||||||
|
String errTxt = "Cannot add " + b.currencyType()
|
||||||
|
+ " to " + a.currencyType();
|
||||||
|
throw new IllegalArgumentException(errTxt);
|
||||||
|
}
|
||||||
|
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.amount().add(b.amount()), a.currencyType());
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount subtract(MoneyAmount a, MoneyAmount b) {
|
||||||
|
if(a.currencyType() != b.currencyType()) {
|
||||||
|
String errTxt = "Cannot subtract " + b.currencyType()
|
||||||
|
+ " from " + a.currencyType();
|
||||||
|
throw new IllegalArgumentException(errTxt);
|
||||||
|
}
|
||||||
|
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.amount().subtract(b.amount()), a.currencyType());
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount subtract(MoneyAmount a, ΩAmountΩ b) {
|
||||||
|
MoneyAmount ma = new MoneyAmount(a.amount().subtract(b), a.currencyType());
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ subtract(ΩAmountΩ a, ΩAmountΩ b) {
|
||||||
|
return a.subtract(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) {
|
||||||
|
return a.subtract(b.amount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRebalancingProposal() {
|
||||||
|
try {
|
||||||
|
ΩSolanaAddressΩ solanaAddressForEvelynIOU = state.getSolanaAddressForEvelynIOU();
|
||||||
|
ΩSPLMintAddressΩ syrupUSDCMintAddr = SolanaConstants.SPL_TOKEN_SYRUPUSDC;
|
||||||
|
|
||||||
|
AssetPrice currentPriceFromRaydium;
|
||||||
|
SPLTokenHolding syrupHolding = getSPLHolding(solanaAddressForEvelynIOU, syrupUSDCMintAddr);
|
||||||
|
|
||||||
|
ΩSyrupAmountΩ inactiveInAccountSyrup = new ΩSyrupAmountΩ(
|
||||||
|
syrupHolding.uiAmount(),
|
||||||
|
SYRUPUSDC.getCurrencyType()
|
||||||
|
);
|
||||||
|
|
||||||
|
while(true) {
|
||||||
|
state.update();
|
||||||
|
ΩmilliSecondsΩ iterationStartTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
RaydiumLiquidityPoolPrice concentratedResult;
|
||||||
|
Future<RaydiumLiquidityPoolPrice> cFuture = executor.submit(
|
||||||
|
() -> new RaydiumLiquidityPoolPrice(
|
||||||
|
state.getRaydiumPoolId(),
|
||||||
|
raydium.fetchPoolPrice(state.getRaydiumPoolId())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
concentratedResult = cFuture.get();
|
||||||
|
currentPriceFromRaydium = concentratedResult.price();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Iteration interval: " + (state.getIterationInterval() / 1000) + " secs");
|
||||||
|
|
||||||
|
System.out.println("Solana balances:");
|
||||||
|
ΩSolanaAmountΩ solBalanceEvelynAccount = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOU());
|
||||||
|
System.out.println(" Evelyn account: " + solBalanceEvelynAccount.amount());
|
||||||
|
ΩSolanaAmountΩ solBalanceBurnAccount = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOUBurner());
|
||||||
|
System.out.println(" Burn account: " + solBalanceBurnAccount.amount());
|
||||||
|
System.out.println("SYRUP owned by Evelyn: " + state.getSyrupOwnedByEvelyn().amount());
|
||||||
|
System.out.println("SYRUP inactive on Evelyn account: " + inactiveInAccountSyrup);
|
||||||
|
|
||||||
|
System.out.println("Pool price: " + currentPriceFromRaydium);
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> totalDistributedSums =
|
||||||
|
desiredPositionCalculator.calculateTotalDistributedSums();
|
||||||
|
ΩSolanaAmountΩ totalDistributedSumSolana = totalDistributedSums.left();
|
||||||
|
ΩSyrupAmountΩ totalDistributedSumSyrup = totalDistributedSums.right();
|
||||||
|
System.out.println("Total amount currently distributed: " + totalDistributedSumSolana
|
||||||
|
+ " / " + totalDistributedSumSyrup);
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> amountsLocked =
|
||||||
|
desiredPositionCalculator.calculateLockedSums(currentPriceFromRaydium);
|
||||||
|
ΩSolanaAmountΩ amountLockedSolana = amountsLocked.left();
|
||||||
|
ΩSyrupAmountΩ amountLockedSyrup = amountsLocked.right();
|
||||||
|
System.out.println("Total amount locked due to HIGH price: " + amountLockedSolana);
|
||||||
|
System.out.println("Total amount locked due to LOW price: " + amountLockedSyrup);
|
||||||
|
|
||||||
|
ΩSyrupAmountΩ totalAmountSyrup = add(totalDistributedSumSyrup, inactiveInAccountSyrup);
|
||||||
|
ΩSyrupAmountΩ reservedForBurnSyrup = subtract(totalAmountSyrup, state.getSyrupOwnedByEvelyn());
|
||||||
|
|
||||||
|
ΩSolanaAmountΩ readyForBurnSolana = subtract(solBalanceEvelynAccount, SOFT_LOW_LIMIT_SOLANA_BALANCE);
|
||||||
|
System.out.println("Amount reserved for burn: "
|
||||||
|
+ "Solana: " + readyForBurnSolana
|
||||||
|
+ ", Syrup:" + reservedForBurnSyrup);
|
||||||
|
|
||||||
|
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> totalReadyAmount = desiredPositionCalculator
|
||||||
|
.calculateRedistributableSums(
|
||||||
|
currentPriceFromRaydium,
|
||||||
|
solBalanceEvelynAccount,
|
||||||
|
amountZeroSolana, // We do not burn Solana
|
||||||
|
inactiveInAccountSyrup,
|
||||||
|
reservedForBurnSyrup
|
||||||
|
);
|
||||||
|
ΩSolanaAmountΩ solTotalReadyAmount = totalReadyAmount.left();
|
||||||
|
ΩSyrupAmountΩ syrupTotalReadyAmount = totalReadyAmount.right();
|
||||||
|
System.out.println("Total amount of Syrup ready for distribution: " + syrupTotalReadyAmount);
|
||||||
|
|
||||||
|
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> rebalancingProposal =
|
||||||
|
desiredPositionCalculator.calculateRebalancingProposal(
|
||||||
|
currentPriceFromRaydium,
|
||||||
|
solTotalReadyAmount,
|
||||||
|
syrupTotalReadyAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions =
|
||||||
|
state.getLiquidityPositions();
|
||||||
|
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> diffs =
|
||||||
|
calculateDiffs(rebalancingProposal, liquidityPositions);
|
||||||
|
|
||||||
|
String minKey = diffs.entrySet().stream()
|
||||||
|
.min(Comparator.comparing(entry -> entry.getValue().right().amount()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
String maxKey = diffs.entrySet().stream()
|
||||||
|
.max(Comparator.comparing(entry -> entry.getValue().right().amount()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
System.out.println("Move value from (" + maxKey + "): ");
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated maxPos = liquidityPositions.get(maxKey);
|
||||||
|
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> maxDiff = diffs.get(maxKey);
|
||||||
|
System.out.println(" " + maxPos.priceRange().from() + ".."
|
||||||
|
+ maxPos.priceRange().to()
|
||||||
|
+ " - Current: " + maxDiff.left().amount()
|
||||||
|
+ ", Suggested: " + maxDiff.middle().amount()
|
||||||
|
+ ", Diff: " + maxDiff.right().amount()
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println(" --> ");
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated minPos = liquidityPositions.get(minKey);
|
||||||
|
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> minDiff = diffs.get(minKey);
|
||||||
|
System.out.println(" " + minPos.priceRange().from() + ".."
|
||||||
|
+ minPos.priceRange().to()
|
||||||
|
+ " - Current: " + minDiff.left().amount()
|
||||||
|
+ ", Suggested: " + minDiff.middle().amount()
|
||||||
|
+ ", Diff: " + minDiff.right().amount());
|
||||||
|
|
||||||
|
ΩmilliSecondsΩ iterationExecutionTime = (System.currentTimeMillis() - iterationStartTime);
|
||||||
|
|
||||||
|
for(ΩmilliSecondsΩ sleepTime = state.getIterationInterval() - iterationExecutionTime; sleepTime > 0; sleepTime -= 1000) {
|
||||||
|
System.out.print("\rSleeping for " + (sleepTime / 1000) + " secs... ");
|
||||||
|
System.out.flush();
|
||||||
|
Thread.sleep(1000L);
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("-------------------------------------------------------------");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> calculateDiffs(
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> rebalancingProposal,
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositionMap
|
||||||
|
) {
|
||||||
|
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> diffs =
|
||||||
|
new HashMap<>(rebalancingProposal.size());
|
||||||
|
|
||||||
|
for(ΩRaydiumLiquidityPoolPositionNftIdΩ nftId : rebalancingProposal.keySet()) {
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated pos = liquidityProviderPositionMap.get(nftId);
|
||||||
|
ΩSyrupAmountΩ currentlyAmountAdded = pos.amountMintB();
|
||||||
|
ΩSyrupAmountΩ suggestedAmount = rebalancingProposal.get(nftId).right();
|
||||||
|
ΩSyrupAmountΩ diff = subtract(currentlyAmountAdded, suggestedAmount);
|
||||||
|
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> t = new Triblet<>(
|
||||||
|
currentlyAmountAdded,
|
||||||
|
suggestedAmount,
|
||||||
|
diff
|
||||||
|
);
|
||||||
|
diffs.put(nftId, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final static ΩSolanaAmountΩ amountZeroSolana = new ΩSolanaAmountΩ(
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
SOLANA.getCurrencyType()
|
||||||
|
);
|
||||||
|
|
||||||
|
private final ΩAmountΩ SOFT_LOW_LIMIT_SOLANA_BALANCE = new BigDecimal("0.1");
|
||||||
|
|
||||||
|
private final State state;
|
||||||
|
private final DesiredPositionCalculator desiredPositionCalculator;
|
||||||
|
private final Raydium raydium;
|
||||||
|
private final SolanaBlockChain solanaChain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.fanitas.evelyn.core.impl.ref;
|
||||||
|
|
||||||
|
import com.fanitas.evelyn.core.Evelyn;
|
||||||
|
import com.fanitas.evelyn.core.DesiredPositionCalculator;
|
||||||
|
import com.fanitas.evelyn.core.State;
|
||||||
|
import com.r35157.libs.raydium.*;
|
||||||
|
import com.r35157.libs.raydium.impl.ref.RaydiumImpl;
|
||||||
|
import com.r35157.libs.solana.SPLTokenHolding;
|
||||||
|
import com.r35157.libs.solana.SPLTokenSupply;
|
||||||
|
import com.r35157.libs.solana.SolanaAccountInfo;
|
||||||
|
import com.r35157.libs.solana.SolanaBlockChain;
|
||||||
|
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
|
||||||
|
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||||
|
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
|
import com.r35157.libs.valuetypes.basic.Range;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class Main {
|
||||||
|
|
||||||
|
static void main() throws Exception {
|
||||||
|
log.info("Initializing Evelyn...");
|
||||||
|
SolanaBlockChain solanaChain = new SolanaBlockChainImpl();
|
||||||
|
Raydium raydium = new RaydiumImpl(solanaChain);
|
||||||
|
System.out.println(" Done.");
|
||||||
|
|
||||||
|
State state = new StateImpl(raydium);
|
||||||
|
DesiredPositionCalculator desiredPositionCalculator = new DesiredPositionCalculatorImpl(
|
||||||
|
state.getCurveWidth(),
|
||||||
|
state.getLiquidityPositions()
|
||||||
|
);
|
||||||
|
|
||||||
|
Evelyn evelyn = new EvelynImpl(
|
||||||
|
state,
|
||||||
|
desiredPositionCalculator,
|
||||||
|
raydium,
|
||||||
|
solanaChain
|
||||||
|
);
|
||||||
|
|
||||||
|
evelyn.executeService();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Main.class);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package com.fanitas.evelyn.core.impl.ref;
|
||||||
|
|
||||||
|
import com.fanitas.evelyn.core.State;
|
||||||
|
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionAccounting;
|
||||||
|
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||||
|
import com.r35157.libs.raydium.*;
|
||||||
|
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
|
||||||
|
import com.r35157.libs.valuetypes.basic.*;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class StateImpl implements State {
|
||||||
|
|
||||||
|
public StateImpl(Raydium raydium) throws IOException, InterruptedException {
|
||||||
|
this.raydium = raydium;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩmilliSecondsΩ getIterationInterval() {
|
||||||
|
return iterationInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩSyrupAmountΩ getSyrupOwnedByEvelyn() {
|
||||||
|
return syrupOwnedByEvelyn;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩSolanaAddressΩ getSolanaAddressForEvelynIOU() {
|
||||||
|
return solanaAddressForEvelynIOU;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩSolanaAddressΩ getSolanaAddressForEvelynIOUBurner() {
|
||||||
|
return solanaAddressForEvelynIOUBurner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩRaydiumLiquidityPoolIdΩ getRaydiumPoolId() {
|
||||||
|
return raydiumPoolId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getCurveWidth() {
|
||||||
|
return curveWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> getLiquidityPositions() {
|
||||||
|
return liquidityPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update() throws IOException, InterruptedException {
|
||||||
|
System.out.print("Updating from config file...");
|
||||||
|
updateStateFromPersistence();
|
||||||
|
System.out.println(" Done.");
|
||||||
|
System.out.print("Updating from Raydium...");
|
||||||
|
updateStateFromRaydium();
|
||||||
|
System.out.println(" Done.");
|
||||||
|
System.out.print("Updating from positions file...");
|
||||||
|
updateStateFromPositionsFile();
|
||||||
|
System.out.println(" Done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStateFromPersistence() throws IOException {
|
||||||
|
try (BufferedReader reader = Files.newBufferedReader(PATH_EVELYN_STATE, StandardCharsets.UTF_8)) {
|
||||||
|
iterationInterval = readIterationInterval(reader);
|
||||||
|
syrupOwnedByEvelyn = readSyrupOwnedByEvelyn(reader);
|
||||||
|
solanaAddressForEvelynIOU = readSolanaAddress(reader);
|
||||||
|
solanaAddressForEvelynIOUBurner = readSolanaAddress(reader);
|
||||||
|
raydiumPoolId = readRaydiumPoolId(reader);
|
||||||
|
curveWidth = readCurveWidth(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStateFromRaydium() throws IOException, InterruptedException {
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds =
|
||||||
|
raydium.fetchConcentratedPositionNftIds(solanaAddressForEvelynIOU);
|
||||||
|
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> positions =
|
||||||
|
new HashMap<>();
|
||||||
|
|
||||||
|
System.out.println("Detected " + positionNftIds.size() + " position(s)...");
|
||||||
|
for (ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId : positionNftIds) {
|
||||||
|
RaydiumConcentratedPositionState positionState =
|
||||||
|
raydium.fetchConcentratedPositionState(positionNftId);
|
||||||
|
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo =
|
||||||
|
raydium.fetchConcentratedPoolInfo(positionState.poolId());
|
||||||
|
|
||||||
|
RaydiumConcentratedPoolState poolState =
|
||||||
|
raydium.fetchConcentratedPoolState(positionState.poolId());
|
||||||
|
|
||||||
|
CurrencyType ctSol = WellKnownCurrencyTypes.SOLANA.getCurrencyType();
|
||||||
|
CurrencyType ctSyrup = WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType();
|
||||||
|
|
||||||
|
int mintBDecimals = poolInfo.mintBDecimals();
|
||||||
|
Range<AssetPrice> priceRange = raydium.calculateConcentratedPositionPriceRange(
|
||||||
|
positionState,
|
||||||
|
poolInfo,
|
||||||
|
mintBDecimals
|
||||||
|
);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolTokenAmounts tokenAmounts =
|
||||||
|
raydium.calculateConcentratedPositionTokenAmounts(
|
||||||
|
positionState,
|
||||||
|
poolInfo,
|
||||||
|
poolState
|
||||||
|
);
|
||||||
|
|
||||||
|
MoneyAmount solAmount = new MoneyAmount(tokenAmounts.amountA(), ctSol);
|
||||||
|
MoneyAmount syrupAmount = new MoneyAmount(tokenAmounts.amountB(), ctSyrup);
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated position =
|
||||||
|
new RaydiumLiquidityPoolPositionConcentrated(
|
||||||
|
positionState.poolId(),
|
||||||
|
positionNftId,
|
||||||
|
priceRange,
|
||||||
|
solAmount,
|
||||||
|
syrupAmount,
|
||||||
|
null // The accounting info will be added from 'conf/positions.conf' later.
|
||||||
|
);
|
||||||
|
|
||||||
|
positions.put(positionNftId, position);
|
||||||
|
System.out.println(" Added '" + position.nftId() + "': "
|
||||||
|
+ "Range:" + position.priceRange()
|
||||||
|
+ ", Liquidity:" + solAmount + "," + syrupAmount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
liquidityPositions = Map.copyOf(positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStateFromPositionsFile() throws IOException {
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> accountingEntries =
|
||||||
|
readPositionAccounting();
|
||||||
|
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> updatedEntries =
|
||||||
|
new HashMap<>(liquidityPositions);
|
||||||
|
|
||||||
|
for (RaydiumLiquidityPoolPositionAccounting accountingEntry : accountingEntries.values()) {
|
||||||
|
String nftId = accountingEntry.nftId();
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated position = updatedEntries.get(nftId);
|
||||||
|
|
||||||
|
if (position == null) {
|
||||||
|
System.out.println(
|
||||||
|
"WARNING: File 'conf/positions.conf' contains accounting for an NFT '" + nftId
|
||||||
|
+ "' that was not discovered in the wallet!");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionConcentrated updatedPosition =
|
||||||
|
new RaydiumLiquidityPoolPositionConcentrated(
|
||||||
|
position.poolId(),
|
||||||
|
position.nftId(),
|
||||||
|
position.priceRange(),
|
||||||
|
position.amountMintA(),
|
||||||
|
position.amountMintB(),
|
||||||
|
accountingEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedEntries.put(nftId, updatedPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
liquidityPositions = Map.copyOf(updatedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> readPositionAccounting()
|
||||||
|
throws IOException {
|
||||||
|
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionAccounting> result = new HashMap<>();
|
||||||
|
|
||||||
|
try (BufferedReader reader = Files.newBufferedReader(PATH_POSITIONS, StandardCharsets.UTF_8)) {
|
||||||
|
String line;
|
||||||
|
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove comments at end of line
|
||||||
|
int index = line.indexOf('#');
|
||||||
|
if(index > -1) {
|
||||||
|
line = line.substring(0, index).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionAccounting accounting = mapPositionAccounting(line);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolPositionAccounting previous = result.put(
|
||||||
|
accounting.nftId(),
|
||||||
|
accounting
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previous != null) {
|
||||||
|
throw new IOException("Duplicate position accounting for NFT id: " + accounting.nftId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RaydiumLiquidityPoolPositionAccounting mapPositionAccounting(String line) throws IOException {
|
||||||
|
String[] values = line.split(";");
|
||||||
|
|
||||||
|
if (values.length != 5) {
|
||||||
|
String errormsg = "Expected position accounting line with 5 fields: "
|
||||||
|
+ "'NFTId ; AddedMintA ; AddedMintB ; BorrowedMintA ; BorrowedMintB'. "
|
||||||
|
+ "Line was: '" + line + "'";
|
||||||
|
throw new IOException(errormsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ nftId = values[0].trim();
|
||||||
|
ΩAmountΩ addedMintA = new BigDecimal(values[1].trim());
|
||||||
|
ΩAmountΩ addedMintB = new BigDecimal(values[2].trim());
|
||||||
|
ΩAmountΩ borrowedMintA = new BigDecimal(values[3].trim());
|
||||||
|
ΩAmountΩ borrowedMintB = new BigDecimal(values[4].trim());
|
||||||
|
|
||||||
|
return new RaydiumLiquidityPoolPositionAccounting(
|
||||||
|
nftId,
|
||||||
|
addedMintA,
|
||||||
|
addedMintB,
|
||||||
|
borrowedMintA,
|
||||||
|
borrowedMintB
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩSolanaAddressΩ readSolanaAddress(BufferedReader reader) throws IOException {
|
||||||
|
ΩSolanaAddressΩ sa = readLineFromFile(reader);
|
||||||
|
return sa;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumLiquidityPoolIdΩ readRaydiumPoolId(BufferedReader reader) throws IOException {
|
||||||
|
ΩRaydiumLiquidityPoolIdΩ rlpid = readLineFromFile(reader);
|
||||||
|
return rlpid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩCurveWidthΩ readCurveWidth(BufferedReader reader) throws IOException {
|
||||||
|
String cwStr = readLineFromFile(reader);
|
||||||
|
ΩCurveWidthΩ cw = new ΩCurveWidthΩ(cwStr);
|
||||||
|
return cw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩmilliSecondsΩ readIterationInterval(BufferedReader reader) throws IOException {
|
||||||
|
String iterationIntervalStr = readLineFromFile(reader);
|
||||||
|
ΩmilliSecondsΩ millis = Long.parseLong(iterationIntervalStr) * 1000;
|
||||||
|
return millis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩSyrupAmountΩ readSyrupOwnedByEvelyn(BufferedReader reader) throws IOException {
|
||||||
|
String syrupOwnedByEvelynStr = readLineFromFile(reader);
|
||||||
|
CurrencyType ct = WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType();
|
||||||
|
ΩSyrupAmountΩ ma = stringToMoneyAmount(syrupOwnedByEvelynStr, ct);
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readLineFromFile(BufferedReader reader) throws IOException {
|
||||||
|
String line;
|
||||||
|
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount bigDecimalToMoneyAmount(BigDecimal bd, CurrencyType ct) {
|
||||||
|
MoneyAmount ma = new MoneyAmount(bd, ct);
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyAmount stringToMoneyAmount(String str, CurrencyType ct) {
|
||||||
|
ΩAmountΩ a = new ΩAmountΩ(str);
|
||||||
|
MoneyAmount ma = new MoneyAmount(a, ct);
|
||||||
|
return ma;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Path PATH_EVELYN_STATE = Path.of("conf/evelyn.conf");
|
||||||
|
private static final Path PATH_POSITIONS = Path.of("conf/positions.conf");
|
||||||
|
|
||||||
|
private final Raydium raydium;
|
||||||
|
|
||||||
|
private ΩmilliSecondsΩ iterationInterval;
|
||||||
|
private ΩSyrupAmountΩ syrupOwnedByEvelyn;
|
||||||
|
private ΩSolanaAddressΩ solanaAddressForEvelynIOU;
|
||||||
|
private ΩSolanaAddressΩ solanaAddressForEvelynIOUBurner;
|
||||||
|
private ΩRaydiumLiquidityPoolIdΩ raydiumPoolId;
|
||||||
|
private ΩCurveWidthΩ curveWidth;
|
||||||
|
private Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.fanitas.evelyn.math;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
|
||||||
|
import static java.math.BigDecimal.ZERO;
|
||||||
|
|
||||||
|
public class BigDecimalUtils {
|
||||||
|
public static BigDecimal min(BigDecimal a, BigDecimal b) {
|
||||||
|
return a.compareTo(b) <= 0 ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BigDecimal max(BigDecimal a, BigDecimal b) {
|
||||||
|
return a.compareTo(b) >= 0 ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BigDecimal sqrt(BigDecimal value, MathContext mc) {
|
||||||
|
if (value.compareTo(ZERO) < 0) {
|
||||||
|
throw new IllegalArgumentException("sqrt af negativ værdi");
|
||||||
|
}
|
||||||
|
if (value.compareTo(ZERO) == 0) {
|
||||||
|
return ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal x = new BigDecimal(Math.sqrt(value.doubleValue()), mc);
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
x = x.add(value.divide(x, mc), mc).divide(TWO, mc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double erf(BigDecimal x) {
|
||||||
|
double value = x.doubleValue();
|
||||||
|
double sign = value < 0 ? -1.0 : 1.0;
|
||||||
|
value = Math.abs(value);
|
||||||
|
|
||||||
|
double t = 1.0 / (1.0 + 0.5 * value);
|
||||||
|
|
||||||
|
double tau = t * Math.exp(
|
||||||
|
-value * value
|
||||||
|
- 1.26551223
|
||||||
|
+ t * (1.00002368
|
||||||
|
+ t * (0.37409196
|
||||||
|
+ t * (0.09678418
|
||||||
|
+ t * (-0.18628806
|
||||||
|
+ t * (0.27886807
|
||||||
|
+ t * (-1.13520398
|
||||||
|
+ t * (1.48851587
|
||||||
|
+ t * (-0.82215223
|
||||||
|
+ t * 0.17087277))))))))
|
||||||
|
);
|
||||||
|
|
||||||
|
double result = sign * (1.0 - tau);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final BigDecimal TWO = new BigDecimal("2");
|
||||||
|
public static final BigDecimal THREE = new BigDecimal("3");
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.fanitas.evelyn.raydium;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record RaydiumLiquidityPoolPositionAccounting(
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ nftId,
|
||||||
|
ΩAmountΩ addedMintA,
|
||||||
|
ΩAmountΩ addedMintB,
|
||||||
|
ΩAmountΩ borrowedMintA,
|
||||||
|
ΩAmountΩ borrowedMintB
|
||||||
|
) {
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.fanitas.evelyn.raydium;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
import com.r35157.libs.valuetypes.basic.Range;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a concentrated liquidity position in a Raydium liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>A concentrated liquidity position is represented on Solana by a position NFT.
|
||||||
|
* The NFT identifies the individual position, while the pool id identifies the
|
||||||
|
* Raydium concentrated liquidity pool that the position belongs to.</p>
|
||||||
|
*
|
||||||
|
* <p>The position is only active within its configured price range. The two mint
|
||||||
|
* amounts represent the token amounts associated with the position at the time
|
||||||
|
* the position data was fetched or calculated.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium concentrated liquidity pool id
|
||||||
|
* @param nftId the Raydium liquidity pool position NFT id identifying this position
|
||||||
|
* @param priceRange the price range where this concentrated liquidity position is active
|
||||||
|
* @param accountingInfo the amount added to and borrowed from the position
|
||||||
|
*/
|
||||||
|
public record RaydiumLiquidityPoolPositionConcentrated(
|
||||||
|
@NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||||
|
@NotNull ΩRaydiumLiquidityPoolPositionNftIdΩ nftId,
|
||||||
|
@NotNull Range<AssetPrice> priceRange,
|
||||||
|
@NotNull MoneyAmount amountMintA,
|
||||||
|
@NotNull MoneyAmount amountMintB,
|
||||||
|
@NotNull RaydiumLiquidityPoolPositionAccounting accountingInfo
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.r35157.assetaz.core.service.ticker;
|
||||||
|
|
||||||
|
public interface TickerService {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.r35157.assetaz.core.service.ticker.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.assetaz.core.service.ticker.TickerService;
|
||||||
|
|
||||||
|
public class TickerServiceImpl implements TickerService {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.r35157.driver.ledsign;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public interface LedSign {
|
||||||
|
void sendCommand(@NotNull byte[] payload);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.r35157.driver.ledsign.impl.sigmaasc434;
|
||||||
|
|
||||||
|
import com.r35157.driver.ledsign.LedSign;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import com.fazecast.jSerialComm.*;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public class SigmaAsc434 implements LedSign {
|
||||||
|
@Override
|
||||||
|
public void sendCommand(@NotNull byte[] bytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void main() throws Exception {
|
||||||
|
try {
|
||||||
|
new SigmaAsc434();
|
||||||
|
} catch(Exception e) {
|
||||||
|
System.out.println(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SigmaAsc434() throws Exception {
|
||||||
|
SerialPort port = SerialPort.getCommPort("/dev/ttyUSB0");
|
||||||
|
port.setComPortParameters(BAUD, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY);
|
||||||
|
port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED);
|
||||||
|
port.setComPortTimeouts(SerialPort.TIMEOUT_WRITE_BLOCKING, 0, 0);
|
||||||
|
port.setDTR();
|
||||||
|
port.setRTS();
|
||||||
|
|
||||||
|
if (!port.openPort()) throw new RuntimeException("Kunne ikke åbne porten");
|
||||||
|
|
||||||
|
try (OutputStream out = port.getOutputStream()) {
|
||||||
|
long ts = System.currentTimeMillis();
|
||||||
|
//// 1) Sync (parser reset)
|
||||||
|
//for (int i = 0; i < 20; i++) {
|
||||||
|
// out.write(0xAA);
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//// 2) Start command
|
||||||
|
//out.write(0xBB);
|
||||||
|
//
|
||||||
|
//// 3) Program select: A
|
||||||
|
//out.write(0xAF);
|
||||||
|
//out.write('A'); // 0x41
|
||||||
|
//
|
||||||
|
//// 4) Text: rød på sort
|
||||||
|
//int RED_ON_BLACK = 0x01;
|
||||||
|
//
|
||||||
|
//String text = "A";
|
||||||
|
//for (byte b : text.getBytes(StandardCharsets.US_ASCII)) {
|
||||||
|
// //out.write(RED_ON_BLACK);
|
||||||
|
// out.write(b);
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//// 5) End command
|
||||||
|
//out.write(0xBF);
|
||||||
|
//out.write(0xB1);
|
||||||
|
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
out.write(0xAA);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.write(0xBB);
|
||||||
|
out.write(0xAF); out.write(0x41);
|
||||||
|
out.write(0x01); out.write(0x54);
|
||||||
|
out.write(0x01); out.write(0x45);
|
||||||
|
out.write(0x01); out.write(0x53);
|
||||||
|
out.write(0x01); out.write(0x54);
|
||||||
|
out.write(0xBF); out.write(0xB1);
|
||||||
|
|
||||||
|
out.flush();
|
||||||
|
System.out.println("@@@ DONE in " + (System.currentTimeMillis() - ts) + "ms @@@");
|
||||||
|
} finally {
|
||||||
|
port.closePort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//private static final int BAUD = 2400;
|
||||||
|
//private static final int BAUD = 4800;
|
||||||
|
//private static final int BAUD = 9600;
|
||||||
|
//private static final int BAUD = 19200;
|
||||||
|
private static final int BAUD = 115200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm;
|
||||||
|
|
||||||
|
public enum AlarmSeverity {
|
||||||
|
EMERGENCY, // Repeated wake-up alarm - sound always
|
||||||
|
CRITICAL, // One-shot wake-up alarm - sound always
|
||||||
|
WARN, // Audible warning - respecting quiet hours though
|
||||||
|
INFO, // Normal notification with visual and audible feedback
|
||||||
|
SILENT, // Low-priority notification - visual feedback without sound/vibration
|
||||||
|
GHOST // No visual/audible notification - only visible inside the Pushover app
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm;
|
||||||
|
|
||||||
|
public class JupiterPerpsAlarm {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AlarmAction {
|
||||||
|
void trigger(OraclePrice price, PriceAlarmDefinition alarm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
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.*;
|
||||||
|
|
||||||
|
public final class AlarmConfigurationParser {
|
||||||
|
|
||||||
|
public static List<PriceAlarmDefinition> 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("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if(isConstantDefinition(trimmed)) {
|
||||||
|
parseConstantDefinition(constants, line);
|
||||||
|
} else {
|
||||||
|
String resolvedLine = replaceConstants(line, constants);
|
||||||
|
alarms.add(parseLine(resolvedLine));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
path + ":" + lineNumber + ": " + exception.getMessage(),
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alarms.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No alarms found in " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.copyOf(alarms);
|
||||||
|
}
|
||||||
|
|
||||||
|
static PriceAlarmDefinition parseLine(String line) {
|
||||||
|
Cursor cursor = new Cursor(line);
|
||||||
|
|
||||||
|
int id = Integer.parseInt(cursor.nextToken("id"));
|
||||||
|
|
||||||
|
JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf(
|
||||||
|
cursor.nextToken("asset").toUpperCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
|
||||||
|
PriceDirection direction = PriceDirection.valueOf(
|
||||||
|
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
|
||||||
|
BigDecimal target = parseTarget(cursor.nextToken("target"));
|
||||||
|
|
||||||
|
TriggerConfiguration triggerConfiguration = parseTrigger(
|
||||||
|
cursor.nextToken("trigger")
|
||||||
|
);
|
||||||
|
|
||||||
|
AlarmSeverity severity = AlarmSeverity.valueOf(
|
||||||
|
cursor.nextToken("severity").toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
String note = cursor.nextQuotedString("note");
|
||||||
|
|
||||||
|
cursor.skipWhitespace();
|
||||||
|
if (!cursor.atEnd() && cursor.current() != '#') {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unexpected text after note: " + cursor.remaining()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PriceAlarmDefinition(
|
||||||
|
id,
|
||||||
|
asset,
|
||||||
|
direction,
|
||||||
|
target,
|
||||||
|
triggerConfiguration.trigger(),
|
||||||
|
triggerConfiguration.gracePeriod(),
|
||||||
|
severity,
|
||||||
|
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 Cursor(String line) {
|
||||||
|
this.line = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextToken(String fieldName) {
|
||||||
|
skipWhitespace();
|
||||||
|
if (atEnd()) {
|
||||||
|
throw new IllegalArgumentException("Missing " + fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = position;
|
||||||
|
while (!atEnd() && !Character.isWhitespace(current())) {
|
||||||
|
position++;
|
||||||
|
}
|
||||||
|
return line.substring(start, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextQuotedString(String fieldName) {
|
||||||
|
skipWhitespace();
|
||||||
|
if (atEnd() || current() != '"') {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Missing quoted " + fieldName + "; expected \"...\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
position++;
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
boolean escaped = false;
|
||||||
|
|
||||||
|
while (!atEnd()) {
|
||||||
|
char character = current();
|
||||||
|
position++;
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
result.append(switch (character) {
|
||||||
|
case 'n' -> '\n';
|
||||||
|
case 'r' -> '\r';
|
||||||
|
case 't' -> '\t';
|
||||||
|
case '"' -> '"';
|
||||||
|
case '\\' -> '\\';
|
||||||
|
default -> character;
|
||||||
|
});
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character == '"') {
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void skipWhitespace() {
|
||||||
|
while (!atEnd() && Character.isWhitespace(current())) {
|
||||||
|
position++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean atEnd() {
|
||||||
|
return position >= line.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
private char current() {
|
||||||
|
return line.charAt(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String remaining() {
|
||||||
|
return line.substring(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String line;
|
||||||
|
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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
public enum AlarmTrigger {
|
||||||
|
ONETIME,
|
||||||
|
PERSISTENT
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class AssetPriceAlarmMonitor {
|
||||||
|
|
||||||
|
public AssetPriceAlarmMonitor(
|
||||||
|
JupiterPerpsAsset asset,
|
||||||
|
List<PriceAlarmDefinition> definitions,
|
||||||
|
AlarmAction action
|
||||||
|
) {
|
||||||
|
this.asset = asset;
|
||||||
|
this.alarms = definitions.stream()
|
||||||
|
.map(definition -> {
|
||||||
|
if (definition.asset() != asset) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Alarm asset " + definition.asset() + " does not match monitor " + asset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new PriceAlarm(definition, action);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (alarms.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("At least one alarm is required for " + asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void accept(OraclePrice price) {
|
||||||
|
if (price.asset() != asset) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Received " + price.asset() + " price for " + asset + " monitor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
alarms.forEach(alarm -> alarm.accept(price));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PriceAlarm> alarms() {
|
||||||
|
return alarms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void trimRecentEvents() {
|
||||||
|
while (recentEvents.size() > MAX_RECENT_EVENTS) {
|
||||||
|
Iterator<String> iterator = recentEvents.iterator();
|
||||||
|
iterator.next();
|
||||||
|
iterator.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JupiterPerpsAsset asset;
|
||||||
|
private final List<PriceAlarm> alarms;
|
||||||
|
private final Set<String> recentEvents = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
private static final int MAX_RECENT_EVENTS = 512;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
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, PriceAlarmDefinition alarm) {
|
||||||
|
for (AlarmAction action : actions) {
|
||||||
|
try {
|
||||||
|
action.trigger(price, alarm);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
System.err.println("Alarm action failed: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final List<AlarmAction> actions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
public final class ConsoleAlarmAction implements AlarmAction {
|
||||||
|
@Override
|
||||||
|
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
|
||||||
|
System.err.println();
|
||||||
|
System.err.println("============================================================");
|
||||||
|
System.err.printf(
|
||||||
|
"ALARM: %s is %s USD; target %s %s USD%n",
|
||||||
|
price.asset(),
|
||||||
|
price.priceUsd().toPlainString(),
|
||||||
|
alarm.direction(),
|
||||||
|
alarm.target().toPlainString()
|
||||||
|
);
|
||||||
|
System.err.printf(
|
||||||
|
"Trigger: %s, oracle time: %s, slot: %d, source: %s%n",
|
||||||
|
alarm.trigger(),
|
||||||
|
price.oracleTime(),
|
||||||
|
price.slot(),
|
||||||
|
price.source()
|
||||||
|
);
|
||||||
|
System.err.println("============================================================");
|
||||||
|
System.err.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,189 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
||||||
|
public final class JupiterPerpsAlarmImpl {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
Config config;
|
||||||
|
try {
|
||||||
|
config = Config.parse(args, System.getenv());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
printUsage();
|
||||||
|
String errMsg = "Could not parse configuration for JupiterPerpsAlarm: " + exception.getMessage() + "!";
|
||||||
|
throw new IllegalStateException(errMsg, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PriceAlarmDefinition> definitions;
|
||||||
|
try {
|
||||||
|
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
|
||||||
|
} catch (Exception exception) {
|
||||||
|
String errMsg = "Could not load alarm configuration: " + exception.getMessage() + "!";
|
||||||
|
throw new IllegalStateException(errMsg, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> definitionsByAsset =
|
||||||
|
groupByAsset(definitions);
|
||||||
|
|
||||||
|
Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>(
|
||||||
|
JupiterPerpsAsset.class
|
||||||
|
);
|
||||||
|
definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put(
|
||||||
|
asset,
|
||||||
|
new AssetPriceAlarmMonitor(asset, assetDefinitions, action)
|
||||||
|
));
|
||||||
|
|
||||||
|
List<OracleWebSocketClient> clients = new ArrayList<>();
|
||||||
|
for (Map.Entry<JupiterPerpsAsset, AssetPriceAlarmMonitor> entry : monitors.entrySet()) {
|
||||||
|
for (URI endpoint : config.webSocketEndpoints()) {
|
||||||
|
clients.add(new OracleWebSocketClient(
|
||||||
|
endpoint,
|
||||||
|
entry.getKey(),
|
||||||
|
entry.getValue()::accept
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||||
|
() -> clients.forEach(OracleWebSocketClient::close),
|
||||||
|
"shutdown"
|
||||||
|
));
|
||||||
|
|
||||||
|
System.out.println("Alarm configuration: " + config.alarmConfiguration().toAbsolutePath());
|
||||||
|
System.out.println("Loaded alarms: " + definitions.size());
|
||||||
|
definitionsByAsset.forEach((asset, assetDefinitions) -> {
|
||||||
|
System.out.printf(
|
||||||
|
" %s: %d alarm(s), oracle account %s%n",
|
||||||
|
asset,
|
||||||
|
assetDefinitions.size(),
|
||||||
|
asset.oracleAccount()
|
||||||
|
);
|
||||||
|
assetDefinitions.forEach(definition -> System.out.printf(
|
||||||
|
" %s %s USD, %s, severity=%s%n",
|
||||||
|
definition.direction(),
|
||||||
|
definition.target().toPlainString(),
|
||||||
|
definition.trigger(),
|
||||||
|
definition.severity()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
System.out.println("RPC endpoints per asset: " + config.webSocketEndpoints().size());
|
||||||
|
System.out.println("Total WebSocket connections: " + clients.size());
|
||||||
|
|
||||||
|
clients.forEach(OracleWebSocketClient::start);
|
||||||
|
new CountDownLatch(1).await();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> groupByAsset(
|
||||||
|
List<PriceAlarmDefinition> definitions
|
||||||
|
) {
|
||||||
|
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> result = new EnumMap<>(
|
||||||
|
JupiterPerpsAsset.class
|
||||||
|
);
|
||||||
|
for (PriceAlarmDefinition definition : definitions) {
|
||||||
|
result.computeIfAbsent(definition.asset(), ignored -> new ArrayList<>())
|
||||||
|
.add(definition);
|
||||||
|
}
|
||||||
|
result.replaceAll((asset, alarms) -> List.copyOf(alarms));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void printUsage() {
|
||||||
|
System.err.println("""
|
||||||
|
Usage:
|
||||||
|
gradle run
|
||||||
|
gradle run --args='--config=/path/to/price-alarms.conf'
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config=<path> Default: price-alarms.conf
|
||||||
|
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PRICE_ALARMS_CONFIG Alternative default configuration path
|
||||||
|
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
|
||||||
|
PUSHOVER_APP_TOKEN Pushover application token
|
||||||
|
PUSHOVER_USER_KEY Pushover user/group key
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Config(
|
||||||
|
Path alarmConfiguration,
|
||||||
|
List<URI> webSocketEndpoints,
|
||||||
|
String pushoverToken,
|
||||||
|
String pushoverUserKey
|
||||||
|
) {
|
||||||
|
private static Config parse(String[] args, Map<String, String> environment) {
|
||||||
|
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
|
||||||
|
));
|
||||||
|
|
||||||
|
String configurationText = options.get("config");
|
||||||
|
if (configurationText == null || configurationText.isBlank()) {
|
||||||
|
configurationText = environment.getOrDefault(
|
||||||
|
"PRICE_ALARMS_CONFIG",
|
||||||
|
"price-alarms.conf"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
Path.of(configurationText),
|
||||||
|
endpoints,
|
||||||
|
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
|
||||||
|
blankToNull(environment.get("PUSHOVER_USER_KEY"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JupiterPerpsAlarmImpl() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
public enum JupiterPerpsAsset {
|
||||||
|
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
|
||||||
|
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
|
||||||
|
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC");
|
||||||
|
|
||||||
|
JupiterPerpsAsset(String oracleAccount) {
|
||||||
|
this.oracleAccount = oracleAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String oracleAccount() {
|
||||||
|
return oracleAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String oracleAccount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
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.impl.ref;
|
||||||
|
|
||||||
|
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,90 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public final class PriceAlarm {
|
||||||
|
|
||||||
|
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
|
||||||
|
this.definition = definition;
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void accept(OraclePrice price) {
|
||||||
|
if (price.asset() != definition.asset()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Received " + price.asset() + " price for " + definition.asset() + " alarm"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean reached = definition.direction().reached(
|
||||||
|
price.priceUsd(),
|
||||||
|
definition.target()
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean enteredTriggeredSide = previousReached == null
|
||||||
|
? reached
|
||||||
|
: reached && !previousReached;
|
||||||
|
|
||||||
|
previousReached = reached;
|
||||||
|
|
||||||
|
if (!reached) {
|
||||||
|
previousReached = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.trigger() == AlarmTrigger.ONETIME) {
|
||||||
|
if (!enteredTriggeredSide || triggerCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger(price);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
|
||||||
|
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
|
||||||
|
trigger(price);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PriceAlarmDefinition definition() {
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized long 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 AlarmAction action;
|
||||||
|
|
||||||
|
private Instant lastTriggeredAt;
|
||||||
|
private Boolean previousReached;
|
||||||
|
private long triggerCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
AlarmTrigger trigger,
|
||||||
|
ΩsecondsΩ triggerGracePeriod,
|
||||||
|
AlarmSeverity severity,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
public PriceAlarmDefinition {
|
||||||
|
Objects.requireNonNull(asset, "asset");
|
||||||
|
Objects.requireNonNull(direction, "direction");
|
||||||
|
Objects.requireNonNull(target, "target");
|
||||||
|
Objects.requireNonNull(trigger, "trigger");
|
||||||
|
Objects.requireNonNull(note, "note");
|
||||||
|
|
||||||
|
if (target.signum() < 0) {
|
||||||
|
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerGracePeriod < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Trigger grace period cannot be negative: " + triggerGracePeriod
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
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,93 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||||
|
|
||||||
|
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, PriceAlarmDefinition alarm) {
|
||||||
|
String title = "Jupiter Perps " + price.asset() + " alarm";
|
||||||
|
String message = createMessage(price, alarm);
|
||||||
|
|
||||||
|
String body = form("token", applicationToken) + "&" +
|
||||||
|
form("user", userKey) + "&" +
|
||||||
|
form("title", title) + "&" +
|
||||||
|
form("message", message) + "&" +
|
||||||
|
createPushoverSeverityParameters(alarm.severity());;
|
||||||
|
|
||||||
|
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.1")
|
||||||
|
.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 alarm sent: " + alarm.severity());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String form(String name, String value) {
|
||||||
|
return URLEncoder.encode(name, StandardCharsets.UTF_8) + "=" +
|
||||||
|
URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String createPushoverSeverityParameters(AlarmSeverity severity) {
|
||||||
|
return switch (severity) {
|
||||||
|
case EMERGENCY -> "priority=2&retry=30&expire=10800&sound=persistent";
|
||||||
|
case CRITICAL -> "priority=1&sound=spacealarm";
|
||||||
|
case WARN -> "priority=0&sound=siren";
|
||||||
|
case INFO -> "priority=0";
|
||||||
|
case SILENT -> "priority=-1";
|
||||||
|
case GHOST -> "priority=-2";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 String applicationToken;
|
||||||
|
private final String userKey;
|
||||||
|
|
||||||
|
private static final URI PUSHOVER_URI =
|
||||||
|
URI.create("https://api.pushover.net/1/messages.json");
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.r35157.libs.basic;
|
||||||
|
|
||||||
|
public record Pair<L, R>(
|
||||||
|
L left,
|
||||||
|
R right
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.r35157.libs.basic;
|
||||||
|
|
||||||
|
public record Triblet<L, M, R>(
|
||||||
|
L left,
|
||||||
|
M middle,
|
||||||
|
R right
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
+50
@@ -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();
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
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
@@ -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.math;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
|
||||||
|
public interface UtilsBigDecimal {
|
||||||
|
BigDecimal min(BigDecimal a, BigDecimal b);
|
||||||
|
BigDecimal max(BigDecimal a, BigDecimal b);
|
||||||
|
BigDecimal sqrt(BigDecimal value, MathContext mc);
|
||||||
|
|
||||||
|
BigDecimal TWO = new BigDecimal("2");
|
||||||
|
BigDecimal THREE = new BigDecimal("3");
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.r35157.libs.math;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public interface UtilsDouble {
|
||||||
|
double erf(BigDecimal x);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.r35157.libs.notification;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface AddressedNotifier<
|
||||||
|
D extends NotificationDestination,
|
||||||
|
M extends NotificationMessage>
|
||||||
|
{
|
||||||
|
void push(D destination, M message) throws IOException;
|
||||||
|
|
||||||
|
default BoundNotifier<M> bind(D destination) {
|
||||||
|
return message -> push(destination, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.r35157.libs.notification;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface BoundNotifier<M extends NotificationMessage> {
|
||||||
|
void push(M message) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.r35157.libs.notification;
|
||||||
|
|
||||||
|
public interface NotificationDestination {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.r35157.libs.notification;
|
||||||
|
|
||||||
|
public interface NotificationMessage {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.r35157.libs.notification.impl.discord;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.NotificationMessage;
|
||||||
|
|
||||||
|
public record DiscordMessage(String text) implements NotificationMessage {
|
||||||
|
public DiscordMessage {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Discord message cannot be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.r35157.libs.notification.impl.discord;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.BoundNotifier;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
|
||||||
|
public class DiscordNotifier implements BoundNotifier<DiscordMessage> {
|
||||||
|
|
||||||
|
public DiscordNotifier(URL discordWebhookUrl) {
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
this.discordWebhookUrl = discordWebhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void push(DiscordMessage message) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("Not implemented yet!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void test() throws IOException {
|
||||||
|
URL discordWebhookUrl = null;
|
||||||
|
|
||||||
|
BoundNotifier<DiscordMessage> boundNotifier = new DiscordNotifier(discordWebhookUrl);
|
||||||
|
|
||||||
|
DiscordMessage message = new DiscordMessage("Hello World!");
|
||||||
|
boundNotifier.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final URL discordWebhookUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.r35157.libs.notification.impl.pushover;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.NotificationMessage;
|
||||||
|
|
||||||
|
public record PushMessage(String text) implements NotificationMessage {
|
||||||
|
public PushMessage {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Push message cannot be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.r35157.libs.notification.impl.pushover;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.BoundNotifier;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
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;
|
||||||
|
|
||||||
|
public class PushOverNotifier implements BoundNotifier<PushMessage> {
|
||||||
|
|
||||||
|
public PushOverNotifier(ΩAPIKeyΩ token, ΩUserNameΩ userName) {
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
this.token = token;
|
||||||
|
this.userName = userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void push(PushMessage message) throws IOException {
|
||||||
|
String body = formEncode(
|
||||||
|
"token", token,
|
||||||
|
"user", userName,
|
||||||
|
"message", message.text()
|
||||||
|
);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(PUSHOVER_MESSAGES_URI)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
} catch(InterruptedException ie) {
|
||||||
|
throw new IOException("Pushover request was interrupted!", ie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
throw new IOException("Pushover request failed! HTTP "
|
||||||
|
+ response.statusCode() + ": " + response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formEncode(String... keyValues) {
|
||||||
|
if (keyValues.length % 2 != 0) {
|
||||||
|
throw new IllegalArgumentException("keyValues must contain key/value pairs");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < keyValues.length; i += 2) {
|
||||||
|
if (!result.isEmpty()) {
|
||||||
|
result.append('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(encode(keyValues[i]))
|
||||||
|
.append('=')
|
||||||
|
.append(encode(keyValues[i + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void test() throws IOException {
|
||||||
|
ΩAPIKeyΩ apiKey = "SomeKey";
|
||||||
|
ΩUserNameΩ userName = "userNmae";
|
||||||
|
|
||||||
|
BoundNotifier<PushMessage> boundNotifier = new PushOverNotifier(apiKey, userName);
|
||||||
|
|
||||||
|
PushMessage message = new PushMessage("Hello World!");
|
||||||
|
boundNotifier.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final URI PUSHOVER_MESSAGES_URI =
|
||||||
|
URI.create("https://api.pushover.net/1/messages.json");
|
||||||
|
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final ΩAPIKeyΩ token;
|
||||||
|
private final ΩUserNameΩ userName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.r35157.libs.notification.impl.smtp;
|
||||||
|
|
||||||
|
public record EmailBody(String text) {
|
||||||
|
public EmailBody {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Email body cannot be blank!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.r35157.libs.notification.impl.smtp;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.NotificationMessage;
|
||||||
|
|
||||||
|
public record EmailMessage(
|
||||||
|
EmailSubject subject,
|
||||||
|
EmailBody body
|
||||||
|
) implements NotificationMessage {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.r35157.libs.notification.impl.smtp;
|
||||||
|
|
||||||
|
public record EmailSubject(String text) {
|
||||||
|
public EmailSubject {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Email subject cannot be blank!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.r35157.libs.notification.impl.smtp;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.NotificationDestination;
|
||||||
|
|
||||||
|
public record SMTPDestination(ΩEmailAddressΩ emailAddress) implements NotificationDestination { }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.r35157.libs.notification.impl.smtp;
|
||||||
|
|
||||||
|
import com.r35157.libs.notification.AddressedNotifier;
|
||||||
|
import com.r35157.libs.notification.BoundNotifier;
|
||||||
|
import com.r35157.libs.valuetypes.basic.Credentials;
|
||||||
|
import com.r35157.libs.valuetypes.basic.NetworkEndPoint;
|
||||||
|
import com.r35157.libs.valuetypes.basic.SmtpConfiguration;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class SMTPNotifier implements AddressedNotifier<SMTPDestination, EmailMessage> {
|
||||||
|
|
||||||
|
public SMTPNotifier(SmtpConfiguration smtpConfiguration) {
|
||||||
|
this.smtpConfiguration = smtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void push(SMTPDestination destination, EmailMessage message) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("Not implemented yet!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void test() throws IOException {
|
||||||
|
ΩHostnameΩ hostName = new ΩHostnameΩ("SomeSMTPHost");
|
||||||
|
ΩportNumberΩ portNumber = 2525;
|
||||||
|
NetworkEndPoint networkEndPoint = new NetworkEndPoint(hostName, portNumber);
|
||||||
|
|
||||||
|
ΩUserNameΩ userName = "SomeUser";
|
||||||
|
ΩPasswordΩ password = "SomePassword";
|
||||||
|
Credentials credentials = new Credentials(userName, password);
|
||||||
|
|
||||||
|
SmtpConfiguration smtpConfiguration = new SmtpConfiguration(networkEndPoint, credentials);
|
||||||
|
AddressedNotifier<SMTPDestination, EmailMessage> notifier = new SMTPNotifier(smtpConfiguration);
|
||||||
|
|
||||||
|
ΩEmailAddressΩ emailAddress = "SomeReceiver";
|
||||||
|
SMTPDestination destination = new SMTPDestination(emailAddress);
|
||||||
|
BoundNotifier boundNotifier = notifier.bind(destination);
|
||||||
|
|
||||||
|
EmailSubject subject = new EmailSubject("SomeSubject");
|
||||||
|
EmailBody body = new EmailBody("SomeBody");
|
||||||
|
EmailMessage message = new EmailMessage(subject, body);
|
||||||
|
boundNotifier.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final SmtpConfiguration smtpConfiguration;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.r35157.libs.random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This interface provides a way to generate random integers.
|
||||||
|
*/
|
||||||
|
public interface RandomValueGeneratorInt {
|
||||||
|
/**
|
||||||
|
* Returns a random integer in the full range (min -2<sup>31</sup>. to max 2<sup>31</sup>-1)
|
||||||
|
*
|
||||||
|
* @return a random integer in full range
|
||||||
|
*/
|
||||||
|
int getSomeInt();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random integer in the range provided by the parameters. Both parameters are included in the range and
|
||||||
|
* {@code minInclusive} must be less than {@code maxInclusive}.
|
||||||
|
*
|
||||||
|
* @return a random integer in the provided range
|
||||||
|
*/
|
||||||
|
int getSomeInt(int minInclusive, int maxInclusive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.r35157.libs.random;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public interface RandomValueGeneratorString {
|
||||||
|
/**
|
||||||
|
* Generate a random Alpha Numeric String of exactly the length given as the parameter.
|
||||||
|
*
|
||||||
|
* @param length Length of generated String - cannot be negative
|
||||||
|
* @return A non-null random String of the length given
|
||||||
|
* @throws IllegalArgumentException If length is negative
|
||||||
|
*/
|
||||||
|
@NotNull String getSomeStringAlphaNumericOnly(int length) throws IllegalArgumentException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.r35157.libs.random.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.libs.random.RandomValueGeneratorInt;
|
||||||
|
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
import static java.lang.Integer.MAX_VALUE;
|
||||||
|
import static java.lang.Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
public class RandomValueGeneratorIntImpl implements RandomValueGeneratorInt {
|
||||||
|
@Override
|
||||||
|
public int getSomeInt() {
|
||||||
|
// Default: use the full int range
|
||||||
|
return getSomeInt(MIN_VALUE, MAX_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSomeInt(int minInclusive, int maxInclusive) {
|
||||||
|
if (minInclusive > maxInclusive) {
|
||||||
|
String errorMessage = String.format("'minInclusive' (%d) must be less than or equal to 'maxInclusive' (%d)",
|
||||||
|
minInclusive, maxInclusive);
|
||||||
|
throw new IllegalArgumentException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minInclusive == maxInclusive) {
|
||||||
|
// Edge case: only one possible value
|
||||||
|
return minInclusive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: full int range – ThreadLocalRandom.nextInt() already covers it
|
||||||
|
if (minInclusive == MIN_VALUE && maxInclusive == MAX_VALUE) {
|
||||||
|
return ThreadLocalRandom.current().nextInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use long to avoid overflow when calculating range size
|
||||||
|
long bound = ((long) maxInclusive - (long) minInclusive) + 1L;
|
||||||
|
|
||||||
|
if (bound <= Integer.MAX_VALUE) {
|
||||||
|
// Normal case: the range fits in an int
|
||||||
|
// nextInt(bound) gives a uniform value in [0, bound],
|
||||||
|
// then shift it into the target range by adding minInclusive
|
||||||
|
return minInclusive + ThreadLocalRandom.current().nextInt((int) bound);
|
||||||
|
} else {
|
||||||
|
// Rare case: the range is larger than Integer.MAX_VALUE
|
||||||
|
// (e.g. almost the full int space). Use rejection sampling:
|
||||||
|
// draw values until one falls within the desired interval.
|
||||||
|
int r;
|
||||||
|
do {
|
||||||
|
r = ThreadLocalRandom.current().nextInt();
|
||||||
|
} while (r < minInclusive || r > maxInclusive);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.r35157.libs.random.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.libs.random.RandomValueGeneratorInt;
|
||||||
|
import com.r35157.libs.random.RandomValueGeneratorString;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class RandomValueGeneratorStringImpl implements RandomValueGeneratorString {
|
||||||
|
public RandomValueGeneratorStringImpl(@NotNull RandomValueGeneratorInt rvgi) {
|
||||||
|
Objects.requireNonNull(rvgi, "Cannot initialize with <null> RandomValueGeneratorInt!");
|
||||||
|
this.rvgi = rvgi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull String getSomeStringAlphaNumericOnly(int length) throws IllegalArgumentException {
|
||||||
|
if(length < 0) {
|
||||||
|
throw new IllegalArgumentException("Cannot generate random Strings of size " + length + "!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASCII Range size
|
||||||
|
// Numeric 48-57 10
|
||||||
|
// Alpha upper 65-90 26
|
||||||
|
// Alpha lower 97-122 26
|
||||||
|
int totalNumberOfCandidates = 10 + 26 + 26;
|
||||||
|
|
||||||
|
StringBuilder buffer = new StringBuilder(length);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
int randomInt = rvgi.getSomeInt(0, totalNumberOfCandidates - 1);
|
||||||
|
int asciiIndex = convertRandomIndexToASCIIIndex(randomInt);
|
||||||
|
|
||||||
|
buffer.append((char) asciiIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int convertRandomIndexToASCIIIndex(int randomIndex) {
|
||||||
|
// RandomIndex 0... ...9 - 10... ...35 - 36... ... 61
|
||||||
|
// ASCII values: 48...<numeric>...57 - 65...<alpha upper>...90 - 97...<alpha lower>...122
|
||||||
|
int asciiIndex;
|
||||||
|
|
||||||
|
if(randomIndex >= 36) {
|
||||||
|
asciiIndex = 97 + (randomIndex - 36);
|
||||||
|
} else if(randomIndex >= 10) {
|
||||||
|
asciiIndex = 65 + (randomIndex - 10);
|
||||||
|
} else {
|
||||||
|
asciiIndex = 48 + randomIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asciiIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RandomValueGeneratorInt rvgi;
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
import com.r35157.libs.valuetypes.basic.Range;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface Raydium {
|
||||||
|
/**
|
||||||
|
* Fetches the current price for a Raydium liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The price is fetched from Raydium using the supplied pool id. The pool id may refer
|
||||||
|
* to any Raydium liquidity pool supported by the implementation.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium liquidity pool id
|
||||||
|
* @return the current pool price
|
||||||
|
* @throws IOException if the price could not be fetched or the response could not be parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the price
|
||||||
|
*/
|
||||||
|
AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ poolId) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the Raydium liquidity pool ids where the supplied Solana owner address has positions.
|
||||||
|
*
|
||||||
|
* <p>The returned set contains unique pool ids and does not guarantee iteration order.</p>
|
||||||
|
*
|
||||||
|
* <p>An implementation may discover pools through multiple Raydium position mechanisms,
|
||||||
|
* including standard liquidity pool token holdings and concentrated liquidity position NFTs.
|
||||||
|
* The returned ids are aggregated under the common {@code ΩRaydiumLiquidityPoolIdΩ} ValueTag.</p>
|
||||||
|
*
|
||||||
|
* @param ownerAddress the Solana owner address to inspect
|
||||||
|
* @return the unique Raydium liquidity pool ids where the owner has a position
|
||||||
|
* @throws IOException if liquidity pool ids could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching liquidity pool ids
|
||||||
|
*/
|
||||||
|
Set<ΩRaydiumLiquidityPoolIdΩ> fetchLiquidityPoolIds(ΩSolanaAddressΩ ownerAddress) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the Raydium concentrated liquidity position NFT ids owned by a Solana address.
|
||||||
|
*
|
||||||
|
* <p>The supplied owner address is inspected for token holdings that are candidates for
|
||||||
|
* Raydium concentrated liquidity position NFTs. The implementation verifies the candidates
|
||||||
|
* against Raydium's concentrated liquidity program before returning them.</p>
|
||||||
|
*
|
||||||
|
* <p>The returned set contains position NFT ids, not pool ids. Callers can use the returned
|
||||||
|
* NFT ids to fetch concentrated position state and then determine which pools the positions
|
||||||
|
* belong to.</p>
|
||||||
|
*
|
||||||
|
* @param ownerAddress the Solana owner address to inspect
|
||||||
|
* @return the Raydium concentrated liquidity position NFT ids owned by the address
|
||||||
|
* @throws IOException if the position NFT ids could not be fetched or verified
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching position NFT ids
|
||||||
|
*/
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
|
||||||
|
ΩSolanaAddressΩ ownerAddress
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the total token amounts held by a standard Raydium liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The returned amounts represent the pool-level token reserves for token A and
|
||||||
|
* token B. They do not describe a single liquidity provider's share of the pool.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium standard liquidity pool id
|
||||||
|
* @return the total token amounts held by the standard liquidity pool
|
||||||
|
* @throws IOException if the pool information could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the pool information
|
||||||
|
*/
|
||||||
|
RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolTokenAmounts(
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the token amounts represented by an owner's position in a standard Raydium liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The returned amounts describe the owner's calculated share of token A and token B in the
|
||||||
|
* standard liquidity pool. The calculation is based on the owner's liquidity pool token holdings,
|
||||||
|
* the LP mint supply and the pool-level token reserves returned by Raydium.</p>
|
||||||
|
*
|
||||||
|
* <p><strong>Important:</strong> The returned amounts should be treated as an estimated pool-share
|
||||||
|
* view, not as an exact withdraw preview. Raydium's UI may show lower amounts when removing
|
||||||
|
* liquidity. For example, for pool {@code 8os8bnXoy5voKv3uBPPuVGyqWZGJaa2RRri5RbLUwPCY},
|
||||||
|
* the pool-share calculation returned approximately {@code 2.1594651 EVE / 31.867153 USDT},
|
||||||
|
* while Raydium's 100% withdraw preview showed approximately {@code 2.1062559 EVE / 31.55046 USDT}.</p>
|
||||||
|
*
|
||||||
|
* <p>// TODO To match Raydium's withdraw preview more precisely, this method should eventually use
|
||||||
|
* on-chain pool state and vault balances, excluding protocol, fund and creator fee buckets.
|
||||||
|
* Raydium's CPMM pool state contains fee fields and a {@code vault_amount_without_fee(...)}
|
||||||
|
* calculation that appears relevant for this. Until that is implemented, this method represents
|
||||||
|
* an estimate based on Raydium REST pool amounts and Solana LP supply.</p>
|
||||||
|
*
|
||||||
|
* <p>This method is specific to standard liquidity pools. Concentrated liquidity positions are
|
||||||
|
* represented differently and should use concentrated-position-specific methods.</p>
|
||||||
|
*
|
||||||
|
* @param ownerAddress the Solana owner address whose standard pool position should be inspected
|
||||||
|
* @param poolId the Raydium standard liquidity pool id
|
||||||
|
* @return the estimated token amounts represented by the owner's standard pool position
|
||||||
|
* @throws IOException if the position information could not be fetched, calculated or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the position information
|
||||||
|
*/
|
||||||
|
RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolPositionTokenAmounts(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the raw Raydium concentrated position state for a position NFT.
|
||||||
|
*
|
||||||
|
* <p>The supplied NFT id identifies a concentrated liquidity position on Raydium. The returned
|
||||||
|
* state is a low-level Raydium representation of that position, including the pool id, tick
|
||||||
|
* boundaries and liquidity value decoded from the position account.</p>
|
||||||
|
*
|
||||||
|
* <p>This method does not calculate token amounts, price ranges or higher-level strategy values.
|
||||||
|
* Higher-level modules may use the returned state together with pool state, current price and CLMM
|
||||||
|
* math to calculate those values.</p>
|
||||||
|
*
|
||||||
|
* @param positionNftId the Raydium concentrated liquidity position NFT id
|
||||||
|
* @return the raw Raydium concentrated position state for the supplied position NFT
|
||||||
|
* @throws IOException if the position state could not be fetched or decoded
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the position state
|
||||||
|
*/
|
||||||
|
RaydiumConcentratedPositionState fetchConcentratedPositionState(
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches basic information about a Raydium concentrated liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The returned information contains the pool id, token mint addresses,
|
||||||
|
* token decimals and the current pool price as reported by Raydium. This is
|
||||||
|
* low-level pool information that can be combined with concentrated position
|
||||||
|
* state to interpret ticks, price ranges and liquidity values.</p>
|
||||||
|
*
|
||||||
|
* <p>This method does not fetch an owner's position and does not calculate token
|
||||||
|
* amounts for a position.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium concentrated liquidity pool id
|
||||||
|
* @return basic information about the concentrated liquidity pool
|
||||||
|
* @throws IOException if the pool information could not be fetched or parsed
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the pool information
|
||||||
|
*/
|
||||||
|
RaydiumConcentratedPoolInfo fetchConcentratedPoolInfo(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the raw on-chain state for a Raydium concentrated liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The returned state is decoded from the Raydium concentrated pool account on Solana.
|
||||||
|
* It contains low-level CLMM state such as active liquidity, the current square-root price
|
||||||
|
* in Q64.64 format and the current tick index.</p>
|
||||||
|
*
|
||||||
|
* <p>This method is different from {@link #fetchConcentratedPoolInfo(ΩRaydiumLiquidityPoolConcentratedIdΩ)},
|
||||||
|
* which fetches pool information from Raydium's REST API. This method is intended for calculations
|
||||||
|
* that need fresher or more precise on-chain CLMM state than the REST API price field can provide.</p>
|
||||||
|
*
|
||||||
|
* <p>This method does not fetch token metadata, token decimals or position state. Those values
|
||||||
|
* must be supplied separately when needed for higher-level calculations.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium concentrated liquidity pool id
|
||||||
|
* @return the raw on-chain state for the concentrated liquidity pool
|
||||||
|
* @throws IOException if the pool state could not be fetched or decoded
|
||||||
|
* @throws InterruptedException if the calling thread is interrupted while fetching the pool state
|
||||||
|
*/
|
||||||
|
RaydiumConcentratedPoolState fetchConcentratedPoolState(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the price range represented by a Raydium concentrated liquidity position.
|
||||||
|
*
|
||||||
|
* <p>The returned range is calculated from the position's lower and upper tick indexes,
|
||||||
|
* adjusted by the token decimals from the concentrated pool information.</p>
|
||||||
|
*
|
||||||
|
* <p>Raydium concentrated liquidity ranges are treated as lower-inclusive and
|
||||||
|
* upper-exclusive. This avoids overlapping ownership at shared boundaries between
|
||||||
|
* adjacent tick ranges.</p>
|
||||||
|
*
|
||||||
|
* @param positionState the concentrated position state containing the lower and upper tick indexes
|
||||||
|
* @param poolInfo the concentrated pool information containing token decimals and pool context
|
||||||
|
* @param decimalPlaces the number decimal places in the resulting price currency
|
||||||
|
* @return the price range represented by the concentrated liquidity position
|
||||||
|
*/
|
||||||
|
Range<AssetPrice> calculateConcentratedPositionPriceRange(
|
||||||
|
RaydiumConcentratedPositionState positionState,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
int decimalPlaces
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the token amounts represented by a Raydium concentrated liquidity position using
|
||||||
|
* raw on-chain pool state for the current square-root price.
|
||||||
|
*
|
||||||
|
* <p>The returned amounts are calculated from the position liquidity, the position tick range,
|
||||||
|
* token decimals from the concentrated pool information, and the current square-root price from
|
||||||
|
* the supplied on-chain pool state. This can be more precise than using the REST API price field
|
||||||
|
* from {@link RaydiumConcentratedPoolInfo}.</p>
|
||||||
|
*
|
||||||
|
* <p>If the current pool price is below the position range, the position is represented as
|
||||||
|
* token A. If the current pool price is above the position range, the position is represented
|
||||||
|
* as token B. If the current pool price is inside the position range, the position is
|
||||||
|
* represented as a mix of token A and token B.</p>
|
||||||
|
*
|
||||||
|
* <p>This method performs only the mathematical conversion from Raydium concentrated position
|
||||||
|
* state and pool state to token amounts. It does not fetch position state, pool information or
|
||||||
|
* pool state.</p>
|
||||||
|
*
|
||||||
|
* @param positionState the concentrated position state containing tick indexes and liquidity
|
||||||
|
* @param poolInfo the concentrated pool information containing token mints and decimals
|
||||||
|
* @param poolState the on-chain concentrated pool state containing current square-root price and tick
|
||||||
|
* @return the token amounts represented by the concentrated liquidity position
|
||||||
|
*/
|
||||||
|
RaydiumLiquidityPoolTokenAmounts calculateConcentratedPositionTokenAmounts(
|
||||||
|
RaydiumConcentratedPositionState positionState,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
RaydiumConcentratedPoolState poolState
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents basic information about a Raydium concentrated liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>This record contains the pool-level information needed to interpret
|
||||||
|
* concentrated position state. The mint addresses and decimals are required when
|
||||||
|
* converting Raydium tick indexes into human-readable prices.</p>
|
||||||
|
*
|
||||||
|
* <p>This record is intentionally a low-level Raydium model. It does not describe
|
||||||
|
* an owner's position and does not calculate token amounts for a position.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium concentrated liquidity pool id
|
||||||
|
* @param mintA the SPL mint address of token A
|
||||||
|
* @param mintADecimals the number of decimals used by token A
|
||||||
|
* @param mintB the SPL mint address of token B
|
||||||
|
* @param mintBDecimals the number of decimals used by token B
|
||||||
|
* @param priceEstimate the pool price estimate as reported by Raydium
|
||||||
|
*/
|
||||||
|
public record RaydiumConcentratedPoolInfo(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||||
|
ΩSPLMintAddressΩ mintA,
|
||||||
|
ΩamountDecimalsΩ mintADecimals,
|
||||||
|
ΩSPLMintAddressΩ mintB,
|
||||||
|
ΩamountDecimalsΩ mintBDecimals,
|
||||||
|
ΩPriceΩ priceEstimate
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents raw on-chain state for a Raydium concentrated liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>This record contains low-level CLMM pool state decoded from the Raydium
|
||||||
|
* concentrated liquidity pool account on Solana. It is different from
|
||||||
|
* {@link RaydiumConcentratedPoolInfo}, which represents pool information fetched
|
||||||
|
* from Raydium's REST API.</p>
|
||||||
|
*
|
||||||
|
* <p>The {@code sqrtPriceX64} field represents the current square-root price in
|
||||||
|
* Raydium's fixed-point Q64.64 format. This value can be used for more precise
|
||||||
|
* concentrated liquidity calculations than using the REST API price field.</p>
|
||||||
|
*
|
||||||
|
* <p>This record does not contain token mint metadata, decimals or display prices.
|
||||||
|
* Those values belong in {@link RaydiumConcentratedPoolInfo} or in higher-level
|
||||||
|
* calculations that combine pool state with pool information.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium concentrated liquidity pool id
|
||||||
|
* @param liquidity the currently active liquidity in the pool
|
||||||
|
* @param sqrtPriceX64 the current square-root price in Q64.64 fixed-point format
|
||||||
|
* @param tickCurrent the current Raydium liquidity tick index
|
||||||
|
*/
|
||||||
|
public record RaydiumConcentratedPoolState(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||||
|
ΩRaydiumLiquidityΩ liquidity,
|
||||||
|
ΩRaydiumSqrtPriceX64Ω sqrtPriceX64,
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickCurrent
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
public record RaydiumConcentratedPositionState(
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ nftId,
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickLowerIndex,
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickUpperIndex,
|
||||||
|
ΩRaydiumLiquidityΩ liquidity
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a standard Raydium liquidity pool position.
|
||||||
|
*
|
||||||
|
* <p>A standard liquidity position is represented by ownership of liquidity pool
|
||||||
|
* tokens. The liquidity pool token mint identifies the LP token, while the SPL
|
||||||
|
* token account identifies where the owner holds the LP token balance.</p>
|
||||||
|
*
|
||||||
|
* <p>This record describes both the account holding the LP tokens and the amount
|
||||||
|
* of LP tokens held in that account. It does not represent a concentrated
|
||||||
|
* liquidity NFT position.</p>
|
||||||
|
*
|
||||||
|
* @param positionId the Raydium liquidity pool position id
|
||||||
|
* @param lpMintId the SPL mint address of the Raydium liquidity pool token
|
||||||
|
* @param lpAccount the Raydium liquidity pool account associated with the position
|
||||||
|
* @param lpTokenAccount the SPL token account holding the liquidity pool tokens
|
||||||
|
* @param lpTokenAmount the amount of liquidity pool tokens held in the SPL token account
|
||||||
|
*/
|
||||||
|
public record RaydiumLiquidityPoolPositionStandard(
|
||||||
|
ΩRaydiumLiquidityPoolPositionIdΩ positionId,
|
||||||
|
ΩRaydiumLiquidityPoolPositionMintIdΩ lpMintId,
|
||||||
|
ΩRaydiumLiquidityPoolAccountΩ lpAccount,
|
||||||
|
ΩSPLTokenAccountΩ lpTokenAccount,
|
||||||
|
ΩAmountΩ lpTokenAmount
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current price of the tokens in a Raydium liquidity pool.
|
||||||
|
*
|
||||||
|
* <p>The 'poolId' identifies the Raydium liquidity pool for which the current token price applies.
|
||||||
|
* The 'price' represents the price value returned or calculated for that pool.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium liquidity pool id that the price belongs to
|
||||||
|
* @param price the price for a token in the liquidity pool
|
||||||
|
*/
|
||||||
|
public record RaydiumLiquidityPoolPrice(
|
||||||
|
ΩRaydiumLiquidityPoolIdΩ poolId,
|
||||||
|
AssetPrice price
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.r35157.libs.raydium;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents token amounts for a Raydium liquidity pool or liquidity position.
|
||||||
|
*
|
||||||
|
* <p>The pool id identifies the Raydium liquidity pool that the amounts belong to.
|
||||||
|
* The two mint addresses identify the tokens in the pool, and the corresponding
|
||||||
|
* amounts describe how much of each token is represented.</p>
|
||||||
|
*
|
||||||
|
* <p>This record can be used for total pool amounts as well as calculated position
|
||||||
|
* amounts, depending on the method returning it.</p>
|
||||||
|
*
|
||||||
|
* @param poolId the Raydium liquidity pool id
|
||||||
|
* @param mintA the SPL mint address of token A
|
||||||
|
* @param amountA the amount of token A
|
||||||
|
* @param mintB the SPL mint address of token B
|
||||||
|
* @param amountB the amount of token B
|
||||||
|
*/
|
||||||
|
public record RaydiumLiquidityPoolTokenAmounts(
|
||||||
|
ΩRaydiumLiquidityPoolIdΩ poolId,
|
||||||
|
ΩSPLMintAddressΩ mintA,
|
||||||
|
ΩAmountΩ amountA,
|
||||||
|
ΩSPLMintAddressΩ mintB,
|
||||||
|
ΩAmountΩ amountB
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
package com.r35157.libs.raydium.impl.ref;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.WellKnownTradingPairs;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.r35157.libs.raydium.Raydium;
|
||||||
|
import com.r35157.libs.raydium.RaydiumConcentratedPositionState;
|
||||||
|
import com.r35157.libs.raydium.RaydiumConcentratedPoolInfo;
|
||||||
|
import com.r35157.libs.raydium.RaydiumConcentratedPoolState;
|
||||||
|
import com.r35157.libs.raydium.RaydiumLiquidityPoolTokenAmounts;
|
||||||
|
import com.r35157.libs.solana.SPLTokenHolding;
|
||||||
|
import com.r35157.libs.solana.SPLTokenSupply;
|
||||||
|
import com.r35157.libs.solana.SolanaAccountInfo;
|
||||||
|
import com.r35157.libs.solana.SolanaBlockChain;
|
||||||
|
import com.r35157.libs.solana.SolanaProgramAddressSeed;
|
||||||
|
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||||
|
import com.r35157.libs.valuetypes.basic.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
||||||
|
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
||||||
|
|
||||||
|
public class RaydiumImpl implements Raydium {
|
||||||
|
|
||||||
|
public RaydiumImpl(SolanaBlockChain solanaBlockChain) {
|
||||||
|
this.solanaBlockChain = solanaBlockChain;
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException {
|
||||||
|
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId);
|
||||||
|
|
||||||
|
JsonNode root = fetchJson(endpoint);
|
||||||
|
|
||||||
|
JsonNode firstPool = getFirstPoolNode(root);
|
||||||
|
JsonNode priceNode = firstPool.path("price");
|
||||||
|
|
||||||
|
if (priceNode.isMissingNode() || !priceNode.isNumber()) {
|
||||||
|
throw new IOException("Could NOT find field 'price' in JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Find out how not to hardcode the trading pair here
|
||||||
|
TradingPair tp = WellKnownTradingPairs.SOL_SYRUPUSDC.getTradingPair();
|
||||||
|
ΩPriceΩ p = new ΩPriceΩ(priceNode.toString());
|
||||||
|
AssetPrice ap = new AssetPrice(p, tp);
|
||||||
|
|
||||||
|
return ap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<ΩRaydiumLiquidityPoolIdΩ> fetchLiquidityPoolIds(ΩSolanaAddressΩ ownerAddress) throws IOException, InterruptedException {
|
||||||
|
Set<ΩRaydiumLiquidityPoolIdΩ> liquidityPoolIds = new HashSet<>();
|
||||||
|
|
||||||
|
liquidityPoolIds.addAll(fetchStandardLiquidityPoolIds(ownerAddress));
|
||||||
|
liquidityPoolIds.addAll(fetchConcentratedLiquidityPoolIds(ownerAddress));
|
||||||
|
|
||||||
|
return Set.copyOf(liquidityPoolIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
|
||||||
|
ΩSolanaAddressΩ ownerAddress
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Set<ΩSolanaNFTAddressΩ> candidates;
|
||||||
|
Set<ΩSolanaNFTAddressΩ> allCandidates = new HashSet<>();
|
||||||
|
|
||||||
|
System.out.println("Searching for legacy NFTs...");
|
||||||
|
candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, SPL_TOKEN_PROGRAM);
|
||||||
|
System.out.println("Found " + candidates.size() + " legacy NFTs");
|
||||||
|
allCandidates.addAll(candidates);
|
||||||
|
|
||||||
|
System.out.println("Searching for token_2022 NFTs...");
|
||||||
|
candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, TOKEN_2022_PROGRAM);
|
||||||
|
System.out.println("Found " + candidates.size() + " token_2022 NFTs");
|
||||||
|
allCandidates.addAll(candidates);
|
||||||
|
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolTokenAmounts(
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
RaydiumStandardPoolInfo poolInfo = fetchStandardPoolInfo(poolId);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts(
|
||||||
|
poolInfo.poolId(),
|
||||||
|
poolInfo.mintA(),
|
||||||
|
poolInfo.amountA(),
|
||||||
|
poolInfo.mintB(),
|
||||||
|
poolInfo.amountB()
|
||||||
|
);
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolPositionTokenAmounts(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
RaydiumStandardPoolInfo poolInfo = fetchStandardPoolInfo(poolId);
|
||||||
|
|
||||||
|
SPLTokenHolding ownerLpTokenHolding = fetchOwnerLpTokenHolding(
|
||||||
|
ownerAddress,
|
||||||
|
poolInfo.lpMint()
|
||||||
|
);
|
||||||
|
|
||||||
|
SPLTokenSupply lpTokenSupply = solanaBlockChain.getSPLTokenSupply(
|
||||||
|
poolInfo.lpMint(),
|
||||||
|
SPL_TOKEN_PROGRAM
|
||||||
|
);
|
||||||
|
|
||||||
|
ΩAmountΩ ownerLpAmount = ownerLpTokenHolding.uiAmount();
|
||||||
|
ΩAmountΩ ownerShare = ownerLpAmount.divide(
|
||||||
|
lpTokenSupply.uiAmount(),
|
||||||
|
MathContext.DECIMAL128
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: This is a pool-share estimate, not an exact Raydium withdraw preview.
|
||||||
|
// The calculation uses Raydium REST pool amounts and Solana LP mint supply:
|
||||||
|
//
|
||||||
|
// ownerShare = ownerLpAmount / lpMintSupply
|
||||||
|
// ownerAmount = poolAmount * ownerShare
|
||||||
|
//
|
||||||
|
// This matches the simple LP-share model, but it may not match the Raydium UI's
|
||||||
|
// remove-liquidity preview exactly.
|
||||||
|
//
|
||||||
|
// Observed example:
|
||||||
|
// poolId: 8os8bnXoy5voKv3uBPPuVGyqWZGJaa2RRri5RbLUwPCY
|
||||||
|
// LP amount owned: 0.077459898
|
||||||
|
// LP mint supply: 0.077459898
|
||||||
|
// calculated: 2.1594651 EVE / 31.867153 USDT
|
||||||
|
// Raydium UI 100% withdraw preview: 2.1062559 EVE / 31.55046 USDT
|
||||||
|
//
|
||||||
|
// Likely reason:
|
||||||
|
// The REST mintAmountA/mintAmountB values do not necessarily represent the
|
||||||
|
// exact withdrawable vault amounts. Raydium's on-chain CPMM pool state has
|
||||||
|
// fee buckets such as protocol fees, fund fees and creator fees. A precise
|
||||||
|
// withdraw-preview calculation probably needs to decode the on-chain pool
|
||||||
|
// state, fetch vault balances and subtract non-withdrawable fee amounts.
|
||||||
|
//
|
||||||
|
// Future fix:
|
||||||
|
// Decode the on-chain CPMM pool state, fetch the token vault balances,
|
||||||
|
// subtract protocol/fund/creator fee buckets, and calculate the owner's
|
||||||
|
// share from those withdrawable balances instead of from REST mintAmountA
|
||||||
|
// and mintAmountB.
|
||||||
|
ΩAmountΩ ownerAmountA = poolInfo.amountA().multiply(ownerShare);
|
||||||
|
ΩAmountΩ ownerAmountB = poolInfo.amountB().multiply(ownerShare);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts(
|
||||||
|
poolInfo.poolId(),
|
||||||
|
poolInfo.mintA(),
|
||||||
|
ownerAmountA,
|
||||||
|
poolInfo.mintB(),
|
||||||
|
ownerAmountB
|
||||||
|
);
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumConcentratedPositionState fetchConcentratedPositionState(
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
SolanaAccountInfo positionAccountInfo = fetchRaydiumPositionAccountInfo(positionNftId);
|
||||||
|
byte[] accountData = decodeBase64AccountData(positionAccountInfo);
|
||||||
|
|
||||||
|
byte[] poolIdBytes = extractConcentratedLiquidityPoolIdBytes(accountData);
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId = solanaBlockChain.encodeSolanaAddress(poolIdBytes);
|
||||||
|
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickLowerIndex = extractConcentratedLiquidityTickIndex(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET,
|
||||||
|
"tickLowerIndex"
|
||||||
|
);
|
||||||
|
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickUpperIndex = extractConcentratedLiquidityTickIndex(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET,
|
||||||
|
"tickUpperIndex"
|
||||||
|
);
|
||||||
|
|
||||||
|
ΩRaydiumLiquidityΩ liquidity = extractConcentratedLiquidity(accountData);
|
||||||
|
|
||||||
|
RaydiumConcentratedPositionState state = new RaydiumConcentratedPositionState(
|
||||||
|
positionNftId,
|
||||||
|
poolId,
|
||||||
|
tickLowerIndex,
|
||||||
|
tickUpperIndex,
|
||||||
|
liquidity
|
||||||
|
);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumConcentratedPoolInfo fetchConcentratedPoolInfo(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(poolId);
|
||||||
|
|
||||||
|
JsonNode root = fetchJson(endpoint);
|
||||||
|
JsonNode firstPool = getFirstPoolNode(root);
|
||||||
|
|
||||||
|
ΩSPLMintAddressΩ mintA = extractMintAddress(firstPool, "mintA");
|
||||||
|
ΩamountDecimalsΩ mintADecimals = extractMintDecimals(firstPool, "mintA");
|
||||||
|
ΩSPLMintAddressΩ mintB = extractMintAddress(firstPool, "mintB");
|
||||||
|
ΩamountDecimalsΩ mintBDecimals = extractMintDecimals(firstPool, "mintB");
|
||||||
|
ΩPriceΩ priceEstimate = extractPriceEstimate(firstPool);
|
||||||
|
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo = new RaydiumConcentratedPoolInfo(
|
||||||
|
poolId,
|
||||||
|
mintA,
|
||||||
|
mintADecimals,
|
||||||
|
mintB,
|
||||||
|
mintBDecimals,
|
||||||
|
priceEstimate
|
||||||
|
);
|
||||||
|
return poolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumConcentratedPoolState fetchConcentratedPoolState(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
SolanaAccountInfo poolAccountInfo = fetchRaydiumPoolAccountInfo(poolId);
|
||||||
|
byte[] accountData = decodeBase64AccountData(poolAccountInfo);
|
||||||
|
|
||||||
|
ΩRaydiumLiquidityΩ liquidity = extractConcentratedPoolLiquidity(accountData);
|
||||||
|
ΩRaydiumSqrtPriceX64Ω sqrtPriceX64 = extractConcentratedPoolSqrtPriceX64(accountData);
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickCurrent = extractConcentratedLiquidityTickIndex(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POOL_STATE_TICK_CURRENT_OFFSET,
|
||||||
|
"tickCurrent"
|
||||||
|
);
|
||||||
|
|
||||||
|
RaydiumConcentratedPoolState state = new RaydiumConcentratedPoolState(
|
||||||
|
poolId,
|
||||||
|
liquidity,
|
||||||
|
sqrtPriceX64,
|
||||||
|
tickCurrent
|
||||||
|
);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Range<AssetPrice> calculateConcentratedPositionPriceRange(
|
||||||
|
RaydiumConcentratedPositionState positionState,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
int decimalPlaces
|
||||||
|
) {
|
||||||
|
TradingPair tp = WellKnownTradingPairs.SOL_SYRUPUSDC.getTradingPair();
|
||||||
|
|
||||||
|
ΩPriceΩ priceFrom = calculatePriceFromTick(
|
||||||
|
positionState.tickLowerIndex(),
|
||||||
|
poolInfo,
|
||||||
|
decimalPlaces
|
||||||
|
);
|
||||||
|
AssetPrice apFrom = new AssetPrice(priceFrom, tp);
|
||||||
|
|
||||||
|
ΩPriceΩ priceTo = calculatePriceFromTick(
|
||||||
|
positionState.tickUpperIndex(),
|
||||||
|
poolInfo,
|
||||||
|
decimalPlaces
|
||||||
|
);
|
||||||
|
AssetPrice apTo = new AssetPrice(priceTo, tp);
|
||||||
|
|
||||||
|
Range<AssetPrice> range = new Range<>(apFrom, true, apTo, false);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RaydiumLiquidityPoolTokenAmounts calculateConcentratedPositionTokenAmounts(
|
||||||
|
RaydiumConcentratedPositionState positionState,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
RaydiumConcentratedPoolState poolState
|
||||||
|
) {
|
||||||
|
RaydiumLiquidityPoolTokenAmounts amounts = calculateConcentratedPositionTokenAmounts(
|
||||||
|
positionState,
|
||||||
|
poolInfo,
|
||||||
|
calculateCurrentSqrtRawPrice(poolState)
|
||||||
|
);
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RaydiumLiquidityPoolTokenAmounts calculateConcentratedPositionTokenAmounts(
|
||||||
|
RaydiumConcentratedPositionState positionState,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceCurrent
|
||||||
|
) {
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceLower = calculateSqrtRawPriceFromTick(positionState.tickLowerIndex());
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceUpper = calculateSqrtRawPriceFromTick(positionState.tickUpperIndex());
|
||||||
|
BigDecimal liquidity = new BigDecimal(positionState.liquidity());
|
||||||
|
|
||||||
|
BigDecimal rawAmountA;
|
||||||
|
BigDecimal rawAmountB;
|
||||||
|
|
||||||
|
if (sqrtPriceCurrent.compareTo(sqrtPriceLower) <= 0) {
|
||||||
|
rawAmountA = calculateAmountAForLiquidity(liquidity, sqrtPriceLower, sqrtPriceUpper);
|
||||||
|
rawAmountB = BigDecimal.ZERO;
|
||||||
|
} else if (sqrtPriceCurrent.compareTo(sqrtPriceUpper) >= 0) {
|
||||||
|
rawAmountA = BigDecimal.ZERO;
|
||||||
|
rawAmountB = calculateAmountBForLiquidity(liquidity, sqrtPriceLower, sqrtPriceUpper);
|
||||||
|
} else {
|
||||||
|
rawAmountA = calculateAmountAForLiquidity(liquidity, sqrtPriceCurrent, sqrtPriceUpper);
|
||||||
|
rawAmountB = calculateAmountBForLiquidity(liquidity, sqrtPriceLower, sqrtPriceCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
ΩAmountΩ amountA = convertRawTokenAmountToUiAmount(
|
||||||
|
rawAmountA,
|
||||||
|
poolInfo.mintADecimals()
|
||||||
|
);
|
||||||
|
|
||||||
|
ΩAmountΩ amountB = convertRawTokenAmountToUiAmount(
|
||||||
|
rawAmountB,
|
||||||
|
poolInfo.mintBDecimals()
|
||||||
|
);
|
||||||
|
|
||||||
|
RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts(
|
||||||
|
poolInfo.poolId(),
|
||||||
|
poolInfo.mintA(),
|
||||||
|
amountA,
|
||||||
|
poolInfo.mintB(),
|
||||||
|
amountB
|
||||||
|
);
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumSqrtPriceΩ calculateCurrentSqrtRawPrice(RaydiumConcentratedPoolState poolState) {
|
||||||
|
ΩRaydiumSqrtPriceΩ p = new BigDecimal(poolState.sqrtPriceX64()).divide(
|
||||||
|
RAYDIUM_Q64_FACTOR,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumSqrtPriceΩ calculateSqrtRawPriceFromTick(ΩraydiumLiquidityTickIndexΩ tickIndex) {
|
||||||
|
BigDecimal rawPrice = pow(RAYDIUM_TICK_BASE, tickIndex);
|
||||||
|
BigDecimal result = rawPrice.sqrt(RAYDIUM_PRICE_MATH_CONTEXT);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateAmountAForLiquidity(
|
||||||
|
BigDecimal liquidity,
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceLower,
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceUpper
|
||||||
|
) {
|
||||||
|
BigDecimal numerator = liquidity.multiply(
|
||||||
|
sqrtPriceUpper.subtract(sqrtPriceLower, RAYDIUM_PRICE_MATH_CONTEXT),
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
BigDecimal denominator = sqrtPriceUpper.multiply(
|
||||||
|
sqrtPriceLower,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
BigDecimal result = numerator.divide(
|
||||||
|
denominator,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateAmountBForLiquidity(
|
||||||
|
BigDecimal liquidity,
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceLower,
|
||||||
|
ΩRaydiumSqrtPriceΩ sqrtPriceUpper
|
||||||
|
) {
|
||||||
|
BigDecimal result = liquidity.multiply(
|
||||||
|
sqrtPriceUpper.subtract(sqrtPriceLower, RAYDIUM_PRICE_MATH_CONTEXT),
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ convertRawTokenAmountToUiAmount(
|
||||||
|
BigDecimal rawAmount,
|
||||||
|
ΩamountDecimalsΩ decimals
|
||||||
|
) {
|
||||||
|
BigDecimal decimalFactor = pow(BigDecimal.TEN, decimals);
|
||||||
|
|
||||||
|
ΩAmountΩ a = rawAmount.divide(
|
||||||
|
decimalFactor,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
a = a.setScale(decimals, RoundingMode.HALF_UP);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩPriceΩ calculatePriceFromTick(
|
||||||
|
ΩraydiumLiquidityTickIndexΩ tickIndex,
|
||||||
|
RaydiumConcentratedPoolInfo poolInfo,
|
||||||
|
int decimalPlaces
|
||||||
|
) {
|
||||||
|
if (decimalPlaces < 0) {
|
||||||
|
throw new IllegalArgumentException("decimalPlaces must not be negative!");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal rawPrice = pow(RAYDIUM_TICK_BASE, tickIndex);
|
||||||
|
BigDecimal decimalFactor = calculateDecimalFactor(poolInfo);
|
||||||
|
|
||||||
|
ΩPriceΩ p = rawPrice.multiply(
|
||||||
|
decimalFactor,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
p = p.setScale(
|
||||||
|
decimalPlaces,
|
||||||
|
RoundingMode.HALF_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateDecimalFactor(RaydiumConcentratedPoolInfo poolInfo) {
|
||||||
|
BigDecimal result = pow(
|
||||||
|
BigDecimal.TEN,
|
||||||
|
poolInfo.mintADecimals() - poolInfo.mintBDecimals()
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal pow(BigDecimal base, int exponent) {
|
||||||
|
BigDecimal power = base.pow(
|
||||||
|
Math.abs(exponent),
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exponent >= 0) {
|
||||||
|
return power;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal result = BigDecimal.ONE.divide(
|
||||||
|
power,
|
||||||
|
RAYDIUM_PRICE_MATH_CONTEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SPLTokenHolding fetchOwnerLpTokenHolding(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
ΩSPLMintAddressΩ lpMint
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
var tokenHoldings = solanaBlockChain.getSPLTokenHoldings(
|
||||||
|
ownerAddress,
|
||||||
|
SPL_TOKEN_PROGRAM
|
||||||
|
);
|
||||||
|
|
||||||
|
SPLTokenHolding tokenHolding = tokenHoldings.get(lpMint);
|
||||||
|
|
||||||
|
if (tokenHolding == null) {
|
||||||
|
throw new IOException("Owner does not hold LP token mint: " + lpMint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenHolding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RaydiumStandardPoolInfo fetchStandardPoolInfo(
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(poolId);
|
||||||
|
|
||||||
|
JsonNode root = fetchJson(endpoint);
|
||||||
|
JsonNode firstPool = getFirstPoolNode(root);
|
||||||
|
|
||||||
|
ΩSPLMintAddressΩ mintA = extractMintAddress(firstPool, "mintA");
|
||||||
|
ΩAmountΩ amountA = extractAmount(firstPool, "mintAmountA");
|
||||||
|
ΩSPLMintAddressΩ mintB = extractMintAddress(firstPool, "mintB");
|
||||||
|
ΩAmountΩ amountB = extractAmount(firstPool, "mintAmountB");
|
||||||
|
ΩSPLMintAddressΩ lpMint = extractMintAddress(firstPool, "lpMint");
|
||||||
|
ΩAmountΩ totalLpAmount = extractAmount(firstPool, "lpAmount");
|
||||||
|
|
||||||
|
return new RaydiumStandardPoolInfo(
|
||||||
|
poolId,
|
||||||
|
mintA,
|
||||||
|
amountA,
|
||||||
|
mintB,
|
||||||
|
amountB,
|
||||||
|
lpMint,
|
||||||
|
totalLpAmount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩSPLMintAddressΩ extractMintAddress(JsonNode poolNode, String mintFieldName) throws IOException {
|
||||||
|
JsonNode addressNode = poolNode.path(mintFieldName).path("address");
|
||||||
|
|
||||||
|
if (addressNode.isMissingNode() || !addressNode.isTextual()) {
|
||||||
|
throw new IOException("Could NOT find textual field '" + mintFieldName + ".address' in pool JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return addressNode.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩAmountΩ extractAmount(JsonNode poolNode, String amountFieldName) throws IOException {
|
||||||
|
JsonNode amountNode = poolNode.path(amountFieldName);
|
||||||
|
|
||||||
|
if (amountNode.isMissingNode() || !amountNode.isNumber()) {
|
||||||
|
throw new IOException("Could NOT find numeric field '" + amountFieldName + "' in pool JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BigDecimal(amountNode.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩamountDecimalsΩ extractMintDecimals(JsonNode poolNode, String mintFieldName) throws IOException {
|
||||||
|
JsonNode decimalsNode = poolNode.path(mintFieldName).path("decimals");
|
||||||
|
|
||||||
|
if (decimalsNode.isMissingNode() || !decimalsNode.isNumber()) {
|
||||||
|
throw new IOException("Could NOT find numeric field '" + mintFieldName + ".decimals' in pool JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimalsNode.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩPriceΩ extractPriceEstimate(JsonNode poolNode) throws IOException {
|
||||||
|
JsonNode priceNode = poolNode.path("price");
|
||||||
|
|
||||||
|
if (priceNode.isMissingNode() || !priceNode.isNumber()) {
|
||||||
|
throw new IOException("Could NOT find numeric field 'price' in pool JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BigDecimal(priceNode.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<ΩRaydiumLiquidityPoolStandardIdΩ> fetchStandardLiquidityPoolIds(ΩSolanaAddressΩ ownerAddress) throws IOException, InterruptedException {
|
||||||
|
var tokenHoldings = solanaBlockChain.getSPLTokenHoldings(
|
||||||
|
ownerAddress,
|
||||||
|
SPL_TOKEN_PROGRAM
|
||||||
|
);
|
||||||
|
|
||||||
|
Set<ΩSPLMintAddressΩ> mintAddresses = tokenHoldings.keySet();
|
||||||
|
|
||||||
|
if (mintAddresses.isEmpty()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
ΩRestEndpointΩ endpoint = createPoolInfoByCandidateLpMintsEndpoint(mintAddresses);
|
||||||
|
JsonNode root = fetchJson(endpoint);
|
||||||
|
|
||||||
|
return extractStandardLiquidityPoolIds(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> fetchConcentratedLiquidityPoolIds(
|
||||||
|
ΩSolanaAddressΩ ownerAddress
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds = fetchConcentratedPositionNftIds(ownerAddress);
|
||||||
|
Set<SolanaAccountInfo> positionAccountInfos = fetchRaydiumPositionAccountInfos(positionNftIds);
|
||||||
|
|
||||||
|
return extractConcentratedLiquidityPoolIds(positionAccountInfos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> filterCandidatePositionNftIds(
|
||||||
|
Set<ΩSolanaNFTAddressΩ> candidatePositionNftIds
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds = new HashSet<>();
|
||||||
|
|
||||||
|
System.out.println("Filtering " + candidatePositionNftIds.size() + " NFTs for Raydium Concentrated pools...");
|
||||||
|
for (ΩSolanaNFTAddressΩ candidatePositionNftId : candidatePositionNftIds) {
|
||||||
|
System.out.print(" " + candidatePositionNftId + " ");
|
||||||
|
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
|
||||||
|
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
||||||
|
|
||||||
|
if (accountInfo == null) {
|
||||||
|
System.out.println("NO");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||||
|
System.out.println("YES");
|
||||||
|
positionNftIds.add(candidatePositionNftId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set.copyOf(positionNftIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SolanaProgramDerivedAddress findRaydiumPositionPda(ΩSolanaNFTAddressΩ candidatePositionNftId) {
|
||||||
|
return solanaBlockChain.findProgramAddress(
|
||||||
|
RAYDIUM_CLMM_PROGRAM_ID,
|
||||||
|
List.of(
|
||||||
|
SolanaProgramAddressSeed.utf8("position"),
|
||||||
|
SolanaProgramAddressSeed.solanaAddress(candidatePositionNftId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SolanaAccountInfo fetchRaydiumPositionAccountInfo(
|
||||||
|
ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(positionNftId);
|
||||||
|
|
||||||
|
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
||||||
|
|
||||||
|
if (accountInfo == null) {
|
||||||
|
throw new IOException("Raydium position account was not found for position NFT id: " + positionNftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||||
|
throw new IOException("Raydium position account is not owned by the Raydium CLMM program: " + positionPda.address());
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SolanaAccountInfo fetchRaydiumPoolAccountInfo(
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(poolId);
|
||||||
|
|
||||||
|
if (accountInfo == null) {
|
||||||
|
throw new IOException("Raydium concentrated pool account was not found for pool id: " + poolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||||
|
throw new IOException("Raydium concentrated pool account is not owned by the Raydium CLMM program: " + poolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<SolanaAccountInfo> fetchRaydiumPositionAccountInfos(
|
||||||
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Set<SolanaAccountInfo> accountInfos = new HashSet<>();
|
||||||
|
|
||||||
|
for (ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId : positionNftIds) {
|
||||||
|
accountInfos.add(fetchRaydiumPositionAccountInfo(positionNftId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set.copyOf(accountInfos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> extractConcentratedLiquidityPoolIds(
|
||||||
|
Set<SolanaAccountInfo> positionAccountInfos
|
||||||
|
) throws IOException {
|
||||||
|
Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> liquidityPoolIds = new HashSet<>();
|
||||||
|
|
||||||
|
for (SolanaAccountInfo positionAccountInfo : positionAccountInfos) {
|
||||||
|
byte[] accountData = decodeBase64AccountData(positionAccountInfo);
|
||||||
|
|
||||||
|
byte[] poolIdBytes = extractConcentratedLiquidityPoolIdBytes(accountData);
|
||||||
|
ΩRaydiumLiquidityPoolConcentratedIdΩ liquidityPoolId = solanaBlockChain.encodeSolanaAddress(poolIdBytes);
|
||||||
|
|
||||||
|
liquidityPoolIds.add(liquidityPoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set.copyOf(liquidityPoolIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<ΩRaydiumLiquidityPoolStandardIdΩ> extractStandardLiquidityPoolIds(JsonNode root) throws IOException {
|
||||||
|
JsonNode data = root.path("data");
|
||||||
|
if (!data.isArray()) {
|
||||||
|
throw new IOException("Returned JSON do NOT contain a valid data-array!");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<ΩRaydiumLiquidityPoolStandardIdΩ> liquidityPoolIds = new HashSet<>();
|
||||||
|
|
||||||
|
for (JsonNode poolNode : data) {
|
||||||
|
if (poolNode == null || poolNode.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode idNode = poolNode.path("id");
|
||||||
|
|
||||||
|
if (idNode.isMissingNode() || !idNode.isTextual()) {
|
||||||
|
throw new IOException("Could NOT find textual field 'id' in pool JSON!");
|
||||||
|
}
|
||||||
|
|
||||||
|
liquidityPoolIds.add(idNode.asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set.copyOf(liquidityPoolIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRestEndpointΩ createPoolInfoByIdEndpoint(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) {
|
||||||
|
return ΩRestEndpointΩ.create(RAYDIUM_API_V3_BASE_URI + "/pools/info/ids?ids=" + raydiumLiquidityPoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode fetchJson(ΩRestEndpointΩ restEndpoint) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(restEndpoint)
|
||||||
|
.GET()
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = sendThrottled(request);
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("HTTP error: " + response.statusCode() + ", body: " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.readTree(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized HttpResponse<String> sendThrottled(HttpRequest request) throws IOException, InterruptedException {
|
||||||
|
waitBeforeRemoteCall();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return httpClient.send(
|
||||||
|
request,
|
||||||
|
HttpResponse.BodyHandlers.ofString()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
previousRemoteCallTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void waitBeforeRemoteCall() throws InterruptedException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long elapsed = now - previousRemoteCallTime;
|
||||||
|
|
||||||
|
if (elapsed < MINIMUM_REMOTE_CALL_INTERVAL) {
|
||||||
|
ΩmilliSecondsΩ sleepTime = MINIMUM_REMOTE_CALL_INTERVAL - elapsed;
|
||||||
|
//System.out.println("Throttling Raydium request for " + sleepTime + "ms...");
|
||||||
|
Thread.sleep(sleepTime);
|
||||||
|
//System.out.println("Ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRestEndpointΩ createPoolInfoByCandidateLpMintsEndpoint(Set<ΩSPLMintAddressΩ> candidateLpMintAddresses) {
|
||||||
|
String joinedLpMintAddresses = String.join(
|
||||||
|
",",
|
||||||
|
candidateLpMintAddresses.stream()
|
||||||
|
.map(Object::toString)
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ΩRestEndpointΩ.create(RAYDIUM_API_V3_BASE_URI + "/pools/info/lps?lps=" + joinedLpMintAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] decodeBase64AccountData(SolanaAccountInfo accountInfo) throws IOException {
|
||||||
|
try {
|
||||||
|
return Base64.getDecoder().decode(accountInfo.dataBase64());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IOException("Could not decode Solana account data as Base64 for account: " + accountInfo.address(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] extractConcentratedLiquidityPoolIdBytes(byte[] accountData) throws IOException {
|
||||||
|
ensureAccountDataContains(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET,
|
||||||
|
SOLANA_ADDRESS_LENGTH,
|
||||||
|
"pool id"
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] poolIdBytes = new byte[SOLANA_ADDRESS_LENGTH];
|
||||||
|
|
||||||
|
System.arraycopy(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET,
|
||||||
|
poolIdBytes,
|
||||||
|
0,
|
||||||
|
SOLANA_ADDRESS_LENGTH
|
||||||
|
);
|
||||||
|
|
||||||
|
return poolIdBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩraydiumLiquidityTickIndexΩ extractConcentratedLiquidityTickIndex(
|
||||||
|
byte[] accountData,
|
||||||
|
int offset,
|
||||||
|
String fieldName
|
||||||
|
) throws IOException {
|
||||||
|
ensureAccountDataContains(
|
||||||
|
accountData,
|
||||||
|
offset,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH,
|
||||||
|
fieldName
|
||||||
|
);
|
||||||
|
|
||||||
|
return (accountData[offset] & 0xFF)
|
||||||
|
| ((accountData[offset + 1] & 0xFF) << 8)
|
||||||
|
| ((accountData[offset + 2] & 0xFF) << 16)
|
||||||
|
| ((accountData[offset + 3] & 0xFF) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumLiquidityΩ extractConcentratedLiquidity(byte[] accountData) throws IOException {
|
||||||
|
return extractUnsigned128LittleEndian(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_LIQUIDITY_OFFSET,
|
||||||
|
"liquidity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumLiquidityΩ extractConcentratedPoolLiquidity(byte[] accountData) throws IOException {
|
||||||
|
return extractUnsigned128LittleEndian(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET,
|
||||||
|
"liquidity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ΩRaydiumSqrtPriceX64Ω extractConcentratedPoolSqrtPriceX64(byte[] accountData) throws IOException {
|
||||||
|
return extractUnsigned128LittleEndian(
|
||||||
|
accountData,
|
||||||
|
RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET,
|
||||||
|
"sqrtPriceX64"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger extractUnsigned128LittleEndian(
|
||||||
|
byte[] accountData,
|
||||||
|
int offset,
|
||||||
|
String fieldName
|
||||||
|
) throws IOException {
|
||||||
|
ensureAccountDataContains(
|
||||||
|
accountData,
|
||||||
|
offset,
|
||||||
|
RAYDIUM_U128_LENGTH,
|
||||||
|
fieldName
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] bigEndianBytes = new byte[RAYDIUM_U128_LENGTH + 1];
|
||||||
|
|
||||||
|
for (int i = 0; i < RAYDIUM_U128_LENGTH; i++) {
|
||||||
|
bigEndianBytes[bigEndianBytes.length - 1 - i] = accountData[offset + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BigInteger(bigEndianBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureAccountDataContains(
|
||||||
|
byte[] accountData,
|
||||||
|
int offset,
|
||||||
|
int length,
|
||||||
|
String fieldName
|
||||||
|
) throws IOException {
|
||||||
|
if (accountData.length < offset + length) {
|
||||||
|
throw new IOException("Raydium position account data is too short to contain " + fieldName + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode getFirstPoolNode(JsonNode root) throws IOException {
|
||||||
|
JsonNode data = root.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray() || data.isEmpty()) {
|
||||||
|
throw new IOException("Returned JSON do NOT contain a valid data-array!");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode firstPool = data.get(0);
|
||||||
|
|
||||||
|
if (firstPool == null || firstPool.isNull()) {
|
||||||
|
throw new IOException("Returned JSON data-array did NOT contain a pool!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RaydiumStandardPoolInfo(
|
||||||
|
ΩRaydiumLiquidityPoolStandardIdΩ poolId,
|
||||||
|
ΩSPLMintAddressΩ mintA,
|
||||||
|
ΩAmountΩ amountA,
|
||||||
|
ΩSPLMintAddressΩ mintB,
|
||||||
|
ΩAmountΩ amountB,
|
||||||
|
ΩSPLMintAddressΩ lpMint,
|
||||||
|
ΩAmountΩ totalLpAmount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ΩRestEndpointBaseΩ RAYDIUM_API_V3_BASE_URI = ΩRestEndpointBaseΩ.create("https://api-v3.raydium.io");
|
||||||
|
private static final ΩRaydiumProgramIdΩ RAYDIUM_CLMM_PROGRAM_ID = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK";
|
||||||
|
private static final ΩmilliSecondsΩ MINIMUM_REMOTE_CALL_INTERVAL = 5000L;
|
||||||
|
private static final BigDecimal RAYDIUM_TICK_BASE = new BigDecimal("1.0001");
|
||||||
|
private static final BigDecimal RAYDIUM_Q64_FACTOR = new BigDecimal(BigInteger.ONE.shiftLeft(64));
|
||||||
|
private static final MathContext RAYDIUM_PRICE_MATH_CONTEXT = MathContext.DECIMAL128;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte layout used when decoding Raydium CLMM PersonalPositionState account data.
|
||||||
|
*
|
||||||
|
* <p>This layout is based on Raydium's on-chain {@code PersonalPositionState}
|
||||||
|
* account. The account starts with the 8-byte Anchor discriminator, followed by
|
||||||
|
* the packed position fields. The fields decoded here are the NFT mint, pool id,
|
||||||
|
* lower/upper tick indexes and position liquidity.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 0 - 8 Anchor discriminator
|
||||||
|
* 8 - 9 bump
|
||||||
|
* 9 - 41 nft_mint
|
||||||
|
* 41 - 73 pool_id
|
||||||
|
* 73 - 77 tick_lower_index i32 little-endian
|
||||||
|
* 77 - 81 tick_upper_index i32 little-endian
|
||||||
|
* 81 - 97 liquidity u128 little-endian
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>If Raydium changes the on-chain {@code PersonalPositionState} layout, these
|
||||||
|
* offsets must be checked and updated before decoding position state.</p>
|
||||||
|
*/
|
||||||
|
private static final int SOLANA_ADDRESS_LENGTH = 32;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_DISCRIMINATOR_LENGTH = 8;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_BUMP_LENGTH = 1;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_NFT_MINT_OFFSET =
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_DISCRIMINATOR_LENGTH + RAYDIUM_POSITION_ACCOUNT_BUMP_LENGTH;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET =
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_NFT_MINT_OFFSET + SOLANA_ADDRESS_LENGTH;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH = 4;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET =
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET + SOLANA_ADDRESS_LENGTH;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET =
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET + RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH;
|
||||||
|
private static final int RAYDIUM_U128_LENGTH = 16;
|
||||||
|
private static final int RAYDIUM_POSITION_ACCOUNT_LIQUIDITY_OFFSET =
|
||||||
|
RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET + RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte layout used when decoding Raydium CLMM PoolState account data.
|
||||||
|
*
|
||||||
|
* <p>This layout is based on Raydium's on-chain {@code PoolState} account.
|
||||||
|
* The account starts with the 8-byte Anchor discriminator, followed by the packed
|
||||||
|
* {@code PoolState} fields. The fields decoded here are pool liquidity,
|
||||||
|
* current square-root price in Q64.64 format, and current tick index.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 0 - 8 Anchor discriminator
|
||||||
|
* 8 - 9 bump
|
||||||
|
* 9 - 41 amm_config
|
||||||
|
* 41 - 73 owner
|
||||||
|
* 73 - 105 token_mint_0
|
||||||
|
* 105 - 137 token_mint_1
|
||||||
|
* 137 - 169 token_vault_0
|
||||||
|
* 169 - 201 token_vault_1
|
||||||
|
* 201 - 233 observation_key
|
||||||
|
* 233 - 234 mint_decimals_0
|
||||||
|
* 234 - 235 mint_decimals_1
|
||||||
|
* 235 - 237 tick_spacing
|
||||||
|
* 237 - 253 liquidity u128 little-endian
|
||||||
|
* 253 - 269 sqrt_price_x64 u128 little-endian
|
||||||
|
* 269 - 273 tick_current i32 little-endian
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>If Raydium changes the on-chain {@code PoolState} layout, these offsets must
|
||||||
|
* be checked and updated before decoding pool state.</p>
|
||||||
|
*/
|
||||||
|
private static final int RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET = 237;
|
||||||
|
private static final int RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET =
|
||||||
|
RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET + RAYDIUM_U128_LENGTH;
|
||||||
|
private static final int RAYDIUM_POOL_STATE_TICK_CURRENT_OFFSET =
|
||||||
|
RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET + RAYDIUM_U128_LENGTH;
|
||||||
|
|
||||||
|
private final SolanaBlockChain solanaBlockChain;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private ΩmilliSecondsΩ previousRemoteCallTime = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an SPL token holding owned by a Solana address.
|
||||||
|
*
|
||||||
|
* <p>The holding describes one SPL token account and the token mint it belongs to.
|
||||||
|
* The amount is represented both as a UI amount and as the raw on-chain amount,
|
||||||
|
* together with the number of decimals used by the token mint.</p>
|
||||||
|
*
|
||||||
|
* <p>The program id identifies which SPL token program owns the token account,
|
||||||
|
* for example the original SPL Token Program or the Token-2022 Program.</p>
|
||||||
|
*
|
||||||
|
* @param tokenAccount the SPL token account holding the token balance
|
||||||
|
* @param mintAddress the SPL mint address of the token
|
||||||
|
* @param uiAmount the human-readable token amount
|
||||||
|
* @param rawAmount the raw on-chain token amount before decimal conversion
|
||||||
|
* @param decimals the number of decimals used by the token mint
|
||||||
|
* @param programId the SPL token program id that owns the token account
|
||||||
|
*/
|
||||||
|
public record SPLTokenHolding(
|
||||||
|
ΩSPLTokenAccountΩ tokenAccount,
|
||||||
|
ΩSPLMintAddressΩ mintAddress,
|
||||||
|
ΩAmountΩ uiAmount,
|
||||||
|
ΩRawAmountΩ rawAmount,
|
||||||
|
ΩamountDecimalsΩ decimals,
|
||||||
|
ΩSPLProgramIdΩ programId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the total supply of an SPL token mint.
|
||||||
|
*
|
||||||
|
* <p>The supply is represented both as a UI amount and as the raw on-chain amount,
|
||||||
|
* together with the number of decimals used by the token mint.</p>
|
||||||
|
*
|
||||||
|
* @param mintAddress the SPL mint address whose supply was fetched
|
||||||
|
* @param uiAmount the human-readable token supply
|
||||||
|
* @param rawAmount the raw on-chain token supply before decimal conversion
|
||||||
|
* @param decimals the number of decimals used by the token mint
|
||||||
|
* @param programId the SPL token program id for the mint
|
||||||
|
*/
|
||||||
|
public record SPLTokenSupply(
|
||||||
|
ΩSPLMintAddressΩ mintAddress,
|
||||||
|
ΩAmountΩ uiAmount,
|
||||||
|
ΩRawAmountΩ rawAmount,
|
||||||
|
ΩamountDecimalsΩ decimals,
|
||||||
|
ΩSPLProgramIdΩ programId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents basic information about a Solana account.
|
||||||
|
*
|
||||||
|
* <p>The address identifies the account that was queried. The owner identifies
|
||||||
|
* the Solana program that owns the account. The account data is represented as
|
||||||
|
* a Base64 encoded string, matching the encoding returned by the Solana RPC
|
||||||
|
* account-info response.</p>
|
||||||
|
*
|
||||||
|
* <p>For normal wallet/system accounts, the data may be empty. Program-owned
|
||||||
|
* accounts, token accounts, mint accounts and application-specific state
|
||||||
|
* accounts may contain encoded account data.</p>
|
||||||
|
*
|
||||||
|
* @param address the Solana account address
|
||||||
|
* @param owner the Solana program id that owns the account
|
||||||
|
* @param dataBase64 the account data encoded as Base64, or an empty string if the account has no data
|
||||||
|
*/
|
||||||
|
public record SolanaAccountInfo(
|
||||||
|
ΩSolanaAddressΩ address,
|
||||||
|
ΩSolanaProgramIdΩ owner,
|
||||||
|
String dataBase64
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains Solana-related constants used by the Solana integration.
|
||||||
|
*
|
||||||
|
* <p>This class is intended for shared constants that are part of the public
|
||||||
|
* Solana API surface or are useful across Solana-related modules.</p>
|
||||||
|
*/
|
||||||
|
public class SolanaConstants {
|
||||||
|
/**
|
||||||
|
* The default Solana JSON-RPC endpoint used by the integration.
|
||||||
|
*/
|
||||||
|
public static final String RPC_URL = "https://api.mainnet.solana.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SPL mint address for Syrup USDC on Solana.
|
||||||
|
*/
|
||||||
|
public static final String SPL_TOKEN_SYRUPUSDC = "AvZZF1YaZDziPY2RCK4oJrRVrbN3mTD9NL24hPeaZeUj";
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a seed used when deriving a Solana program derived address.
|
||||||
|
*
|
||||||
|
* <p>A seed has a kind and a textual value. The kind tells the Solana
|
||||||
|
* implementation how the value should be converted to bytes before it is used
|
||||||
|
* in program address derivation.</p>
|
||||||
|
*
|
||||||
|
* <p>This keeps callers from having to perform encoding or decoding themselves.
|
||||||
|
* For example, a caller can provide a UTF-8 seed or a Solana address seed, and
|
||||||
|
* the implementation decides how each seed is converted to the byte format
|
||||||
|
* required by Solana.</p>
|
||||||
|
*
|
||||||
|
* @param kind the kind of seed
|
||||||
|
* @param value the textual seed value
|
||||||
|
*/
|
||||||
|
public record SolanaProgramAddressSeed(
|
||||||
|
SolanaProgramAddressSeedKind kind,
|
||||||
|
String value
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Creates a UTF-8 seed.
|
||||||
|
*
|
||||||
|
* @param value the text value to encode as UTF-8
|
||||||
|
* @return a program address seed representing the supplied UTF-8 text
|
||||||
|
*/
|
||||||
|
public static SolanaProgramAddressSeed utf8(String value) {
|
||||||
|
return new SolanaProgramAddressSeed(
|
||||||
|
SolanaProgramAddressSeedKind.UTF8,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Solana address seed.
|
||||||
|
*
|
||||||
|
* <p>The supplied address is represented textually here. The implementation
|
||||||
|
* deriving the program address is responsible for decoding the address into
|
||||||
|
* its raw Solana address bytes.</p>
|
||||||
|
*
|
||||||
|
* @param address the Solana address to use as a seed
|
||||||
|
* @return a program address seed representing the supplied Solana address
|
||||||
|
*/
|
||||||
|
public static SolanaProgramAddressSeed solanaAddress(ΩSolanaAddressΩ address) {
|
||||||
|
return new SolanaProgramAddressSeed(
|
||||||
|
SolanaProgramAddressSeedKind.SOLANA_ADDRESS,
|
||||||
|
address
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.r35157.libs.solana;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Solana program address seed should be converted to bytes.
|
||||||
|
*
|
||||||
|
* <p>Program derived addresses are calculated from byte seeds. This enum allows
|
||||||
|
* callers to describe the meaning of a seed without doing the byte conversion
|
||||||
|
* themselves.</p>
|
||||||
|
*/
|
||||||
|
public enum SolanaProgramAddressSeedKind {
|
||||||
|
/**
|
||||||
|
* A seed that should be encoded as UTF-8 text.
|
||||||
|
*/
|
||||||
|
UTF8,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seed that represents a Solana address and should be decoded from its
|
||||||
|
* textual Solana address representation to raw address bytes.
|
||||||
|
*/
|
||||||
|
SOLANA_ADDRESS
|
||||||
|
}
|
||||||
@@ -0,0 +1,741 @@
|
|||||||
|
package com.r35157.libs.solana.impl.ref;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.r35157.libs.solana.*;
|
||||||
|
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||||
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
|
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
|
||||||
|
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
|
import com.r35157.libs.valuetypes.basic.CurrencyType;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static com.r35157.libs.solana.SolanaConstants.RPC_URL;
|
||||||
|
|
||||||
|
public class SolanaBlockChainImpl implements SolanaBlockChain {
|
||||||
|
|
||||||
|
public SolanaBlockChainImpl() {
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩSolanaAmountΩ getBalanceInSolana(ΩSolanaAddressΩ address) throws IOException, InterruptedException {
|
||||||
|
ΩlamportsΩ lamport = getBalanceInLamport(address);
|
||||||
|
ΩAmountΩ bd = ΩAmountΩ.valueOf(lamport).divide(LAMPORTS_PER_SOL);
|
||||||
|
CurrencyType type = WellKnownCurrencyTypes.SOLANA.getCurrencyType();
|
||||||
|
ΩSolanaAmountΩ sa = new ΩSolanaAmountΩ(bd, type);
|
||||||
|
|
||||||
|
return sa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException {
|
||||||
|
String jsonBody = """
|
||||||
|
{
|
||||||
|
"jsonrpc":"2.0",
|
||||||
|
"id":1,
|
||||||
|
"method":"getBalance",
|
||||||
|
"params":[
|
||||||
|
"%s",
|
||||||
|
{
|
||||||
|
"commitment":"finalized"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(address);
|
||||||
|
|
||||||
|
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("Unexpected HTTP status: " + response.statusCode() + ", body: " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
|
||||||
|
JsonNode errorNode = root.get("error");
|
||||||
|
if (errorNode != null) {
|
||||||
|
throw new IOException("RPC error: " + errorNode.toPrettyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode valueNode = root.path("result").path("value");
|
||||||
|
if (valueNode.isMissingNode() || !valueNode.isNumber()) {
|
||||||
|
throw new IOException("Could not read balance from response: " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueNode.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
SolanaSPLTokenProgram splProgram
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "getTokenAccountsByOwner",
|
||||||
|
"params": [
|
||||||
|
"%s",
|
||||||
|
{
|
||||||
|
"programId": "%s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"encoding": "jsonParsed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(ownerAddress, splProgram.getAddress());
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(RPC_URL))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, SPLTokenHolding> result = new HashMap<>();
|
||||||
|
|
||||||
|
JsonNode accounts = root.path("result").path("value");
|
||||||
|
|
||||||
|
for (JsonNode account : accounts) {
|
||||||
|
String tokenAccount = account.path("pubkey").asText();
|
||||||
|
|
||||||
|
JsonNode parsedInfo = account
|
||||||
|
.path("account")
|
||||||
|
.path("data")
|
||||||
|
.path("parsed")
|
||||||
|
.path("info");
|
||||||
|
|
||||||
|
String mint = parsedInfo.path("mint").asText();
|
||||||
|
JsonNode tokenAmount = parsedInfo.path("tokenAmount");
|
||||||
|
String rawAmount = tokenAmount.path("amount").asText();
|
||||||
|
int decimals = tokenAmount.path("decimals").asInt();
|
||||||
|
String uiAmountString = tokenAmount.path("uiAmountString").asText();
|
||||||
|
BigDecimal uiAmount = new BigDecimal(uiAmountString);
|
||||||
|
|
||||||
|
result.put(mint, new SPLTokenHolding(
|
||||||
|
tokenAccount,
|
||||||
|
mint,
|
||||||
|
uiAmount,
|
||||||
|
rawAmount,
|
||||||
|
decimals,
|
||||||
|
splProgram.getAddress()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<ΩSolanaNFTAddressΩ> getSolanaNFTCandidateAddresses(
|
||||||
|
ΩSolanaAddressΩ ownerAddress,
|
||||||
|
SolanaSPLTokenProgram splProgram
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
Map<ΩSPLMintAddressΩ, SPLTokenHolding> tokenHoldings =
|
||||||
|
getSPLTokenHoldings(ownerAddress, splProgram);
|
||||||
|
|
||||||
|
Set<ΩSolanaNFTAddressΩ> nftCandidateAddresses = new HashSet<>();
|
||||||
|
|
||||||
|
for (SPLTokenHolding tokenHolding : tokenHoldings.values()) {
|
||||||
|
if (isSolanaNFTCandidate(tokenHolding)) {
|
||||||
|
nftCandidateAddresses.add(tokenHolding.mintAddress());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set.copyOf(nftCandidateAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SolanaProgramDerivedAddress findProgramAddress(
|
||||||
|
ΩSolanaProgramIdΩ programId,
|
||||||
|
List<SolanaProgramAddressSeed> seeds
|
||||||
|
) {
|
||||||
|
List<byte[]> seedBytes = toSeedBytes(seeds);
|
||||||
|
|
||||||
|
return findProgramAddressFromSeedBytes(programId, seedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SolanaAccountInfo getAccountInfo(
|
||||||
|
ΩSolanaAddressΩ accountAddress
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
String jsonBody = """
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "getAccountInfo",
|
||||||
|
"params": [
|
||||||
|
"%s",
|
||||||
|
{
|
||||||
|
"commitment": "finalized",
|
||||||
|
"encoding": "base64"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(accountAddress);
|
||||||
|
|
||||||
|
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 value = root.path("result").path("value");
|
||||||
|
|
||||||
|
if (value.isMissingNode() || value.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode ownerNode = value.path("owner");
|
||||||
|
if (ownerNode.isMissingNode() || !ownerNode.isTextual()) {
|
||||||
|
throw new IOException("getAccountInfo response did not contain textual owner field!");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode dataNode = value.path("data");
|
||||||
|
if (!dataNode.isArray() || dataNode.isEmpty() || !dataNode.get(0).isTextual()) {
|
||||||
|
throw new IOException("getAccountInfo response did not contain base64 data!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolanaAccountInfo(
|
||||||
|
accountAddress,
|
||||||
|
ownerNode.asText(),
|
||||||
|
dataNode.get(0).asText()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ΩSolanaAddressΩ encodeSolanaAddress(byte[] addressBytes) {
|
||||||
|
if (addressBytes.length != 32) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Solana address must be 32 bytes, but was " + addressBytes.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base58Encode(addressBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SPLTokenSupply getSPLTokenSupply(
|
||||||
|
ΩSPLMintAddressΩ mintAddress,
|
||||||
|
SolanaSPLTokenProgram splProgram
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
String jsonBody = """
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "getTokenSupply",
|
||||||
|
"params": [
|
||||||
|
"%s",
|
||||||
|
{
|
||||||
|
"commitment": "finalized"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(mintAddress);
|
||||||
|
|
||||||
|
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() + " " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
|
||||||
|
if (root.has("error")) {
|
||||||
|
throw new IOException("Solana RPC error: " + root.get("error").toPrettyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode value = root.path("result").path("value");
|
||||||
|
|
||||||
|
if (value.isMissingNode() || value.isNull()) {
|
||||||
|
throw new IOException("getTokenSupply response did not contain a value for mint: " + mintAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode rawAmountNode = value.path("amount");
|
||||||
|
if (rawAmountNode.isMissingNode() || !rawAmountNode.isTextual()) {
|
||||||
|
throw new IOException("getTokenSupply response did not contain textual amount field!");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode decimalsNode = value.path("decimals");
|
||||||
|
if (decimalsNode.isMissingNode() || !decimalsNode.isNumber()) {
|
||||||
|
throw new IOException("getTokenSupply response did not contain numeric decimals field!");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode uiAmountStringNode = value.path("uiAmountString");
|
||||||
|
if (uiAmountStringNode.isMissingNode() || !uiAmountStringNode.isTextual()) {
|
||||||
|
throw new IOException("getTokenSupply response did not contain textual uiAmountString field!");
|
||||||
|
}
|
||||||
|
|
||||||
|
ΩRawAmountΩ rawAmount = rawAmountNode.asText();
|
||||||
|
ΩamountDecimalsΩ decimals = decimalsNode.asInt();
|
||||||
|
ΩAmountΩ uiAmount = new BigDecimal(uiAmountStringNode.asText());
|
||||||
|
|
||||||
|
return new SPLTokenSupply(
|
||||||
|
mintAddress,
|
||||||
|
uiAmount,
|
||||||
|
rawAmount,
|
||||||
|
decimals,
|
||||||
|
splProgram.getAddress()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return httpClient.send(
|
||||||
|
request,
|
||||||
|
HttpResponse.BodyHandlers.ofString()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
previousRemoteCallTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void waitBeforeRemoteCall() throws InterruptedException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long elapsed = now - previousRemoteCallTime;
|
||||||
|
|
||||||
|
if (elapsed < MINIMUM_REMOTE_CALL_INTERVAL) {
|
||||||
|
ΩmilliSecondsΩ sleepTime = MINIMUM_REMOTE_CALL_INTERVAL - elapsed;
|
||||||
|
//System.out.println("Throttling Solana request for " + sleepTime + "ms...");
|
||||||
|
Thread.sleep(sleepTime);
|
||||||
|
//System.out.println("Ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSolanaNFTCandidate(SPLTokenHolding tokenHolding) {
|
||||||
|
// TODO This only checks the owner's token holding.
|
||||||
|
// 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.
|
||||||
|
return tokenHolding.decimals() == 0
|
||||||
|
&& "1".equals(tokenHolding.rawAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] createProgramAddressBytes(
|
||||||
|
ΩSolanaProgramIdΩ programId,
|
||||||
|
List<byte[]> seeds
|
||||||
|
) {
|
||||||
|
if (seeds.size() > 16) {
|
||||||
|
throw new IllegalArgumentException("A Solana program address can have at most 16 seeds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|
||||||
|
for (byte[] seed : seeds) {
|
||||||
|
if (seed.length > 32) {
|
||||||
|
throw new IllegalArgumentException("A Solana program address seed can be at most 32 bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
digest.update(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
digest.update(base58Decode(programId));
|
||||||
|
digest.update(PROGRAM_DERIVED_ADDRESS_MARKER);
|
||||||
|
|
||||||
|
return digest.digest();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 is not available.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isOnEd25519Curve(byte[] encodedPoint) {
|
||||||
|
if (encodedPoint.length != 32) {
|
||||||
|
throw new IllegalArgumentException("Ed25519 encoded point must be 32 bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] yBytes = encodedPoint.clone();
|
||||||
|
|
||||||
|
int signBit = (yBytes[31] & 0x80) >>> 7;
|
||||||
|
yBytes[31] &= 0x7F;
|
||||||
|
|
||||||
|
BigInteger y = littleEndianToBigInteger(yBytes);
|
||||||
|
|
||||||
|
if (y.compareTo(ED25519_P) >= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigInteger ySquared = y.multiply(y).mod(ED25519_P);
|
||||||
|
|
||||||
|
BigInteger numerator = ySquared
|
||||||
|
.subtract(BigInteger.ONE)
|
||||||
|
.mod(ED25519_P);
|
||||||
|
|
||||||
|
BigInteger denominator = ED25519_D
|
||||||
|
.multiply(ySquared)
|
||||||
|
.add(BigInteger.ONE)
|
||||||
|
.mod(ED25519_P);
|
||||||
|
|
||||||
|
if (denominator.equals(BigInteger.ZERO)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigInteger xSquared = numerator
|
||||||
|
.multiply(denominator.modInverse(ED25519_P))
|
||||||
|
.mod(ED25519_P);
|
||||||
|
|
||||||
|
if (xSquared.equals(BigInteger.ZERO)) {
|
||||||
|
return signBit == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isQuadraticResidue(xSquared);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isQuadraticResidue(BigInteger value) {
|
||||||
|
return value.modPow(
|
||||||
|
ED25519_P.subtract(BigInteger.ONE).shiftRight(1),
|
||||||
|
ED25519_P
|
||||||
|
).equals(BigInteger.ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger littleEndianToBigInteger(byte[] littleEndianBytes) {
|
||||||
|
byte[] bigEndianBytes = new byte[littleEndianBytes.length + 1];
|
||||||
|
|
||||||
|
for (int i = 0; i < littleEndianBytes.length; i++) {
|
||||||
|
bigEndianBytes[bigEndianBytes.length - 1 - i] = littleEndianBytes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BigInteger(bigEndianBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String base58Encode(byte[] inputBytes) {
|
||||||
|
if (inputBytes.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] input = Arrays.copyOf(inputBytes, inputBytes.length);
|
||||||
|
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < input.length && input[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
|
||||||
|
char[] encoded = new char[input.length * 2];
|
||||||
|
int outputStart = encoded.length;
|
||||||
|
|
||||||
|
int inputStart = zeros;
|
||||||
|
while (inputStart < input.length) {
|
||||||
|
int mod = divmod(input, inputStart, 256, 58);
|
||||||
|
|
||||||
|
if (input[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded[--outputStart] = BASE58_ALPHABET[mod];
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputStart < encoded.length && encoded[outputStart] == BASE58_ALPHABET[0]) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (zeros-- > 0) {
|
||||||
|
encoded[--outputStart] = BASE58_ALPHABET[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String(encoded, outputStart, encoded.length - outputStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] base58Decode(String input) {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] input58 = new byte[input.length()];
|
||||||
|
|
||||||
|
for (int i = 0; i < input.length(); i++) {
|
||||||
|
char c = input.charAt(i);
|
||||||
|
|
||||||
|
if (c >= 128 || BASE58_INDEXES[c] < 0) {
|
||||||
|
throw new IllegalArgumentException("Invalid Base58 character: " + c);
|
||||||
|
}
|
||||||
|
|
||||||
|
input58[i] = (byte) BASE58_INDEXES[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < input58.length && input58[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decoded = new byte[input.length()];
|
||||||
|
int outputStart = decoded.length;
|
||||||
|
|
||||||
|
int inputStart = zeros;
|
||||||
|
while (inputStart < input58.length) {
|
||||||
|
int mod = divmod(input58, inputStart, 58, 256);
|
||||||
|
|
||||||
|
if (input58[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded[--outputStart] = (byte) mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputStart < decoded.length && decoded[outputStart] == 0) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arrays.copyOfRange(decoded, outputStart - zeros, decoded.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int divmod(byte[] number, int firstDigit, int base, int divisor) {
|
||||||
|
int remainder = 0;
|
||||||
|
|
||||||
|
for (int i = firstDigit; i < number.length; i++) {
|
||||||
|
int digit = number[i] & 0xFF;
|
||||||
|
int temporary = remainder * base + digit;
|
||||||
|
|
||||||
|
number[i] = (byte) (temporary / divisor);
|
||||||
|
remainder = temporary % divisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<byte[]> toSeedBytes(List<SolanaProgramAddressSeed> seeds) {
|
||||||
|
List<byte[]> seedBytes = new ArrayList<>();
|
||||||
|
|
||||||
|
for (SolanaProgramAddressSeed seed : seeds) {
|
||||||
|
seedBytes.add(toSeedBytes(seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return seedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] toSeedBytes(SolanaProgramAddressSeed seed) {
|
||||||
|
return switch (seed.kind()) {
|
||||||
|
case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
case SOLANA_ADDRESS -> {
|
||||||
|
byte[] addressBytes = base58Decode(seed.value());
|
||||||
|
|
||||||
|
if (addressBytes.length != 32) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Solana address seed must decode to 32 bytes, but was " + addressBytes.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield addressBytes;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SolanaProgramDerivedAddress findProgramAddressFromSeedBytes(
|
||||||
|
ΩSolanaProgramIdΩ programId,
|
||||||
|
List<byte[]> seedBytes
|
||||||
|
) {
|
||||||
|
if (seedBytes.size() > 15) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"findProgramAddress(...) can have at most 15 caller-provided seeds because the bump is added as the final seed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int bump = 255; bump >= 0; bump--) {
|
||||||
|
List<byte[]> seedBytesWithBump = new ArrayList<>(seedBytes);
|
||||||
|
seedBytesWithBump.add(new byte[] { (byte) bump });
|
||||||
|
|
||||||
|
byte[] candidateAddress = createProgramAddressBytes(
|
||||||
|
programId,
|
||||||
|
seedBytesWithBump
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOnEd25519Curve(candidateAddress)) {
|
||||||
|
return new SolanaProgramDerivedAddress(
|
||||||
|
base58Encode(candidateAddress),
|
||||||
|
bump
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("Could not find valid program derived address.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] createBase58Indexes() {
|
||||||
|
int[] indexes = new int[128];
|
||||||
|
Arrays.fill(indexes, -1);
|
||||||
|
|
||||||
|
for (int i = 0; i < BASE58_ALPHABET.length; i++) {
|
||||||
|
indexes[BASE58_ALPHABET[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ΩAmountΩ LAMPORTS_PER_SOL = new BigDecimal("1000000000");
|
||||||
|
private static final ΩmilliSecondsΩ MINIMUM_REMOTE_CALL_INTERVAL = 5000L;
|
||||||
|
private static final byte[] PROGRAM_DERIVED_ADDRESS_MARKER = "ProgramDerivedAddress".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final String BASE58_ALPHABET_STRING = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
private static final char[] BASE58_ALPHABET = BASE58_ALPHABET_STRING.toCharArray();
|
||||||
|
private static final int[] BASE58_INDEXES = createBase58Indexes();
|
||||||
|
private static final BigInteger ED25519_P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19));
|
||||||
|
private static final BigInteger ED25519_D =
|
||||||
|
BigInteger.valueOf(-121665)
|
||||||
|
.multiply(BigInteger.valueOf(121666).modInverse(ED25519_P))
|
||||||
|
.mod(ED25519_P);
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private ΩmilliSecondsΩ previousRemoteCallTime = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.r35157.libs.solana.valuetypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Solana program derived address and its bump value.
|
||||||
|
*
|
||||||
|
* <p>A program derived address is a Solana address generated deterministically
|
||||||
|
* from a program id and a set of seeds. The bump value is the extra seed value
|
||||||
|
* used to find a valid program derived address for those inputs.</p>
|
||||||
|
*
|
||||||
|
* @param address the derived Solana address
|
||||||
|
* @param bump the bump value used when deriving the address
|
||||||
|
*/
|
||||||
|
public record SolanaProgramDerivedAddress(
|
||||||
|
ΩSolanaAddressΩ address,
|
||||||
|
int bump
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.r35157.libs.solana.valuetypes.economic;
|
||||||
|
|
||||||
|
import com.r35157.libs.valuetypes.basic.CurrencyType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an SPL token known to the Solana integration.
|
||||||
|
*
|
||||||
|
* <p>The currency type describes the economic identity of the token, while the
|
||||||
|
* mint address identifies the SPL token mint on Solana. The token address is the
|
||||||
|
* Solana address associated with the token in this model.</p>
|
||||||
|
*
|
||||||
|
* @param currencyType the currency type represented by this SPL token
|
||||||
|
* @param mintAddress the SPL mint address of the token
|
||||||
|
* @param tokenAddress the Solana address associated with the token
|
||||||
|
*/
|
||||||
|
public record SolanaSPLToken (
|
||||||
|
CurrencyType currencyType,
|
||||||
|
ΩSPLMintAddressΩ mintAddress,
|
||||||
|
ΩSolanaAddressΩ tokenAddress
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.r35157.libs.solana.valuetypes.economic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Solana SPL token program supported by this integration.
|
||||||
|
*
|
||||||
|
* <p>Solana has more than one token program. The original SPL Token Program is
|
||||||
|
* used by many existing tokens, while the Token-2022 Program supports newer token
|
||||||
|
* functionality and extensions.</p>
|
||||||
|
*
|
||||||
|
* <p>The program address is used when querying token accounts owned by a wallet,
|
||||||
|
* for example when discovering SPL token holdings or NFT-like token holdings
|
||||||
|
* under a specific token program.</p>
|
||||||
|
*/
|
||||||
|
public enum SolanaSPLTokenProgram {
|
||||||
|
/**
|
||||||
|
* The original Solana SPL Token Program.
|
||||||
|
*/
|
||||||
|
SPL_TOKEN_PROGRAM("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Solana Token-2022 Program.
|
||||||
|
*/
|
||||||
|
TOKEN_2022_PROGRAM("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Solana SPL token program entry.
|
||||||
|
*
|
||||||
|
* @param address the Solana program address for the token program
|
||||||
|
*/
|
||||||
|
SolanaSPLTokenProgram(ΩSPLProgramIdΩ address) {
|
||||||
|
this.address = address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Solana program address for this token program.
|
||||||
|
*
|
||||||
|
* @return the SPL token program address
|
||||||
|
*/
|
||||||
|
public ΩSPLProgramIdΩ getAddress() {
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ΩSPLProgramIdΩ address;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.r35157.libs.valuetypes.basic;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AssetPrice(
|
||||||
|
@NotNull ΩPriceΩ price,
|
||||||
|
@NotNull TradingPair tradingPair
|
||||||
|
) implements Comparable<AssetPrice> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NotNull AssetPrice other) {
|
||||||
|
if (!tradingPair.equals(other.tradingPair)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"AssetPrice values with different trading pairs cannot be compared: "
|
||||||
|
+ tradingPair + " and " + other.tradingPair
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return price.compareTo(other.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull String toString() {
|
||||||
|
return price + " " + tradingPair.quote();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.r35157.libs.valuetypes.basic;
|
||||||
|
|
||||||
|
public record Credentials(
|
||||||
|
ΩUserNameΩ userName,
|
||||||
|
ΩPasswordΩ password
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.r35157.libs.valuetypes.basic;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CurrencyType(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
String symbol
|
||||||
|
) {
|
||||||
|
@Override
|
||||||
|
public @NotNull String toString() {
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.r35157.libs.valuetypes.basic;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record MoneyAmount(
|
||||||
|
ΩAmountΩ amount,
|
||||||
|
CurrencyType currencyType
|
||||||
|
) {
|
||||||
|
@Override
|
||||||
|
public @NotNull String toString() {
|
||||||
|
return amount + " " + currencyType();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.r35157.libs.valuetypes.basic;
|
||||||
|
|
||||||
|
public record NetworkEndPoint(
|
||||||
|
ΩHostnameΩ hostName,
|
||||||
|
ΩportNumberΩ portNumber
|
||||||
|
) {
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user