130 Commits
Author SHA1 Message Date
minimons 12843e1501 47: Add explicit format versions to configuration files 2026-08-05 10:12:39 +02:00
minimons 4cc14ceef5 45: Add hysteresis support to CROSSING alarms 2026-08-05 10:12:39 +02:00
minimons bf492c9db1 44: Restrict CROSSING conditions to directional operators only 2026-08-05 10:12:39 +02:00
minimons f5c449fe23 46: Support human-readable time units for PERSISTENT alarm grace periods 2026-08-05 10:12:39 +02:00
minimons 5afac819d8 X: Fix compiler warnings 2026-08-05 10:12:39 +02:00
minimons 8f2e43dc8a X: Reenable compiler warnings 2026-08-05 10:12:39 +02:00
minimons f42ac30b3f X: publishGitHub.sh should support a default branch 2026-08-05 10:12:39 +02:00
minimons 451b5f252c X: Upgrade to gradle-9.6.1 2026-08-05 10:12:39 +02:00
minimons 0b6b0b6974 X: ConsoleAlarmAction should print alarm id too. 2026-08-05 10:12:39 +02:00
minimons d4ced47012 43: Prevent Position Decrease from reducing a position below a configured size reserve 2026-08-05 10:12:39 +02:00
minimons 3ba92bae4b 41: Skip Jupiter Perps position increase when USDC balance is insufficient 2026-08-05 10:12:39 +02:00
minimons 5d940d37cc 40: Add SPL token balance lookup to SolanaWallet 2026-08-05 10:12:39 +02:00
minimons d4e6d3438b 42: Fail on multiple SPL token accounts for the same mint 2026-08-05 10:12:39 +02:00
minimons bbba18dcae 39: Add configurable Jupiter Perps position decrease alarm action 2026-08-05 10:12:39 +02:00
minimons 9a770e1801 X: Add 'cleanupPrivateKeyFile.sh' 2026-08-05 10:12:39 +02:00
minimons 5b50e67d8e 38: Add support for building and executing Jupiter Perps position decrease transactions 2026-08-05 10:12:39 +02:00
minimons d2f28af1bc 37: Add re-armable CROSSING alarm trigger 2026-08-05 10:12:38 +02:00
minimons e59aff95b8 35: Separate alarm definitions from action configuration 2026-08-05 10:12:38 +02:00
minimons 7266a8c2dd 34: Combine alarm direction and target into one condition 2026-08-05 10:12:38 +02:00
minimons d33dff7091 33: Create a small dirty hacky functionality for position increase instead of Push Notification 2026-08-05 10:12:38 +02:00
minimons 6a352e90e9 31: Add a minimal Solana wallet API and external-signer implementation 2026-08-05 10:12:38 +02:00
minimons 2e61011a68 30: Submit signed transactions through the Solana API 2026-08-05 10:12:38 +02:00
minimons ba376b6688 29: Generate an unsigned transaction for increasing a Jupiter Perps position 2026-08-05 10:12:38 +02:00
minimons a0d0740f10 28: Add simple ObjectCache and cached SolanaBlockChain decorator 2026-08-05 10:12:38 +02:00
minimons 776f626abb 25: Add dynamic Jupiter Perps liquidation price variables to alarm config 2026-08-05 10:12:38 +02:00
minimons 228698e3e4 X: Small alarm configuration update 2026-08-05 10:12:38 +02:00
minimons 0166d6bcf7 X: Remove dependency to API package (temporarily) 2026-08-05 10:12:38 +02:00
minimons 87ef7c67ae X: Add 'jupiter-perps-alarm-var.refresh.bak' 2026-08-05 10:12:38 +02:00
minimons e0713c9121 X: More logging 2026-08-05 10:12:38 +02:00
minimons 5c9f6251e5 X: Remove debug mode 2026-08-05 10:12:38 +02:00
minimons f3e4947086 24: Calculate and expose liquidationPrice in JupiterPerpsPosition 2026-08-05 10:12:38 +02:00
minimons db20e68e93 23: Investigate and expose closeFeeUsd in JupiterPerpsPosition 2026-08-05 10:12:38 +02:00
minimons eda12a12fd X: NFC - Remove code duplication 2026-08-05 10:12:38 +02:00
minimons b2278b12d5 X: Test code update 2026-08-05 10:12:38 +02:00
minimons 27c161bb6b 22: Investigate and expose borrowFeeUsd in JupiterPerpsPosition 2026-08-05 10:12:38 +02:00
minimons f6afbd322b X: Remove duplicate class 2026-08-05 10:12:38 +02:00
minimons 3e82b49f0d X: A lot of cleanup and refacorings 2026-08-05 10:12:38 +02:00
minimons 397028fe37 X: Adding DecodingPrimitives helper class 2026-08-05 10:12:38 +02:00
minimons 2c57b4cdb5 X: Add new CustodyAccountInfo class 2026-08-05 10:12:38 +02:00
minimons 724b2d6eb6 X: Do not return old closed positions 2026-08-05 10:12:38 +02:00
minimons aded93839b X: Clean-up decoding 2026-08-05 10:12:38 +02:00
minimons 16fd99c80f X: Add new JupiterPerpsPositionInfo class 2026-08-05 10:12:38 +02:00
minimons e5b60f9db2 X: Add a lot of new fields to 'JupiterPerpsPostion' 2026-08-05 10:12:38 +02:00
minimons 5c8da8bf8a X: Move non-conflicting interface classes from API project 2026-08-05 10:12:38 +02:00
minimons ff96949fdc X: Update publishGitHub script 2026-08-05 10:12:38 +02:00
minimons d4cac8d0bd 21: Add sizeUsd to JupiterPerpsPosition 2026-08-05 10:12:38 +02:00
minimons 82331e068a 19: Decode 'collateralUsd' 2026-08-05 10:12:38 +02:00
minimons 8f86773786 20: Add dynamic Jupiter Perps entry price variables to alarm config 2026-08-05 10:12:38 +02:00
minimons 041909ae6a X: Add publishGitHub 2026-08-05 10:12:38 +02:00
minimons b21b0b081d X: Updated for some manual testing in Main 2026-08-05 10:12:38 +02:00
minimons 0997c0558e 17: Add mint address field in JupiterPerpsPosition 2026-08-05 10:12:38 +02:00
minimons eda11ed459 18: Add a codec class for Base58 2026-08-05 10:12:38 +02:00
minimons c05509bdbe X: Rename AnchorIdlJupiterPerpsPositionServiceImpl 2026-08-05 10:12:38 +02:00
minimons 32a2667e59 X: Rename JupiterPerpsPositionService 2026-08-05 10:12:38 +02:00
minimons b8da2fb0f5 X: Move JupiterPerpsPositionService to API instead of implementation 2026-08-05 10:12:38 +02:00
minimons 57875d1eeb 16: Add 'side' to AnchorIdlJupiterPerpsPositionDecoder 2026-08-05 10:12:38 +02:00
minimons 23b644171b X: Move JupiterPerpsPosition to API from implementation 2026-08-05 10:12:38 +02:00
minimons 265a30bfa3 X: Add some manuel testing code 2026-08-05 10:12:38 +02:00
minimons 9e41499501 15: Add getOpenPositions() to Jupiter Perps API 2026-08-05 10:12:38 +02:00
minimons c97469a9fe 14: Add getProgramAccounts(...) API method 2026-08-05 10:12:38 +02:00
minimons 5375309b08 X: Some dependency cleanup 2026-08-05 10:12:38 +02:00
minimons 5063a6e523 13: Create initial Jupiter Perps position API 2026-08-05 10:12:38 +02:00
minimons cdcbd331ec 12: Add grace period support for persistent alarms 2026-08-05 10:12:38 +02:00
minimons 7d96848464 11: Remove temporary SelfTest code 2026-08-05 10:12:38 +02:00
minimons 7075429efa 10: Support constants in alarm configuration 2026-08-05 10:12:38 +02:00
minimons 329e73f3f8 7: Make JupiterPerpsAlarm send the note on the push notification 2026-08-05 10:12:38 +02:00
minimons ee9e4d9194 9: Support percentage expressions in alarm targets 2026-08-05 10:12:38 +02:00
minimons b7490b8482 8: Add 'example' to configuration files 2026-08-05 10:12:38 +02:00
minimons 1546db773f 6: Extend the JupiterPerpsAlarm with an ID column 2026-08-05 10:12:38 +02:00
minimons ecb0b265b5 5: JupiterPerpsAlarm should be able to alarm on price 0 2026-08-05 10:12:38 +02:00
minimons d18190964c 4: Implement severity in JupiterPerpsAlarm 2026-08-05 10:12:38 +02:00
minimons 832785ed4d 3: Remove support for Docker 2026-08-05 10:12:38 +02:00
minimons f874820aa3 2: Enable support for autostart of JupiterPerpsAlarmImpl 2026-08-05 10:12:38 +02:00
minimons fb1327f62e X: Dependency update 2026-08-05 10:12:38 +02:00
minimons 2445fe2722 Lots of small updates 2026-08-05 10:12:38 +02:00
minimons 794904bcf1 Add 2 applications as auto starting 2026-06-19 09:52:24 +02:00
minimons c57417288a Add TickerService and API (dummy empty) 2026-06-19 09:52:24 +02:00
minimons 3a17604491 NenjimHub now autostarts Jupiter Alarm 2026-06-19 09:52:24 +02:00
minimons a63f8776f2 Enable logging 2026-06-19 09:52:24 +02:00
minimons e2fbc1a219 Security upgrade dependencies 2026-06-16 20:03:32 +02:00
minimons d98c5fff34 Added classes from old Cauldron (NON TESTED / BROKEN!) 2026-06-16 19:59:48 +02:00
minimons c0eca5a56d Added Nenjim Kicker (BROKEN!) 2026-06-16 13:23:02 +02:00
minimons 2ad0063c61 Added RandomValue generators 2026-06-16 12:56:31 +02:00
minimons b083b924b5 Added some Nenjim valuetypes (obsolete?) 2026-06-16 11:45:48 +02:00
minimons aa7b3cf9d4 Added sourcecode for LedSign 2026-06-16 11:06:53 +02:00
minimons ff5f1456f4 Added configuration files from Evelyn 2026-06-16 10:49:01 +02:00
minimons 4e1ba0edc1 Disable all old NenjimHub code 2026-06-16 10:09:07 +02:00
minimons 97f0fdaedb Add debug.sh script 2026-06-16 10:08:53 +02:00
minimons f634336290 Jupiter alarm extended with multi alarm and config file 2026-06-16 00:16:52 +02:00
minimons d1445f461e Add Jupiter alarm 2026-06-15 21:52:53 +02:00
minimons 6bf35de5c6 Finally compileable again 2026-06-15 21:07:13 +02:00
minimons b6fa7d5240 More fixes 2026-06-15 11:24:50 +02:00
minimons aff5fda1e1 Rename MoneyPrice to AssetPrice 2026-06-15 11:24:50 +02:00
minimons aba9344b7e Fix incorrect return type 2026-06-11 17:48:37 +02:00
minimons 034f1d969d ValueTag improvements 2026-06-11 17:14:06 +02:00
minimons fe265aa523 Add missing import 2026-06-11 17:14:06 +02:00
minimons 3f3eb82fe6 Fix bugs and generics in calculateLockedSums() 2026-06-11 17:14:06 +02:00
minimons 62e8563cbd Change API to use generics 2026-06-11 17:14:06 +02:00
minimons bb0a43a31f Use generics in calculateTotalDistributedSums() 2026-06-10 09:13:38 +02:00
minimons 3e047ccf2d Fix some bugs in calculateTotalDistributedSums() 2026-06-10 09:09:23 +02:00
minimons b51db22ad1 Move Pair og Triblet to new basic library 2026-06-10 08:57:47 +02:00
minimons bff1fd354a Add Solana implementation 2026-06-10 08:41:49 +02:00
minimons 115d9ca0ee Rename all java files to tjava 2026-06-10 08:24:41 +02:00
minimons 179840d0ab Added Evelyn to the hub temporarily 2026-06-10 08:08:21 +02:00
minimons c0cc65751b Move hubd API to Impl temporarily 2026-06-09 21:27:56 +02:00
minimons cead475afd Move notification implementations to Nenjim 2026-06-09 21:05:43 +02:00
minimons cf73e791db Make publish.sh call docker.sh first. 2026-06-09 20:15:15 +02:00
minimons 578e2cf31b Add Raydium implementation 2026-06-09 19:14:32 +02:00
minimons 35d587ef98 Migrate to buildx docker plugin 2026-06-09 14:55:10 +02:00
minimons b087052f59 Split build.sh to build and docker image creation 2026-06-09 14:46:58 +02:00
minimons 04b3fec8f2 Migrate from log4j to slf4j 2026-06-09 14:09:19 +02:00
minimons f9ed9ea00b Add support for tjava 2026-06-09 14:02:15 +02:00
minimons af473f2408 X - Fixes for API changes 2026-03-23 16:00:27 +01:00
minimons e63e20955a NoIssue: Upgrade build for gradle-9.3.1 2026-02-07 10:47:49 +01:00
minimons 677ff96616 NoIssue: Fix dash/underscore bug 2026-02-07 10:46:51 +01:00
minimons d29014d806 NoIssue: Add dummy implementation of NenjimHubImpl.monitorJournal() 2026-02-06 16:59:14 +01:00
minimons 6add00d399 NoIssue: Docker stack fix 2026-02-01 22:11:44 +01:00
minimons 8348094736 NoIssue: Permission fix 2026-02-01 21:50:12 +01:00
minimons fec65a0e36 NoIssue: Run fixes 2026-02-01 21:28:38 +01:00
minimons 89b6a4f28d NoIssue: Fix for build, run and publish 2026-02-01 21:15:21 +01:00
minimons 4e94320e81 NoIssue: A lot of cleanup and generalizations 2026-02-01 11:06:49 +01:00
minimons 2aee555d3c NoIssue: More minimalistic main 2026-01-28 12:09:52 +01:00
minimons c22dfb4e05 NoIssue: Add to gitignore 2026-01-27 10:47:04 +01:00
minimons b0f5430fe0 NoIssue: Upgrade build for gradle-9.3.0 2026-01-26 15:54:15 +01:00
minimons 8ab1947a08 NoIssue: Upgrade build for Java 25 2026-01-26 15:52:54 +01:00
minimons 8b242c2820 NoIssue: Dependency bumps 2026-01-24 19:00:13 +01:00
Minimons 938894ea26 NoIssue: Add noop() 2026-01-24 09:23:33 +01:00
Minimons 7d113b4522 NoIssue: Too verbose building 2026-01-23 15:11:12 +01:00
Minimons 642b531553 NoIssue: JRE update 2026-01-12 22:15:37 +01:00
minimons 88f147d46e NoIssue: Support for building runable docker container 2025-12-27 22:28:39 +01:00
283 changed files with 19052 additions and 31 deletions
+5
View File
@@ -3,3 +3,8 @@
# Ignore Gradle build output directory
build
logs/*.log
logs/*.log.gz
conf/*.conf
conf/*.xml
+33
View File
@@ -11,6 +11,39 @@ This module was initially created with the following command:
## Usage
Explain how to use this module
## Configuration format versions
Every application-owned, human-maintained runtime configuration file starts
with an explicit format version:
```text
FORMAT_VERSION=1
```
Blank lines and full-line comments may precede the declaration, but it must be
the first actual configuration entry. Loading fails before any other content
is parsed when the declaration is missing, duplicated, malformed, misplaced,
or different from the version supported by the corresponding loader.
Versions are independent for each configuration format. A breaking change to
one format, such as `alarms.conf`, does not change any other format's version.
When a breaking format change is made, update both the loader's hardcoded
`SUPPORTED_FORMAT_VERSION` and the declaration in the runtime and example
configuration files. Compatible additions do not require a version increment.
This policy currently applies to:
- `alarms.conf`
- `alarmaction_Console.conf`
- `alarmaction_Pushover.conf`
- `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf`
- `alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf`
- `evelyn.conf`
- `positions.conf`
Third-party configuration formats such as Log4j's XML configuration are
versioned by their owning library and do not use this declaration.
## Release log:
| Date | Version | Description |
|:-----------------|:--------------:|----------------------|
+439
View File
@@ -0,0 +1,439 @@
# Jupiter Perps Price Alarm
The Jupiter Perps Price Alarm monitors the Jupiter Perps aggregated oracle accounts for SOL, ETH, and BTC. It evaluates configurable price conditions and dispatches matching alarms to independently configured console, Pushover, and Jupiter position-increase or position-decrease actions.
Oracle prices are received through Solana WebSocket `accountSubscribe` using `processed` commitment. The application does not poll prices once per second. It also fetches the current oracle account state whenever a WebSocket connection opens, reconnects automatically, and supports multiple RPC endpoints for redundancy.
> **Important:** This application is not necessarily notification-only. Every active entry in a Jupiter Perps position action file can sign and submit a real transaction. Review both transaction action files before running it.
## Startup flow
The application performs the following steps before it starts monitoring prices:
1. Parse command-line options and the alarm configuration file.
2. Fetch the wallet's current open Jupiter Perps positions.
3. Populate entry-price and liquidation-price variables.
4. Resolve and validate every alarm condition.
5. Start the periodic variable refresher and file-trigger watcher.
6. Parse and validate the four action configuration files.
7. Open one oracle WebSocket connection per configured asset and RPC endpoint.
Startup fails before WebSockets are opened if an alarm condition or action configuration contains an unresolved variable or invalid value.
## Requirements and build
The project uses:
- JDK 25
- Gradle 9.3.1 through the Gradle wrapper
- Java preview features
- Detag-generated Java sources from `src/main/tjava`
Compile the project with:
```bash
./gradlew classes
```
To create the application JAR and collect runtime dependencies in `build/libs`:
```bash
./gradlew prepareLibs
```
There is currently no alarm-specific automated self-test task.
## Running the alarm application
The alarm entry point is:
```text
com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl
```
The Gradle application plugin currently points at the Nenjim Hub main class, so `./gradlew run` does **not** directly start the alarm application. Run `JupiterPerpsAlarmImpl` from the IDE, or run the prepared JAR and dependencies explicitly:
```bash
java --enable-preview \
-cp "build/libs/*" \
com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl \
--config=conf/alarms.conf
```
The repository contains example files for the alarm configuration and every action configuration. The real `conf/*.conf` files are ignored by Git; keep credentials and operational wallet details out of version control.
### Command-line options and environment variables
| Purpose | Command-line option | Environment variable | Default |
|---|---|---|---|
| Alarm configuration | `--config=<path>` | `PRICE_ALARMS_CONFIG` | `price-alarms.conf` |
| Solana WebSocket endpoints | `--ws=<url1,url2,...>` | `SOLANA_WS_URLS` | `wss://api.mainnet-beta.solana.com` |
The command-line option takes precedence over its environment variable.
The variable-refresh file watcher uses the configuration file's parent directory. Until that is changed, use a configuration path with an explicit directory component, such as `conf/alarms.conf` or `./price-alarms.conf`, rather than relying on the bare default filename.
## Configuration files
The application uses five configuration files:
| File | Purpose |
|---|---|
| `alarms.conf` | Variables and price-alarm definitions |
| `alarmaction_Console.conf` | Alarm IDs written to the console |
| `alarmaction_Pushover.conf` | Pushover credentials and notifications |
| `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf` | Jupiter wallet, signer, and position increases |
| `alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf` | Jupiter wallet, signer, and position decreases |
The four action files are resolved in the same directory as the file supplied through `--config`. All five files are required at startup, although an action can contain no alarm IDs. Blank lines and lines beginning with `#` are ignored.
Example files:
- [alarms.conf.example](conf/alarms.conf.example)
- [alarmaction_Console.conf.example](conf/alarmaction_Console.conf.example)
- [alarmaction_Pushover.conf.example](conf/alarmaction_Pushover.conf.example)
- [alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf.example](conf/alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf.example)
- [alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf.example](conf/alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf.example)
### Format version
Every configuration file must declare its format version as its first actual
configuration entry:
```text
FORMAT_VERSION=1
```
Blank lines and full-line comments may appear before it. The application
rejects a missing, duplicate, malformed, misplaced, older, or newer version
before parsing the remaining configuration.
Each file format has an independent version. Increment only the affected
format when making an incompatible change, and update both its parser constant
and its runtime and example files. See
[Configuration format versions](README.md#configuration-format-versions) for
the project-wide policy.
## Shared variables
### Variable definitions
Variable definitions live in `alarms.conf`. A definition consists of a name and a single-token value:
```text
{{JUPITER_PERPS_WALLET}} vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf
```
Variable names must use uppercase letters, digits, and underscores. User-defined variables can be referenced from alarm conditions and action configuration values as `{{NAME}}`.
`JUPITER_PERPS_WALLET` is required for the initial and periodic Jupiter position fetches. For open SOL, BTC, and ETH positions, the refresher supplies these dynamic variables:
```text
{{SOL_LONG_ENTRY_PRICE}} {{SOL_LONG_LIQ_PRICE}}
{{SOL_SHORT_ENTRY_PRICE}} {{SOL_SHORT_LIQ_PRICE}}
{{BTC_LONG_ENTRY_PRICE}} {{BTC_LONG_LIQ_PRICE}}
{{BTC_SHORT_ENTRY_PRICE}} {{BTC_SHORT_LIQ_PRICE}}
{{ETH_LONG_ENTRY_PRICE}} {{ETH_LONG_LIQ_PRICE}}
{{ETH_SHORT_ENTRY_PRICE}} {{ETH_SHORT_LIQ_PRICE}}
```
The refresher replaces user-supplied variables with these names when position data is fetched. Only variables for positions returned by Jupiter are populated. Startup validation fails if a condition or action value references a variable that is unavailable after the initial position fetch.
Alarm conditions, Pushover notes, and Jupiter position-increase rows are resolved again when an alarm triggers, so refreshed values take effect without restarting the application. Pushover credentials and severity plus the Jupiter wallet and signer are resolved during startup.
## Alarm definitions
Each alarm is one line with four columns:
```text
ID ASSET CONDITION TRIGGER
```
Example:
```text
4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
```
Supported column values:
- `ID`: integer alarm identifier. IDs should be unique. Action files use this ID to select alarms.
- `ASSET`: `SOL`, `ETH`, or `BTC`.
- `CONDITION`: a comparison or range expression. It must be one token with no whitespace.
- `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:<duration>`.
A trailing comment is allowed after a variable value or trigger.
An alarm can be listed in zero, one, or several action files. Absence means that action ignores the alarm; absence from all action files means that the condition is monitored but produces no action.
## Condition expressions
A condition is evaluated against the current oracle price in USD. Conditions are resolved again for every accepted price event, so periodically refreshed entry and liquidation prices take effect without restarting the application.
The complete condition must not contain whitespace.
### Target expressions
Each target can be:
```text
75.50
{{SOL_LONG_LIQ_PRICE}}
{{SOL_LONG_LIQ_PRICE}}+1%
{{SOL_SHORT_LIQ_PRICE}}-3%
```
`BASE+P%` adds `P` percent of `BASE`; `BASE-P%` subtracts it. The base, percentage, and resulting target must be zero or positive. General arithmetic expressions are not supported.
### Comparisons
| Syntax | Matches when |
|---|---|
| `<X` | `price < X` |
| `<=X` | `price <= X` |
| `>X` | `price > X` |
| `>=X` | `price >= X` |
Examples:
```text
<={{SOL_LONG_LIQ_PRICE}}+1%
>={{SOL_SHORT_LIQ_PRICE}}-1%
```
### Ranges
Ranges use `-->` between a lower and an upper target. Parentheses exclude a boundary; square brackets include it.
| Syntax | Matches when |
|---|---|
| `(A-->B)` | `A < price < B` |
| `[A-->B)` | `A <= price < B` |
| `(A-->B]` | `A < price <= B` |
| `[A-->B]` | `A <= price <= B` |
The lower resolved target must not exceed the upper resolved target.
Example of two adjacent, non-overlapping long-position conditions:
```text
<={{SOL_LONG_LIQ_PRICE}}+1%
({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%]
```
At exactly `LIQ+1%`, only the first condition matches. The corresponding short-position split is:
```text
>={{SOL_SHORT_LIQ_PRICE}}-1%
[{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%)
```
## Trigger behavior
On the first accepted oracle price after startup, an already-satisfied `ONETIME` or `PERSISTENT` alarm can trigger immediately. A `CROSSING` alarm only establishes its initial state.
### `ONETIME`
- Triggers on the first accepted price that satisfies the condition.
- Triggers at most once during the current process lifetime.
- Leaving and re-entering the condition does not re-arm it.
- Restarting the application re-arms it because alarm state is held only in memory.
### `CROSSING`
- Triggers when the condition changes from not satisfied to satisfied.
- The first accepted price establishes the initial state and never triggers the alarm.
- Remaining inside the condition does not trigger again.
- Without hysteresis, leaving the condition re-arms the alarm, so the next entry triggers again.
- An optional hysteresis can be appended to the condition as `/H` or `/P%`.
- With hysteresis, the alarm remains disarmed until the price reaches the re-arm boundary.
- A grace period is not supported; `CROSSING:1m` is rejected.
- Only the strict directional comparisons `<` and `>` are supported.
- `=`, `<=`, `>=`, and range conditions are rejected during configuration parsing.
The condition determines the crossing direction. Without hysteresis:
```text
19 SOL >{{SOL_LONG_ENTRY_PRICE}} CROSSING
20 SOL <{{SOL_LONG_ENTRY_PRICE}} CROSSING
```
Alarm 19 triggers when the price moves from at or below the entry price to above it. Alarm 20 triggers when the price moves from at or above the entry price to below it. Landing exactly on the entry price is not a crossing.
Hysteresis prevents repeated triggers when the price fluctuates around the target:
```text
21 SOL >{{SOL_LONG_ENTRY_PRICE}}/0.25 CROSSING
22 SOL <{{SOL_LONG_ENTRY_PRICE}}/0.25% CROSSING
```
Alarm 21 triggers above the entry price and re-arms only at or below `entry price - 0.25`. Alarm 22 triggers below the entry price and re-arms only at or above `entry price + 0.25%`.
Percentage hysteresis is calculated from the fully resolved comparison target. Hysteresis is an unsigned distance, so a leading `+` or `-` is rejected. Hysteresis is supported only for `CROSSING` alarms.
### `PERSISTENT`
- Triggers immediately on the first accepted matching price.
- With no grace period, it can trigger for every accepted matching price event.
- `PERSISTENT:<duration>` limits repeated triggering to at most once per configured interval while the condition remains satisfied.
- Leaving and re-entering a condition does not bypass the grace period measured from the previous trigger.
A duration is a non-negative whole number followed by exactly one suffix:
| Suffix | Unit |
|---|---|
| `ms` | millisecond |
| `s` | second |
| `m` | minute |
| `h` | hour |
| `d` | day |
| `w` | week |
| `M` | month (30 days) |
| `y` | year (365 days) |
Suffixes are case-sensitive, so `m` means minute and `M` means month. Combined durations such as `1w3d` are not supported.
Duplicate price events received from redundant RPC endpoints are suppressed using the raw price, exponent, and oracle timestamp. Up to 512 recent event keys are retained per asset monitor.
## Dynamic price-variable refresh
Entry-price and liquidation-price variables are refreshed:
- once during startup, before condition validation;
- every 60 seconds in a daemon task;
- when `jupiter-perps-alarm-var.refresh` is created in the configuration directory.
For example:
```bash
touch conf/jupiter-perps-alarm-var.refresh
```
The watcher detects creation of the file, refreshes the variables, and then deletes the trigger file.
When a dynamic price variable changes, the application logs its old and new value.
## Alarm actions
Actions are executed sequentially through `CompositeAlarmAction`. A runtime failure in one action is logged so the remaining actions can still run.
### Console
`alarmaction_Console.conf` contains one alarm ID per line:
```text
FORMAT_VERSION=1
1
4
5
```
Only listed alarms are written to the console. The output includes the asset, current oracle price, resolved condition, trigger type, oracle time, slot, and source endpoint. Duplicate or non-integer IDs are rejected. An empty file disables console output for all alarms.
### Pushover
`alarmaction_Pushover.conf` contains the credentials followed by zero or more notification definitions:
```text
FORMAT_VERSION=1
APPLICATION_TOKEN <pushover-application-token>
USER_KEY <pushover-user-key>
# ID SEVERITY "NOTE"
4 CRITICAL "🚨 SOL Long add 1.1x"
5 INFO "🌱 SOL Long add 3x"
```
The application token and user/group key are required. Only listed alarm IDs send a Pushover notification. Notes must be quoted and support `\n`, `\r`, `\t`, `\"`, and `\\` escapes.
| Severity | Pushover parameters |
|---|---|
| `EMERGENCY` | `priority=2`, `retry=30`, `expire=10800`, `sound=persistent` |
| `CRITICAL` | `priority=1`, `sound=spacealarm` |
| `WARN` | `priority=0`, `sound=siren` |
| `INFO` | `priority=0` |
| `SILENT` | `priority=-1` |
| `GHOST` | `priority=-2` |
Credentials, severity, and notes may reference shared variables. Credentials and severity are resolved during startup; notes are also resolved for every trigger so refreshed variables take effect. Pushover requests are sent asynchronously with a 15-second request timeout. Rejected requests and asynchronous failures are logged.
### Automatic position increase
`alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf` contains the wallet and signer followed by zero or more position increases:
```text
FORMAT_VERSION=1
WALLET_ID {{JUPITER_PERPS_WALLET}}
SIGNER_KEY_NAME <jup-key-name>
# ID ASSET DIRECTION COLLATERAL_USDC SIZE_DELTA_USD MAX_SLIPPAGE_BPS
4 SOL LONG 0.25 2.50 200
```
Each active row can submit a real transaction when its alarm triggers. Supported values are:
- `ASSET`: `SOL`, `ETH`, or `BTC`.
- `DIRECTION`: `LONG` or `SHORT`.
- `COLLATERAL_USDC`: greater than zero.
- `SIZE_DELTA_USD`: zero or greater.
- `MAX_SLIPPAGE_BPS`: from `0` to `10000`.
Wallet, signer, asset, direction, amounts, and slippage may reference shared variables. Wallet and signer are resolved during startup. Every transaction row is validated during startup and resolved again at trigger time, allowing refreshed variables to change the transaction parameters without a restart.
For a configured ID, the action builds a Jupiter position-increase transaction, signs it by running `jup sign -f json --key <SIGNER_KEY_NAME> --tx <serialized-transaction>`, verifies that the returned signer matches `WALLET_ID`, and submits the signed transaction. IDs absent from this file never perform a transaction.
The example transaction rows are commented out. Do not activate a row until its alarm condition, wallet, signer, asset, direction, amounts, and slippage have been reviewed.
## RPC redundancy and reconnect behavior
Provide multiple comma-separated WebSocket endpoints for redundancy:
```bash
export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example'
```
The application creates one WebSocket connection for every configured asset/endpoint combination. With three configured assets and two endpoints, it creates six connections.
Each connection:
- subscribes with `processed` commitment and Base64 account encoding;
- fetches the current account state after opening;
- sends a heartbeat every 20 seconds;
- reconnects with exponential delays from 1 to 30 seconds.
## Validation and errors
Structural configuration errors include the file path and line number. After the initial Jupiter position fetch, every condition is resolved and parsed. The action files are then parsed and validated before WebSockets are opened.
Startup validation rejects, among other things:
- unknown or unavailable variables;
- missing or unknown comparison operators;
- conditions split by whitespace;
- malformed ranges or multiple `-->` separators;
- missing range targets;
- negative target values or percentages;
- ranges whose lower target exceeds the upper target;
- missing action credentials or wallet settings;
- duplicate action IDs;
- invalid Pushover severities;
- invalid Jupiter assets, directions, amounts, or slippage.
At runtime, a later condition resolution/parsing failure skips that price event for the affected alarm. A later action-variable failure is logged by the action dispatcher, and the remaining actions are still attempted.
## Current limitations
- `processed` commitment minimizes delay but an observed update may belong to a fork that is later abandoned.
- Solana PubSub is not a durable event log and does not guarantee delivery. Multiple independent endpoints reduce, but do not eliminate, this risk.
- Alarm state and grace-period timestamps are kept only in memory and reset on restart.
- Dynamic variables exist only for currently returned open positions.
- Target expressions support only a decimal value with an optional single percentage adjustment; they are not a general expression language.
- Configuration files are read only during startup; editing an action file requires a restart.
- Action IDs are not cross-validated against `alarms.conf`; an unknown ID is accepted but never triggered.
- Pushover credentials and severity plus the Jupiter wallet and signer are resolved only during startup. Dynamic re-resolution applies to conditions, Pushover notes, and Jupiter transaction rows.
- A bare default configuration path has no parent directory for the refresh watcher; use an explicit path such as `./price-alarms.conf` or `conf/alarms.conf`.
- This application is a monitoring and automation aid, not a substitute for independent risk controls.
+103 -6
View File
@@ -1,5 +1,6 @@
plugins {
id("java")
id("application")
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)")
}
application {
mainClass.set("com.r35157.nenjim.hubd.impl.ref.Main")
applicationDefaultJvmArgs = listOf("--enable-preview")
}
repositories {
mavenLocal()
@@ -27,18 +33,109 @@ repositories {
mavenCentral()
}
val detag = configurations.create("detag") {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies {
implementation("org.jetbrains:annotations:26.0.1")
implementation("log4j:log4j:1.2.17")
implementation("com.r35157.nenjim:hubd-api:0.0.0")
detag("com.r35157.tools:detag-impl_ref:0.1.0")
compileOnly("org.jetbrains:annotations:26.1.0")
runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0")
runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0")
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 {
toolchain { languageVersion.set(JavaLanguageVersion.of(24)) }
toolchain { languageVersion.set(JavaLanguageVersion.of(25)) }
}
tasks.withType<JavaCompile> {
options.release.set(24)
tasks.withType<JavaCompile>().configureEach {
options.release.set(25)
options.compilerArgs.addAll(
listOf(
"--enable-preview",
"-Xlint:deprecation",
"-Xlint:unchecked"
)
)
}
val generatedDetagMain = layout.buildDirectory.dir("generated/sources/detag/main/java")
val cleanGeneratedDetagMain = tasks.register<Delete>("cleanGeneratedDetagMain") {
delete(generatedDetagMain)
}
val detagMain = tasks.register<JavaExec>("detagMain") {
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 {
+13 -2
View File
@@ -1,6 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|| git symbolic-ref --short -q HEAD \
|| git rev-parse --short HEAD)
echo "Building $VERSION..."
./gradlew -Pversion=$VERSION jar
GITHASH=$(git rev-parse --short=8 HEAD)
export VERSION_LONG=${VERSION}_${GITHASH}
echo "Building 'NenjimHub v${VERSION_LONG}'..."
./gradlew \
-Pversion=${VERSION} \
jar \
prepareLibs
+3
View File
@@ -1 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
./gradlew clean ; rm -rf build
+20
View File
@@ -0,0 +1,20 @@
python3 -c '
import json
import sys
source, destination = sys.argv[1], sys.argv[2]
with open(source) as f:
key = json.load(f)
if len(key) not in (32, 64):
raise SystemExit(f"Unexpected key length: {len(key)}")
if not all(isinstance(value, int) and 0 <= value <= 255 for value in key):
raise SystemExit("The file contains invalid byte values")
with open(destination, "w") as f:
f.write(json.dumps(key, separators=(",", ":")))
print(f"Created {destination} with {len(key)} bytes")
' $1 $2
+22
View File
@@ -0,0 +1,22 @@
FORMAT_VERSION=1
# Alarm ids handled by ConsoleAlarmAction
##########################################
#1
#2
#3
#4
#5
#6
#7
#8
#9
#10
#11
#12
#13
#14
#15
#16
#17
#18
@@ -0,0 +1,14 @@
FORMAT_VERSION=1
# Wallet and signer used for position-decrease transactions
################################################################
WALLET_ID {{JUPITER_PERPS_WALLET}}
SIGNER_KEY_NAME <SIGNER>
RESERVE_POSITION_SIZE_USD_LIMIT 10
# Id Asset Direction ReceiveToken SizeDeltaUSD MaxSlippageBPS
########################################################################
# Values may reference {{VARIABLES}} defined in alarms.conf.
# ReceiveToken must be USDC or match Asset.
#3 SOL SHORT USDC 5.00 200
#6 SOL LONG USDC 5.00 200
@@ -0,0 +1,13 @@
FORMAT_VERSION=1
# Wallet and signer used for position-increase transactions
################################################################
WALLET_ID {{JUPITER_PERPS_WALLET}}
SIGNER_KEY_NAME <SIGNER>
RESERVE_USDC_LIMIT 10
# Id Asset Direction CollateralUSDC SizeDeltaUSD MaxSlippageBPS
############################################################################
# Values may reference {{VARIABLES}} defined in alarms.conf.
#4 SOL LONG 0.25 2.50 200
#5 SOL LONG 0.25 6.25 200
+41
View File
@@ -0,0 +1,41 @@
FORMAT_VERSION=1
# Pushover credentials
##########################################
# Values and notes may reference {{VARIABLES}} defined in alarms.conf.
APPLICATION_TOKEN <pushover-application-token>
USER_KEY <pushover-user-key>
# Id Severity Note
##########################################
# SOL SHORT
#1 CRITICAL "🚨 SOL Short add 1.1x"
#2 INFO "🌱 SOL Short add 3x"
#3 INFO "💵 SOL Short close"
# SOL LONG
#4 CRITICAL "🚨 SOL Long add 1.1x"
#5 INFO "🌱 SOL Long add 3x"
#6 INFO "💵 SOL Long close"
# ETH SHORT
#7 CRITICAL "🚨 ETH Short add 1.1x"
#8 INFO "🌱 ETH Short add 3x"
#9 INFO "💵 ETH Short close"
# ETH LONG
#10 CRITICAL "🚨 ETH Long add 1.1x"
#11 INFO "🌱 ETH Long add 3x"
#12 INFO "💵 ETH Long close"
# BTC SHORT
#13 CRITICAL "🚨 BTC Short add 1.1x"
#14 INFO "🌱 BTC Short add 3x"
#15 INFO "💵 BTC Short close"
# BTC LONG
#16 CRITICAL "🚨 BTC Long add 1.1x"
#17 INFO "🌱 BTC Long add 3x"
#18 INFO "💵 BTC Long close"
+56
View File
@@ -0,0 +1,56 @@
FORMAT_VERSION=1
# General configurations
#####################################################
{{JUPITER_PERPS_WALLET}} vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf
# Constant Name Value
#####################################
#{{SOL_LONG_LIQ_PRICE}} 74.69
#{{SOL_SHORT_LIQ_PRICE}} 76.77
#{{ETH_LONG_LIQ_PRICE}} 1736.32
#{{ETH_SHORT_LIQ_PRICE}} 1768.08
#{{BTC_LONG_LIQ_PRICE}} 62158.45
#{{BTC_SHORT_LIQ_PRICE}} 63275.97
# Id Asset Condition Trigger
################################################################################################
# SOL SHORT
#1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
#2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
#3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# SOL LONG
#4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
#5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
#6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
# ETH SHORT
#7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
#8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
#9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# ETH LONG
#10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
#11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
#12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
# BTC SHORT
#13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
#14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
#15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# BTC LONG
#16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
#17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
#18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
# CROSSING with hysteresis
# Triggers above the entry price and re-arms at or below entry price minus 0.25%
#19 SOL >{{SOL_LONG_ENTRY_PRICE}}/0.25% CROSSING
+20
View File
@@ -0,0 +1,20 @@
FORMAT_VERSION=1
# 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
+23
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
FORMAT_VERSION=1
# 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
Executable
+24
View File
@@ -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
View File
@@ -1,5 +1,5 @@
org.gradle.java.installations.auto-detect=true
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.configuration-cache=true
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Symlink
+1
View File
@@ -0,0 +1 @@
build/libs
View File
+4 -1
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
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 $VERSION..."
echo "Publishing artifact to local Maven repo ($VERSION)..."
./gradlew -Pversion=$VERSION publishToMavenLocal
-6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
publish.sh
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
DEFAULT_BRANCH=0.1-dev
if [ "$#" -gt 1 ]; then
echo "Usage: $0 [github-snapshot-branch]" >&2
exit 1
fi
BRANCH="${1:-$DEFAULT_BRANCH}"
SOURCE="$HOME/projects/com_r35157_nenjim-hubd-impl_ref"
TARGET="$HOME/projects/com_r35157_nenjim-hubd-impl_ref_github_snapshot"
cd "$TARGET"
git fetch origin
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git switch "$BRANCH"
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
git switch --track "origin/$BRANCH"
else
git switch --create "$BRANCH"
fi
rsync -a --delete \
--exclude '.git' \
--exclude 'conf/*.conf' \
--exclude 'conf/*.xml' \
"$SOURCE/" \
"$TARGET/"
git add -A
if git diff --cached --quiet; then
echo "No snapshot changes to publish on branch '$BRANCH'."
exit 0
fi
git commit -m "Mirror snapshot"
git push -u origin "$BRANCH"
Executable
+18
View File
@@ -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
View File
@@ -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,322 @@
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.configuration.ConfigurationFormatVersionValidator;
import com.r35157.libs.raydium.*;
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
import com.r35157.libs.valuetypes.basic.*;
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.Iterator;
import java.util.List;
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 {
List<String> lines = Files.readAllLines(
PATH_EVELYN_STATE,
StandardCharsets.UTF_8
);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
PATH_EVELYN_STATE,
lines,
SUPPORTED_EVELYN_FORMAT_VERSION
);
Iterator<String> lineIterator = lines.subList(
formatVersionLineIndex + 1,
lines.size()
).iterator();
iterationInterval = readIterationInterval(lineIterator);
syrupOwnedByEvelyn = readSyrupOwnedByEvelyn(lineIterator);
solanaAddressForEvelynIOU = readSolanaAddress(lineIterator);
solanaAddressForEvelynIOUBurner = readSolanaAddress(lineIterator);
raydiumPoolId = readRaydiumPoolId(lineIterator);
curveWidth = readCurveWidth(lineIterator);
}
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<>();
List<String> lines = Files.readAllLines(PATH_POSITIONS, StandardCharsets.UTF_8);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
PATH_POSITIONS,
lines,
SUPPORTED_POSITIONS_FORMAT_VERSION
);
for (int lineIndex = formatVersionLineIndex + 1;
lineIndex < lines.size();
lineIndex++) {
String line = lines.get(lineIndex).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(Iterator<String> lineIterator) throws IOException {
ΩSolanaAddressΩ sa = readLineFromFile(lineIterator);
return sa;
}
private ΩRaydiumLiquidityPoolIdΩ readRaydiumPoolId(Iterator<String> lineIterator) throws IOException {
ΩRaydiumLiquidityPoolIdΩ rlpid = readLineFromFile(lineIterator);
return rlpid;
}
private ΩCurveWidthΩ readCurveWidth(Iterator<String> lineIterator) throws IOException {
String cwStr = readLineFromFile(lineIterator);
ΩCurveWidthΩ cw = new ΩCurveWidthΩ(cwStr);
return cw;
}
private ΩmilliSecondsΩ readIterationInterval(Iterator<String> lineIterator) throws IOException {
String iterationIntervalStr = readLineFromFile(lineIterator);
ΩmilliSecondsΩ millis = Long.parseLong(iterationIntervalStr) * 1000;
return millis;
}
private ΩSyrupAmountΩ readSyrupOwnedByEvelyn(Iterator<String> lineIterator) throws IOException {
String syrupOwnedByEvelynStr = readLineFromFile(lineIterator);
CurrencyType ct = WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType();
ΩSyrupAmountΩ ma = stringToMoneyAmount(syrupOwnedByEvelynStr, ct);
return ma;
}
private static String readLineFromFile(Iterator<String> lineIterator) {
while (lineIterator.hasNext()) {
String line = lineIterator.next();
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
return line;
}
return null;
}
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 static final int SUPPORTED_EVELYN_FORMAT_VERSION = 1;
private static final int SUPPORTED_POSITIONS_FORMAT_VERSION = 1;
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");
}
@@ -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
) {
}
@@ -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,73 @@
package com.r35157.cryptowallet.solana;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
/**
* A Solana wallet capable of identifying itself and signing transactions.
*
* <p>Signing a transaction does not submit it to the Solana blockchain.
* Submission must be performed separately through {@code SolanaBlockChain}.</p>
*/
public interface SolanaWallet {
/**
* Returns the public Solana address controlled by this wallet.
*
* @return the wallet's public address
*/
@NotNull
ΩSolanaWalletIdΩ getAddress();
/**
* Returns the native SOL balance of this wallet.
*
* @return the wallet's native SOL balance
* @throws IOException if the balance cannot be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted
*/
@NotNull
ΩSolanaAmountΩ getSolanaBalance()
throws IOException, InterruptedException;
/**
* Returns the balance of a specific SPL token owned by this wallet.
*
* @param mintAddress the SPL token mint address
* @param splProgram the SPL token program containing the token
* @return the token balance, or {@code null} if no token account exists
* @throws IOException if the balance cannot be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted
* @throws IllegalStateException if multiple accounts exist for the mint
*/
@Nullable
ΩAmountΩ getSPLTokenBalance(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Signs an unsigned Solana transaction.
*
* <p>The configured wallet must be a required signer for the supplied
* transaction. This method does not submit the resulting transaction
* to the Solana blockchain.</p>
*
* @param transaction the unsigned transaction to sign
* @return the transaction containing the wallet's required signature
* @throws IllegalArgumentException if the transaction is missing or blank
* @throws IOException if the transaction cannot be signed or the signer
* returns an invalid response
* @throws InterruptedException if the signing process is interrupted
*/
@NotNull
SolanaSignedTransaction signTransaction(
@NotNull SolanaUnsignedTransaction transaction
) throws IOException, InterruptedException;
}
@@ -0,0 +1,151 @@
package com.r35157.cryptowallet.solana.impl.ref;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.solana.SPLTokenHolding;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
public class SolanaWalletImpl implements SolanaWallet {
public SolanaWalletImpl(
ΩSolanaWalletIdΩ address,
String signerKeyName,
SolanaBlockChain solanaBlockChain
) {
this.address = address;
this.signerKeyName = signerKeyName;
this.solanaBlockChain = solanaBlockChain;
}
@Override
public @NotNull ΩSolanaWalletIdΩ getAddress() {
return address;
}
@Override
public @NotNull ΩSolanaAmountΩ getSolanaBalance()
throws IOException, InterruptedException {
return solanaBlockChain.getBalanceInSolana(address);
}
@Override
public @Nullable ΩAmountΩ getSPLTokenBalance(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException {
SPLTokenHolding tokenHolding = solanaBlockChain
.getSPLTokenHoldings(address, splProgram)
.get(mintAddress);
return tokenHolding == null
? null
: tokenHolding.uiAmount();
}
@Override
public @NotNull SolanaSignedTransaction signTransaction(
@NotNull SolanaUnsignedTransaction transaction
) throws IOException, InterruptedException {
if (transaction.serializedTransaction() == null
|| transaction.serializedTransaction().isBlank()) {
throw new IllegalArgumentException(
"Unsigned serialized transaction must not be blank"
);
}
Process process = new ProcessBuilder(
"jup",
"sign",
"-f",
"json",
"--key",
signerKeyName,
"--tx",
transaction.serializedTransaction()
).start();
String standardOutput = new String(
process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8
);
String standardError = new String(
process.getErrorStream().readAllBytes(),
StandardCharsets.UTF_8
);
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IOException(
"Solana transaction signing failed with exit code "
+ exitCode
+ ": "
+ standardError
);
}
SignerResponse response;
try {
response = OBJECT_MAPPER.readValue(
standardOutput,
SignerResponse.class
);
} catch (JsonProcessingException e) {
throw new IOException(
"Unable to decode signer response: " + standardOutput,
e
);
}
if (response.signer() == null || response.signer().isBlank()) {
throw new IOException(
"Signer response did not contain a signer address"
);
}
if (!address.equals(response.signer())) {
throw new IOException(
"Unexpected signer address. Expected "
+ address
+ " but received "
+ response.signer()
);
}
if (response.signedTransaction() == null
|| response.signedTransaction().isBlank()) {
throw new IOException(
"Signer response did not contain a signed transaction"
);
}
ΩBase64StringΩ signedTransaction = response.signedTransaction();
SolanaSignedTransaction sst = new SolanaSignedTransaction(signedTransaction);
return sst;
}
private record SignerResponse(
String signer,
ΩBase64StringΩ signedTransaction
) {
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ΩSolanaWalletIdΩ address;
private final String signerKeyName;
private final SolanaBlockChain solanaBlockChain;
}
@@ -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, ResolvedPriceAlarm alarm);
}
@@ -0,0 +1,169 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public final class AlarmConditionParser {
private static final String RANGE_SEPARATOR = "-->";
private AlarmConditionParser() {
}
public static PriceCondition parse(String conditionExpression) {
String trimmedExpression = conditionExpression.trim();
if (trimmedExpression.startsWith("[") || trimmedExpression.startsWith("(")) {
return parseRange(trimmedExpression);
}
PriceComparisonOperator operator;
int operatorLength;
if (trimmedExpression.startsWith("<=")) {
operator = PriceComparisonOperator.LESS_THAN_OR_EQUAL;
operatorLength = 2;
} else if (trimmedExpression.startsWith(">=")) {
operator = PriceComparisonOperator.GREATER_THAN_OR_EQUAL;
operatorLength = 2;
} else if (trimmedExpression.startsWith("<")) {
operator = PriceComparisonOperator.LESS_THAN;
operatorLength = 1;
} else if (trimmedExpression.startsWith(">")) {
operator = PriceComparisonOperator.GREATER_THAN;
operatorLength = 1;
} else {
throw new IllegalArgumentException(
"Unknown price condition operator: " + conditionExpression
);
}
String targetExpression = trimmedExpression.substring(operatorLength).trim();
if (targetExpression.isEmpty()) {
throw new IllegalArgumentException(
"Missing target in price condition: " + conditionExpression
);
}
BigDecimal target = AlarmTargetParser.parse(targetExpression);
return new ComparisonPriceCondition(operator, target);
}
public static CrossingPriceCondition parseCrossing(String conditionExpression) {
String trimmedExpression = conditionExpression.trim();
int hysteresisSeparatorIndex = trimmedExpression.indexOf('/');
if (hysteresisSeparatorIndex != trimmedExpression.lastIndexOf('/')) {
throw new IllegalArgumentException(
"CROSSING condition must contain at most one '/': "
+ conditionExpression
);
}
String comparisonExpression = hysteresisSeparatorIndex < 0
? trimmedExpression
: trimmedExpression.substring(0, hysteresisSeparatorIndex);
PriceCondition parsedCondition = parse(comparisonExpression);
if (!(parsedCondition instanceof ComparisonPriceCondition comparison)
|| (comparison.operator() != PriceComparisonOperator.LESS_THAN
&& comparison.operator() != PriceComparisonOperator.GREATER_THAN)) {
throw new IllegalArgumentException(
"CROSSING requires a '<' or '>' condition: " + conditionExpression
);
}
BigDecimal hysteresis = hysteresisSeparatorIndex < 0
? BigDecimal.ZERO
: parseHysteresis(
trimmedExpression.substring(hysteresisSeparatorIndex + 1),
comparison.target()
);
return new CrossingPriceCondition(comparison, hysteresis);
}
private static BigDecimal parseHysteresis(
String hysteresisExpression,
BigDecimal target
) {
String trimmedExpression = hysteresisExpression.trim();
if (trimmedExpression.isEmpty()) {
throw new IllegalArgumentException("Missing CROSSING hysteresis");
}
if (trimmedExpression.contains("+") || trimmedExpression.contains("-")) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be specified without '+' or '-': "
+ hysteresisExpression
);
}
boolean percentage = trimmedExpression.endsWith("%");
String distanceExpression = percentage
? trimmedExpression.substring(0, trimmedExpression.length() - 1)
: trimmedExpression;
BigDecimal distance;
try {
distance = new BigDecimal(distanceExpression);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Invalid CROSSING hysteresis: " + hysteresisExpression,
exception
);
}
if (distance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be zero or positive: "
+ hysteresisExpression
);
}
return percentage
? target.multiply(distance).movePointLeft(2)
: distance;
}
private static PriceCondition parseRange(String rangeExpression) {
char lowerDelimiter = rangeExpression.charAt(0);
char upperDelimiter = rangeExpression.charAt(rangeExpression.length() - 1);
if (upperDelimiter != ')' && upperDelimiter != ']') {
throw new IllegalArgumentException(
"Range must end with ')' or ']': " + rangeExpression
);
}
String rangeBody = rangeExpression.substring(1, rangeExpression.length() - 1);
int separatorIndex = rangeBody.indexOf(RANGE_SEPARATOR);
if (separatorIndex < 0 || separatorIndex != rangeBody.lastIndexOf(RANGE_SEPARATOR)) {
throw new IllegalArgumentException(
"Range must contain exactly one '" + RANGE_SEPARATOR + "': " + rangeExpression
);
}
String lowerTargetExpression = rangeBody.substring(0, separatorIndex).trim();
String upperTargetExpression = rangeBody
.substring(separatorIndex + RANGE_SEPARATOR.length())
.trim();
if (lowerTargetExpression.isEmpty() || upperTargetExpression.isEmpty()) {
throw new IllegalArgumentException(
"Range must contain both a lower and an upper target: " + rangeExpression
);
}
BigDecimal lowerTarget = AlarmTargetParser.parse(lowerTargetExpression);
BigDecimal upperTarget = AlarmTargetParser.parse(upperTargetExpression);
return new RangePriceCondition(
lowerTarget,
lowerDelimiter == '[',
upperTarget,
upperDelimiter == ']'
);
}
}
@@ -0,0 +1,19 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public record AlarmConfiguration(
List<PriceAlarmDefinition> definitions,
Map<String, String> variables
) {
public AlarmConfiguration {
Objects.requireNonNull(definitions, "definitions");
Objects.requireNonNull(variables, "variables");
definitions = List.copyOf(definitions);
variables = new ConcurrentHashMap<>(variables);
}
}
@@ -0,0 +1,331 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class AlarmConfigurationParser {
public static AlarmConfiguration parse(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
path,
lines,
SUPPORTED_FORMAT_VERSION
);
List<PriceAlarmDefinition> alarms = new ArrayList<>();
Map<String, String> constants = new LinkedHashMap<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
if (lineNumber - 1 == formatVersionLineIndex) {
continue;
}
String line = lines.get(lineNumber - 1);
String trimmed = line.trim();
if(trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
try {
if(isConstantDefinition(trimmed)) {
parseConstantDefinition(constants, line);
} else {
alarms.add(parseLine(line));
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (alarms.isEmpty()) {
throw new IllegalArgumentException("No alarms found in " + path);
}
return new AlarmConfiguration(alarms, constants);
}
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)
);
String conditionExpression = parseConditionExpression(cursor);
TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger")
);
validateConditionForTrigger(
conditionExpression,
triggerConfiguration.trigger()
);
cursor.skipWhitespace();
if (!cursor.atEnd() && cursor.current() != '#') {
throw new IllegalArgumentException(
"Unexpected text after trigger: " + cursor.remaining()
);
}
return new PriceAlarmDefinition(
id,
asset,
conditionExpression,
triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod()
);
}
private static String parseConditionExpression(Cursor cursor) {
String conditionExpression = cursor.nextToken("condition");
if (conditionExpression.equals("<")
|| conditionExpression.equals("<=")
|| conditionExpression.equals(">")
|| conditionExpression.equals(">=")) {
throw new IllegalArgumentException(
"Condition must not contain whitespace: " + conditionExpression
);
}
return conditionExpression;
}
private static void validateConditionForTrigger(
String conditionExpression,
AlarmTrigger trigger
) {
if (trigger != AlarmTrigger.CROSSING) {
if (conditionExpression.contains("/")) {
throw new IllegalArgumentException(
"Hysteresis is only supported for CROSSING conditions: "
+ conditionExpression
);
}
return;
}
boolean directionalCondition =
(conditionExpression.startsWith("<")
&& !conditionExpression.startsWith("<="))
|| (conditionExpression.startsWith(">")
&& !conditionExpression.startsWith(">="));
if (!directionalCondition) {
throw new IllegalArgumentException(
"CROSSING requires a '<' or '>' condition: " + conditionExpression
);
}
}
private static TriggerConfiguration parseTrigger(String triggerText) {
String normalized = triggerText.toUpperCase(Locale.ROOT);
if (normalized.equals("ONETIME")) {
return new TriggerConfiguration(AlarmTrigger.ONETIME, 0L);
}
if (normalized.equals("CROSSING")) {
return new TriggerConfiguration(AlarmTrigger.CROSSING, 0L);
}
if (normalized.equals("PERSISTENT")) {
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0L);
}
if (normalized.startsWith("PERSISTENT:")) {
String graceText = triggerText.substring("PERSISTENT:".length());
return new TriggerConfiguration(
AlarmTrigger.PERSISTENT,
parsePersistentGracePeriod(graceText)
);
}
if (normalized.startsWith("ONETIME:")) {
throw new IllegalArgumentException("ONETIME cannot have a grace period: " + triggerText);
}
if (normalized.startsWith("CROSSING:")) {
throw new IllegalArgumentException("CROSSING cannot have a grace period: " + triggerText);
}
throw new IllegalArgumentException("Unknown trigger: " + triggerText);
}
private static ΩmilliSecondsΩ parsePersistentGracePeriod(String graceText) {
Matcher matcher = PERSISTENT_GRACE_PERIOD_PATTERN.matcher(graceText);
if (!matcher.matches()) {
throw invalidPersistentGracePeriod(graceText);
}
try {
long value = Long.parseLong(matcher.group(1));
long millisecondsPerUnit = switch (matcher.group(2)) {
case "ms" -> 1L;
case "s" -> 1_000L;
case "m" -> 60_000L;
case "h" -> 3_600_000L;
case "d" -> 86_400_000L;
case "w" -> 604_800_000L;
case "M" -> 2_592_000_000L;
case "y" -> 31_536_000_000L;
default -> throw new AssertionError("Unsupported grace period suffix");
};
return Math.multiplyExact(value, millisecondsPerUnit);
} catch (NumberFormatException | ArithmeticException exception) {
throw invalidPersistentGracePeriod(graceText, exception);
}
}
private static IllegalArgumentException invalidPersistentGracePeriod(
String graceText
) {
return invalidPersistentGracePeriod(graceText, null);
}
private static IllegalArgumentException invalidPersistentGracePeriod(
String graceText,
Exception cause
) {
return new IllegalArgumentException(
"Invalid persistent grace period '" + graceText
+ "'. Expected a non-negative whole number followed by one of: "
+ "ms, s, m, h, d, w, M, y",
cause
);
}
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 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);
constants.put(parseVariableName(name), value);
}
private static String parseVariableName(String variableName) {
if (!variableName.startsWith("{{") || !variableName.endsWith("}}")) {
throw new IllegalArgumentException("Invalid variable name: " + variableName);
}
String parsedVariableName = variableName.substring(2, variableName.length() - 2);
if (parsedVariableName.isBlank()) {
throw new IllegalArgumentException("Variable name cannot be blank: " + variableName);
}
return parsedVariableName;
}
private static String replaceConstants(
String line,
Map<String, String> constants
) {
String result = line;
for (Map.Entry<String, String> entry : constants.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
if (result.contains("{{") || result.contains("}}")) {
throw new IllegalArgumentException(
"Unknown or malformed constant in line: " + line
);
}
return result;
}
private static void validateConstantName(String name) {
if (!name.matches("\\{\\{[A-Z0-9_]+}}")) {
throw new IllegalArgumentException("Invalid constant name: " + name);
}
}
private record TriggerConfiguration(
AlarmTrigger trigger,
ΩmilliSecondsΩ gracePeriod
) {
}
private static final Pattern PERSISTENT_GRACE_PERIOD_PATTERN =
Pattern.compile("(\\d+)(ms|s|m|h|d|w|M|y)");
private static final int SUPPORTED_FORMAT_VERSION = 1;
private AlarmConfigurationParser() {
}
}
@@ -0,0 +1,60 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public final class AlarmTargetParser {
private AlarmTargetParser() {
}
public static BigDecimal parse(String targetStr) {
String trimmedTargetStr = targetStr.trim();
if (trimmedTargetStr.endsWith("%")) {
int plusIndex = trimmedTargetStr.indexOf('+');
int minusIndex = trimmedTargetStr.indexOf('-', 1);
if (plusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, plusIndex, true);
}
if (minusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, minusIndex, false);
}
}
BigDecimal target = new BigDecimal(trimmedTargetStr);
validateTarget(target, targetStr);
return target;
}
private static BigDecimal parseTargetPercentage(
String targetStr,
int operatorIndex,
boolean add
) {
BigDecimal base = new BigDecimal(targetStr.substring(0, operatorIndex));
String percentStr = targetStr.substring(operatorIndex + 1, targetStr.length() - 1);
BigDecimal percent = new BigDecimal(percentStr);
BigDecimal delta = base
.multiply(percent)
.divide(BigDecimal.valueOf(100));
BigDecimal target = add ? base.add(delta) : base.subtract(delta);
validateTarget(base, targetStr);
validateTarget(percent, targetStr);
validateTarget(target, targetStr);
return target;
}
private static void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Target must be zero or positive: " + originalTargetStr
);
}
}
}
@@ -0,0 +1,7 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public enum AlarmTrigger {
ONETIME,
CROSSING,
PERSISTENT
}
@@ -0,0 +1,30 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.Map;
import java.util.Objects;
public final class AlarmVariableResolver {
public AlarmVariableResolver(Map<String, String> variables) {
this.variables = Objects.requireNonNull(variables, "variables");
}
public String resolve(String text) {
Objects.requireNonNull(text, "text");
String resolvedText = text;
for (Map.Entry<String, String> entry : variables.entrySet()) {
String placeholder = "{{" + entry.getKey() + "}}";
resolvedText = resolvedText.replace(placeholder, entry.getValue());
}
if (resolvedText.contains("{{") || resolvedText.contains("}}")) {
throw new IllegalArgumentException("Unresolved variable in text: " + text);
}
return resolvedText;
}
private final Map<String, String> variables;
}
@@ -0,0 +1,80 @@
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,
AlarmVariableResolver variableResolver,
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, variableResolver, 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,25 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record ComparisonPriceCondition(
PriceComparisonOperator operator,
BigDecimal target
) implements PriceCondition {
public ComparisonPriceCondition {
Objects.requireNonNull(operator, "operator");
Objects.requireNonNull(target, "target");
}
@Override
public boolean matches(BigDecimal price) {
return operator.matches(price, target);
}
@Override
public String expression() {
return operator.symbol() + target.toPlainString();
}
}
@@ -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, ResolvedPriceAlarm 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,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public interface ConfiguredAlarmAction extends AlarmAction {
String getConfigurationFileName();
}
@@ -0,0 +1,103 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class ConsoleAlarmAction implements ConfiguredAlarmAction {
public ConsoleAlarmAction(Path configurationDirectory) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
alarmIds = parseAlarmIds(
configurationDirectory.resolve(getConfigurationFileName())
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_Console.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
if (!alarmIds.contains(alarm.id())) {
return;
}
System.err.println();
System.err.println("============================================================");
System.err.printf(
"ALARM_%d: %s is %s USD; condition %s USD%n",
alarm.id(),
price.asset(),
price.priceUsd().toPlainString(),
alarm.condition().expression()
);
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();
}
private static Set<Integer> parseAlarmIds(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
path,
lines,
SUPPORTED_FORMAT_VERSION
);
Set<Integer> result = new LinkedHashSet<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
if (lineNumber - 1 == formatVersionLineIndex) {
continue;
}
String line = removeComment(lines.get(lineNumber - 1)).trim();
if (line.isEmpty()) {
continue;
}
try {
if (line.chars().anyMatch(Character::isWhitespace)) {
throw new IllegalArgumentException(
"Expected one alarm id, but found: " + line
);
}
int alarmId = Integer.parseInt(line);
if (!result.add(alarmId)) {
throw new IllegalArgumentException(
"Duplicate alarm id: " + alarmId
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
return Set.copyOf(result);
}
private static String removeComment(String line) {
int commentStart = line.indexOf('#');
return commentStart < 0 ? line : line.substring(0, commentStart);
}
private static final int SUPPORTED_FORMAT_VERSION = 1;
private final Set<Integer> alarmIds;
}
@@ -0,0 +1,62 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record CrossingPriceCondition(
ComparisonPriceCondition comparison,
BigDecimal hysteresis
) implements PriceCondition {
public CrossingPriceCondition {
Objects.requireNonNull(comparison, "comparison");
Objects.requireNonNull(hysteresis, "hysteresis");
if (comparison.operator() != PriceComparisonOperator.LESS_THAN
&& comparison.operator() != PriceComparisonOperator.GREATER_THAN) {
throw new IllegalArgumentException(
"CROSSING requires a '<' or '>' comparison"
);
}
if (hysteresis.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be zero or positive: " + hysteresis
);
}
}
@Override
public boolean matches(BigDecimal price) {
return comparison.matches(price);
}
public boolean matchesRearmCondition(BigDecimal price) {
BigDecimal rearmTarget = switch (comparison.operator()) {
case GREATER_THAN -> comparison.target().subtract(hysteresis);
case LESS_THAN -> comparison.target().add(hysteresis);
default -> throw new IllegalStateException(
"Unsupported CROSSING operator: " + comparison.operator()
);
};
return switch (comparison.operator()) {
case GREATER_THAN -> price.compareTo(rearmTarget) <= 0;
case LESS_THAN -> price.compareTo(rearmTarget) >= 0;
default -> throw new IllegalStateException(
"Unsupported CROSSING operator: " + comparison.operator()
);
};
}
@Override
public String expression() {
if (hysteresis.compareTo(BigDecimal.ZERO) == 0) {
return comparison.expression();
}
return comparison.expression()
+ "/"
+ hysteresis.stripTrailingZeros().toPlainString();
}
}
@@ -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,268 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.objcache.ObjectCache;
import com.r35157.libs.objcache.impl.ref.ObjectCacheImpl;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.impl.cached.CachedSolanaBlockChain;
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
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;
System.out.println("Starting Jupiter Perps Alarms...");
try {
System.out.print("Parsing commandline parameters... ");
config = Config.parse(args, System.getenv());
System.out.println("Done.");
} catch (IllegalArgumentException exception) {
printUsage();
String errMsg = "Could not parse configuration for JupiterPerpsAlarm: " + exception.getMessage() + "!";
throw new IllegalStateException(errMsg, exception);
}
List<PriceAlarmDefinition> definitions;
AlarmConfiguration alarmConfiguration;
try {
System.out.print("Parsing alarm configuration... ");
alarmConfiguration = AlarmConfigurationParser.parse(config.alarmConfiguration());
System.out.println("Done.");
definitions = alarmConfiguration.definitions();
} catch (Exception exception) {
String errMsg = "Could not load alarm configuration: " + exception.getMessage() + "!";
throw new IllegalStateException(errMsg, exception);
}
AlarmVariableResolver variableResolver = new AlarmVariableResolver(
alarmConfiguration.variables()
);
System.out.print("Initializing dependencies... ");
ObjectCache objectCache = new ObjectCacheImpl();
SolanaBlockChain realSolanaBlockChain = new SolanaBlockChainImpl();
SolanaBlockChain solanaBlockChain = new CachedSolanaBlockChain(
realSolanaBlockChain,
objectCache
);
JupiterPerpsService jupiterPerpsService = new AnchorIdlJupiterPerpsServiceImpl(solanaBlockChain);
JupiterPerpsEntryPriceVariableRefresher entryPriceVariableRefresher =
new JupiterPerpsEntryPriceVariableRefresher(alarmConfiguration.variables(), jupiterPerpsService);
System.out.println("Done.");
System.out.println("Executing first pre-run data fetch:");
entryPriceVariableRefresher.refresh();
System.out.println("Fetching done.");
System.out.print("Validating alarm conditions... ");
validateAlarmConditions(definitions, variableResolver);
System.out.println("Done.");
entryPriceVariableRefresher.startPeriodicRefresh();
JupiterPerpsEntryPriceVariableRefreshWatcher entryPriceVariableRefreshWatcher =
new JupiterPerpsEntryPriceVariableRefreshWatcher(
config.alarmConfiguration().getParent(),
entryPriceVariableRefresher
);
entryPriceVariableRefreshWatcher.start();
Path alarmConfigurationDirectory = config.alarmConfiguration()
.toAbsolutePath()
.getParent();
List<AlarmAction> actions = new ArrayList<>();
actions.add(new ConsoleAlarmAction(alarmConfigurationDirectory));
actions.add(new JupiterPerpsPositionIncreaseAlarmAction(
alarmConfigurationDirectory,
solanaBlockChain,
jupiterPerpsService,
variableResolver
));
actions.add(new JupiterPerpsPositionDecreaseAlarmAction(
alarmConfigurationDirectory,
solanaBlockChain,
jupiterPerpsService,
variableResolver
));
actions.add(new PushoverAlarmAction(
alarmConfigurationDirectory,
variableResolver
));
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, variableResolver, 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(
() -> {
entryPriceVariableRefresher.close();
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 USD, %s%n",
definition.conditionExpression(),
definition.trigger()
));
});
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 void validateAlarmConditions(
List<PriceAlarmDefinition> definitions,
AlarmVariableResolver variableResolver
) {
for (PriceAlarmDefinition definition : definitions) {
try {
String resolvedConditionExpression = variableResolver.resolve(
definition.conditionExpression()
);
if (definition.trigger() == AlarmTrigger.CROSSING) {
AlarmConditionParser.parseCrossing(resolvedConditionExpression);
} else {
AlarmConditionParser.parse(resolvedConditionExpression);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"Invalid condition for alarm "
+ definition.id()
+ " ("
+ definition.conditionExpression()
+ "): "
+ exception.getMessage(),
exception
);
}
}
}
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
""");
}
private record Config(
Path alarmConfiguration,
List<URI> webSocketEndpoints
) {
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
);
}
}
private JupiterPerpsAlarmImpl() {
}
}
@@ -0,0 +1,35 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public enum JupiterPerpsAsset {
SOL(
"FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh",
"So11111111111111111111111111111111111111112"
),
ETH(
"AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1",
"7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
),
BTC(
"hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC",
"3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh"
);
JupiterPerpsAsset(
String oracleAccount,
ΩSPLMintAddressΩ tradedTokenMint
) {
this.oracleAccount = oracleAccount;
this.tradedTokenMint = tradedTokenMint;
}
public String oracleAccount() {
return oracleAccount;
}
public ΩSPLMintAddressΩ tradedTokenMint() {
return tradedTokenMint;
}
private final String oracleAccount;
private final ΩSPLMintAddressΩ tradedTokenMint;
}
@@ -0,0 +1,92 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.nio.file.*;
import java.util.Objects;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.FileSystems;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
public final class JupiterPerpsEntryPriceVariableRefreshWatcher {
public JupiterPerpsEntryPriceVariableRefreshWatcher(
Path confDirectory,
JupiterPerpsEntryPriceVariableRefresher refresher
) {
this.confDirectory = Objects.requireNonNull(confDirectory, "confDirectory");
this.refresher = Objects.requireNonNull(refresher, "refresher");
}
public void start() {
Thread thread = new Thread(
this::run,
"jupiter-perps-entry-price-variable-refresh-watcher"
);
thread.setDaemon(true);
thread.start();
}
private void run() {
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
confDirectory.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE
);
System.out.println(
"Jupiter Perps entry price variable refresh watcher started for directory: "
+ confDirectory
);
while (true) {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
Path path = (Path) event.context();
if (!REFRESH_TRIGGER_FILE_NAME.equals(path.getFileName().toString())) {
continue;
}
Path triggerFile = confDirectory.resolve(path);
System.out.println("Refresh trigger file detected: " + triggerFile);
try {
refresher.refresh();
} finally {
Files.deleteIfExists(triggerFile);
}
}
if (!key.reset()) {
System.err.println(
"Jupiter Perps entry price variable refresh watcher stopped: watch key is no longer valid"
);
return;
}
}
} catch (IOException exception) {
System.err.println(
"Could not start Jupiter Perps entry price variable refresh watcher: "
+ exception.getMessage()
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
System.err.println(
"Jupiter Perps entry price variable refresh watcher interrupted"
);
}
}
private static final String REFRESH_TRIGGER_FILE_NAME =
"jupiter-perps-alarm-var.refresh";
private final Path confDirectory;
private final JupiterPerpsEntryPriceVariableRefresher refresher;
}
@@ -0,0 +1,211 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class JupiterPerpsEntryPriceVariableRefresher
implements AutoCloseable {
public JupiterPerpsEntryPriceVariableRefresher(
Map<String, String> variables,
JupiterPerpsService jupiterPerpsService
) {
this.variables = Objects.requireNonNull(variables, "variables");
this.jupiterPerpsService = Objects.requireNonNull(jupiterPerpsService, "jupiterPerpsService");
scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(
runnable,
"jupiter-perps-price-variable-refresher"
);
thread.setDaemon(true);
return thread;
});
}
public void startPeriodicRefresh() {
scheduler.scheduleWithFixedDelay(
this::refreshPeriodically,
REFRESH_INTERVAL_SECONDS,
REFRESH_INTERVAL_SECONDS,
TimeUnit.SECONDS
);
System.out.println(
"Periodic Jupiter Perps price variable refresh started with interval: "
+ REFRESH_INTERVAL_SECONDS
+ " seconds"
);
}
@Override
public void close() {
scheduler.shutdownNow();
}
public synchronized void refresh() {
System.out.println("Executing refresh now...");
ΩSolanaWalletIdΩ wallet = variables.get("JUPITER_PERPS_WALLET");
if (wallet == null || wallet.isBlank()) {
System.err.println("Cannot refresh Jupiter Perps price variables: JUPITER_PERPS_WALLET is not configured");
return;
}
try {
System.out.println(" Fetching 'Open Positions':");
Set<JupiterPerpsPosition> positions = jupiterPerpsService.getOpenPositions(wallet);
System.out.println(
"Fetched " + positions.size()
+ " open Jupiter Perps positions for wallet: " + wallet
);
Map<String, String> previousPriceVariables = copyPriceVariables();
removePriceVariables();
for (JupiterPerpsPosition position : positions) {
String entryPriceVariableName = createEntryPriceVariableName(position);
putPriceVariable(
previousPriceVariables,
entryPriceVariableName,
position.entryPrice().toPlainString()
);
String liquidationPriceVariableName = createLiquidationPriceVariableName(position);
putPriceVariable(
previousPriceVariables,
liquidationPriceVariableName,
position.liquidationPrice().toPlainString()
);
}
} catch (IOException |
InterruptedException exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
System.err.println(
"Could not refresh Jupiter Perps price variables: " + exception.getMessage()
);
}
}
private Map<String, String> copyPriceVariables() {
Map<String, String> previousPriceVariables = new HashMap<>();
for (String variableName : PRICE_VARIABLE_NAMES) {
String value = variables.get(variableName);
if (value != null) {
previousPriceVariables.put(variableName, value);
}
}
return previousPriceVariables;
}
private void removePriceVariables() {
for (String variableName : PRICE_VARIABLE_NAMES) {
variables.remove(variableName);
}
}
private void putPriceVariable(
Map<String, String> previousPriceVariables,
String variableName,
String variableValue
) {
String previousValue = previousPriceVariables.get(variableName);
variables.put(variableName, variableValue);
if (previousValue == null || !previousValue.equals(variableValue)) {
System.out.println(
"{{" + variableName + "}} updated from " +
previousValue +
" --> " +
variableValue
);
}
}
private static String createEntryPriceVariableName(JupiterPerpsPosition position) {
return createPriceVariableName(position, "ENTRY_PRICE");
}
private static String createLiquidationPriceVariableName(JupiterPerpsPosition position) {
return createPriceVariableName(position, "LIQ_PRICE");
}
private static String createPriceVariableName(
JupiterPerpsPosition position,
String suffix
) {
String asset = switch (position.tradedTokenMint()) {
case "So11111111111111111111111111111111111111112" -> "SOL";
case "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh" -> "BTC";
case "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs" -> "ETH";
default -> throw new IllegalArgumentException(
"Unsupported Jupiter Perps traded token mint: " + position.tradedTokenMint()
);
};
return asset + "_" + position.direction() + "_" + suffix;
}
private static final Set<String> PRICE_VARIABLE_NAMES = Set.of(
"SOL_LONG_ENTRY_PRICE",
"SOL_SHORT_ENTRY_PRICE",
"BTC_LONG_ENTRY_PRICE",
"BTC_SHORT_ENTRY_PRICE",
"ETH_LONG_ENTRY_PRICE",
"ETH_SHORT_ENTRY_PRICE",
"SOL_LONG_LIQ_PRICE",
"SOL_SHORT_LIQ_PRICE",
"BTC_LONG_LIQ_PRICE",
"BTC_SHORT_LIQ_PRICE",
"ETH_LONG_LIQ_PRICE",
"ETH_SHORT_LIQ_PRICE"
);
private void refreshPeriodically() {
try {
refresh();
} catch (RuntimeException exception) {
System.err.println(
"Unexpected error during periodic Jupiter Perps price variable refresh: "
+ exception.getMessage()
);
exception.printStackTrace(System.err);
}
}
private static final ΩsecondsΩ REFRESH_INTERVAL_SECONDS = 60;
private final ScheduledExecutorService scheduler;
private final Map<String, String> variables;
private final JupiterPerpsService jupiterPerpsService;
}
@@ -0,0 +1,183 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Objects;
public final class JupiterPerpsPositionDecreaseAlarmAction
implements ConfiguredAlarmAction {
public JupiterPerpsPositionDecreaseAlarmAction(
Path configurationDirectory,
SolanaBlockChain solanaBlockChain,
JupiterPerpsService jupiter,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(
configurationDirectory,
"configurationDirectory"
);
Objects.requireNonNull(solanaBlockChain, "solanaBlockChain");
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
this.variableResolver = Objects.requireNonNull(
variableResolver,
"variableResolver"
);
configuration =
JupiterPerpsPositionDecreaseAlarmActionConfigurationParser
.parse(
configurationDirectory.resolve(
getConfigurationFileName()
),
variableResolver
);
wallet = new SolanaWalletImpl(
configuration.walletId(),
configuration.signerKeyName(),
solanaBlockChain
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
JupiterPerpsPositionDecreaseAlarmActionConfiguration.PositionDecrease
definition =
configuration.positionDecreases().get(alarm.id());
if (definition == null) {
return;
}
try {
JupiterPerpsPositionDecreaseAlarmActionConfiguration
.ResolvedPositionDecrease positionDecrease =
definition.resolve(variableResolver);
JupiterPerpsPosition position =
findOpenPosition(positionDecrease);
if (!isPositionSizeOk(position, positionDecrease)) {
return;
}
SolanaUnsignedTransaction utrans =
jupiter.buildPositionDecreaseTransaction(
position.positionAccount(),
positionDecrease.receiveToken()
.receiveTokenMint(),
positionDecrease.sizeUsdDelta(),
positionDecrease.maxSlippageBps()
);
SolanaSignedTransaction strans =
wallet.signTransaction(utrans);
ΩSolanaTransactionSignatureΩ signature =
jupiter.executePositionDecreaseTransaction(strans);
System.out.println(
"Position Decrease Signature: " + signature
);
} catch(Exception e) {
System.err.println("EXCEPTION: " + e.getMessage());
}
}
private JupiterPerpsPosition findOpenPosition(
JupiterPerpsPositionDecreaseAlarmActionConfiguration
.ResolvedPositionDecrease positionDecrease
) throws IOException, InterruptedException {
JupiterPerpsPosition matchingPosition = null;
for (JupiterPerpsPosition position
: jupiter.getOpenPositions(wallet.getAddress())) {
if (!position.tradedTokenMint().equals(
positionDecrease.asset().tradedTokenMint()
)) {
continue;
}
if (position.direction() != positionDecrease.direction()) {
continue;
}
if (matchingPosition != null) {
throw new IllegalStateException(
"Found multiple open "
+ positionDecrease.asset()
+ " "
+ positionDecrease.direction()
+ " positions for wallet "
+ wallet.getAddress()
);
}
matchingPosition = position;
}
if (matchingPosition == null) {
throw new IllegalStateException(
"No open "
+ positionDecrease.asset()
+ " "
+ positionDecrease.direction()
+ " position found for wallet "
+ wallet.getAddress()
);
}
return matchingPosition;
}
private boolean isPositionSizeOk(
JupiterPerpsPosition position,
JupiterPerpsPositionDecreaseAlarmActionConfiguration
.ResolvedPositionDecrease positionDecrease
) {
ΩUSDCAmountΩ currentPositionSize = position.positionSize();
ΩUSDCAmountΩ decreaseSizeUsd = positionDecrease.sizeUsdDelta();
ΩUSDCAmountΩ reservePositionSizeUsdLimit =
configuration.reservePositionSizeUsdLimit();
ΩUSDCAmountΩ remainingPositionSize =
currentPositionSize.subtract(decreaseSizeUsd);
if (remainingPositionSize.compareTo(
reservePositionSizeUsdLimit
) < 0) {
System.err.println(
"Position Decrease of "
+ decreaseSizeUsd
+ " USD skipped: Position size is "
+ currentPositionSize
+ " USD and would become "
+ remainingPositionSize
+ " USD, which is below the "
+ reservePositionSizeUsdLimit
+ " USD reserve."
);
return false;
}
return true;
}
private final JupiterPerpsPositionDecreaseAlarmActionConfiguration
configuration;
private final SolanaWallet wallet;
private final JupiterPerpsService jupiter;
private final AlarmVariableResolver variableResolver;
}
@@ -0,0 +1,150 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public record JupiterPerpsPositionDecreaseAlarmActionConfiguration(
ΩSolanaWalletIdΩ walletId,
String signerKeyName,
ΩUSDCAmountΩ reservePositionSizeUsdLimit,
Map<Integer, PositionDecrease> positionDecreases
) {
public JupiterPerpsPositionDecreaseAlarmActionConfiguration {
Objects.requireNonNull(walletId, "walletId");
Objects.requireNonNull(signerKeyName, "signerKeyName");
Objects.requireNonNull(reservePositionSizeUsdLimit, "reservePositionSizeUsdLimit");
if (reservePositionSizeUsdLimit.signum() < 0) {
throw new IllegalArgumentException(
"Position size USD reserve limit cannot be negative: "
+ reservePositionSizeUsdLimit
);
}
positionDecreases = Map.copyOf(positionDecreases);
if (walletId.isBlank()) {
throw new IllegalArgumentException("Wallet id cannot be blank");
}
if (signerKeyName.isBlank()) {
throw new IllegalArgumentException(
"Signer key name cannot be blank"
);
}
}
public record PositionDecrease(
String assetExpression,
String directionExpression,
String receiveTokenExpression,
String sizeUsdDeltaExpression,
String maxSlippageBpsExpression
) {
public PositionDecrease {
requireNonBlank(assetExpression, "asset");
requireNonBlank(directionExpression, "direction");
requireNonBlank(receiveTokenExpression, "receive token");
requireNonBlank(
sizeUsdDeltaExpression,
"position size delta"
);
requireNonBlank(
maxSlippageBpsExpression,
"maximum slippage"
);
}
public ResolvedPositionDecrease resolve(
AlarmVariableResolver variableResolver
) {
Objects.requireNonNull(
variableResolver,
"variableResolver"
);
return new ResolvedPositionDecrease(
JupiterPerpsAsset.valueOf(
variableResolver.resolve(assetExpression)
.toUpperCase(Locale.ROOT)
),
JupiterPerpsPositionDirection.valueOf(
variableResolver.resolve(directionExpression)
.toUpperCase(Locale.ROOT)
),
JupiterPerpsReceiveToken.valueOf(
variableResolver.resolve(receiveTokenExpression)
.toUpperCase(Locale.ROOT)
),
new ΩUSDCAmountΩ(
variableResolver.resolve(
sizeUsdDeltaExpression
)
),
Integer.parseInt(
variableResolver.resolve(
maxSlippageBpsExpression
)
)
);
}
private static void requireNonBlank(
String value,
String fieldName
) {
Objects.requireNonNull(value, fieldName);
if (value.isBlank()) {
throw new IllegalArgumentException(
fieldName + " cannot be blank"
);
}
}
}
public record ResolvedPositionDecrease(
JupiterPerpsAsset asset,
JupiterPerpsPositionDirection direction,
JupiterPerpsReceiveToken receiveToken,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
public ResolvedPositionDecrease {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(receiveToken, "receiveToken");
Objects.requireNonNull(sizeUsdDelta, "sizeUsdDelta");
if (sizeUsdDelta.signum() <= 0) {
throw new IllegalArgumentException(
"Position size delta must be greater than zero: "
+ sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > 10_000) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ "10000 basis points: "
+ maxSlippageBps
);
}
if (receiveToken != JupiterPerpsReceiveToken.USDC
&& !receiveToken.receiveTokenMint().equals(
asset.tradedTokenMint()
)) {
throw new IllegalArgumentException(
"Receive token must be USDC or match "
+ "the position asset: "
+ receiveToken
);
}
}
}
}
@@ -0,0 +1,180 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class JupiterPerpsPositionDecreaseAlarmActionConfigurationParser {
public static JupiterPerpsPositionDecreaseAlarmActionConfiguration parse(
Path path,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(variableResolver, "variableResolver");
List<String> lines = Files.readAllLines(path);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
path,
lines,
SUPPORTED_FORMAT_VERSION
);
Map<Integer, JupiterPerpsPositionDecreaseAlarmActionConfiguration.PositionDecrease>
positionDecreases = new LinkedHashMap<>();
ΩSolanaWalletIdΩ walletId = null;
String signerKeyName = null;
ΩUSDCAmountΩ reservePositionSizeUsdLimit = null;
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
if (lineNumber - 1 == formatVersionLineIndex) {
continue;
}
String line = removeComment(lines.get(lineNumber - 1)).trim();
if (line.isEmpty()) {
continue;
}
try {
String[] columns = line.split("\\s+");
String firstColumn = columns[0].toUpperCase(Locale.ROOT);
if (firstColumn.equals("WALLET_ID")) {
requireColumnCount(columns, 2, "WALLET_ID value");
if (walletId != null) {
throw new IllegalArgumentException(
"Duplicate WALLET_ID"
);
}
walletId = variableResolver.resolve(columns[1]);
} else if (firstColumn.equals("SIGNER_KEY_NAME")) {
requireColumnCount(
columns,
2,
"SIGNER_KEY_NAME value"
);
if (signerKeyName != null) {
throw new IllegalArgumentException(
"Duplicate SIGNER_KEY_NAME"
);
}
signerKeyName = variableResolver.resolve(columns[1]);
} else if (firstColumn.equals("RESERVE_POSITION_SIZE_USD_LIMIT")) {
requireColumnCount(
columns,
2,
"RESERVE_POSITION_SIZE_USD_LIMIT value"
);
if (reservePositionSizeUsdLimit != null) {
throw new IllegalArgumentException(
"Duplicate RESERVE_POSITION_SIZE_USD_LIMIT"
);
}
reservePositionSizeUsdLimit = new ΩUSDCAmountΩ(
variableResolver.resolve(columns[1])
);
} else {
parsePositionDecrease(
columns,
positionDecreases,
variableResolver
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": "
+ exception.getMessage(),
exception
);
}
}
if (walletId == null) {
throw new IllegalArgumentException(
"Missing WALLET_ID in " + path
);
}
if (signerKeyName == null) {
throw new IllegalArgumentException(
"Missing SIGNER_KEY_NAME in " + path
);
}
if (reservePositionSizeUsdLimit == null) {
throw new IllegalArgumentException(
"Missing RESERVE_POSITION_SIZE_USD_LIMIT in " + path
);
}
return new JupiterPerpsPositionDecreaseAlarmActionConfiguration(
walletId,
signerKeyName,
reservePositionSizeUsdLimit,
positionDecreases
);
}
private static void parsePositionDecrease(
String[] columns,
Map<Integer, JupiterPerpsPositionDecreaseAlarmActionConfiguration.PositionDecrease>
positionDecreases,
AlarmVariableResolver variableResolver
) {
requireColumnCount(
columns,
6,
"alarm id, asset, direction, receive token, "
+ "size delta, and maximum slippage"
);
int alarmId = Integer.parseInt(columns[0]);
if (positionDecreases.containsKey(alarmId)) {
throw new IllegalArgumentException(
"Duplicate alarm id: " + alarmId
);
}
JupiterPerpsPositionDecreaseAlarmActionConfiguration.PositionDecrease
positionDecrease =
new JupiterPerpsPositionDecreaseAlarmActionConfiguration.PositionDecrease(
columns[1],
columns[2],
columns[3],
columns[4],
columns[5]
);
positionDecrease.resolve(variableResolver);
positionDecreases.put(alarmId, positionDecrease);
}
private static void requireColumnCount(
String[] columns,
int expected,
String expectedColumns
) {
if (columns.length != expected) {
throw new IllegalArgumentException(
"Expected " + expectedColumns + ", but found "
+ columns.length + " column(s)"
);
}
}
private static String removeComment(String line) {
int commentStart = line.indexOf('#');
return commentStart < 0
? line
: line.substring(0, commentStart);
}
private static final int SUPPORTED_FORMAT_VERSION = 1;
private JupiterPerpsPositionDecreaseAlarmActionConfigurationParser() {
}
}
@@ -0,0 +1,123 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Objects;
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
public final class JupiterPerpsPositionIncreaseAlarmAction implements ConfiguredAlarmAction {
public JupiterPerpsPositionIncreaseAlarmAction(
Path configurationDirectory,
SolanaBlockChain solanaBlockChain,
JupiterPerpsService jupiter,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
Objects.requireNonNull(solanaBlockChain, "solanaBlockChain");
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
configuration = JupiterPerpsPositionIncreaseAlarmActionConfigurationParser.parse(
configurationDirectory.resolve(getConfigurationFileName()),
variableResolver
);
wallet = new SolanaWalletImpl(
configuration.walletId(),
configuration.signerKeyName(),
solanaBlockChain
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease definition =
configuration.positionIncreases().get(alarm.id());
if (definition == null) {
return;
}
try {
JupiterPerpsPositionIncreaseAlarmActionConfiguration.ResolvedPositionIncrease
positionIncrease = definition.resolve(variableResolver);
if (!isUSDCBalanceOk(positionIncrease)) {
return;
}
SolanaUnsignedTransaction utrans =
jupiter.buildPositionIncreaseTransaction(
wallet.getAddress(),
positionIncrease.asset().tradedTokenMint(),
positionIncrease.direction(),
positionIncrease.collateralUsd(),
positionIncrease.sizeUsdDelta(),
positionIncrease.maxSlippageBps()
);
SolanaSignedTransaction strans = wallet.signTransaction(utrans);
ΩSolanaTransactionSignatureΩ signature = jupiter.executePositionIncreaseTransaction(strans);
System.out.println("Position Increase Signature: " + signature);
} catch(Exception e) {
System.err.println("EXCEPTION: " + e.getMessage());
}
}
private boolean isUSDCBalanceOk(JupiterPerpsPositionIncreaseAlarmActionConfiguration.ResolvedPositionIncrease positionIncrease)
throws IOException, InterruptedException {
ΩAmountΩ usdcBalance = wallet.getSPLTokenBalance(
JupiterPerpsReceiveToken.USDC.receiveTokenMint(),
SPL_TOKEN_PROGRAM
);
if (usdcBalance == null) {
System.err.println("Position Increase of "
+ positionIncrease.collateralUsd()
+ " USDC skipped: Wallet has no USDC token account!"
);
return false;
}
ΩUSDCAmountΩ collateralUsd = positionIncrease.collateralUsd();
ΩUSDCAmountΩ reserveUsdcLimit = configuration.reserveUsdcLimit();
ΩUSDCAmountΩ requiredUsdcBalance = collateralUsd.add(reserveUsdcLimit);
if (usdcBalance.compareTo(requiredUsdcBalance) < 0) {
System.err.println(
"Position Increase of "
+ positionIncrease.collateralUsd()
+ " USDC skipped: Wallet has "
+ usdcBalance
+ " USDC, but "
+ requiredUsdcBalance
+ " USDC is required ("
+ collateralUsd
+ " collateral + "
+ reserveUsdcLimit
+ " reserve)."
);
return false;
}
return true;
}
private final JupiterPerpsPositionIncreaseAlarmActionConfiguration configuration;
private final SolanaWallet wallet;
private final JupiterPerpsService jupiter;
private final AlarmVariableResolver variableResolver;
}
@@ -0,0 +1,106 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public record JupiterPerpsPositionIncreaseAlarmActionConfiguration(
ΩSolanaWalletIdΩ walletId,
String signerKeyName,
ΩUSDCAmountΩ reserveUsdcLimit,
Map<Integer, PositionIncrease> positionIncreases
) {
public JupiterPerpsPositionIncreaseAlarmActionConfiguration {
Objects.requireNonNull(walletId, "walletId");
Objects.requireNonNull(signerKeyName, "signerKeyName");
Objects.requireNonNull(reserveUsdcLimit, "reserveUsdcLimit");
positionIncreases = Map.copyOf(positionIncreases);
if (walletId.isBlank()) {
throw new IllegalArgumentException("Wallet id cannot be blank");
}
if (signerKeyName.isBlank()) {
throw new IllegalArgumentException("Signer key name cannot be blank");
}
if (reserveUsdcLimit.signum() < 0) {
throw new IllegalArgumentException(
"USDC reserve limit cannot be negative: " + reserveUsdcLimit
);
}
}
public record PositionIncrease(
String assetExpression,
String directionExpression,
String collateralUsdExpression,
String sizeUsdDeltaExpression,
String maxSlippageBpsExpression
) {
public PositionIncrease {
requireNonBlank(assetExpression, "asset");
requireNonBlank(directionExpression, "direction");
requireNonBlank(collateralUsdExpression, "collateral");
requireNonBlank(sizeUsdDeltaExpression, "position size delta");
requireNonBlank(maxSlippageBpsExpression, "maximum slippage");
}
public ResolvedPositionIncrease resolve(AlarmVariableResolver variableResolver) {
Objects.requireNonNull(variableResolver, "variableResolver");
return new ResolvedPositionIncrease(
JupiterPerpsAsset.valueOf(
variableResolver.resolve(assetExpression).toUpperCase(Locale.ROOT)
),
JupiterPerpsPositionDirection.valueOf(
variableResolver.resolve(directionExpression)
.toUpperCase(Locale.ROOT)
),
new ΩUSDCAmountΩ(variableResolver.resolve(collateralUsdExpression)),
new ΩUSDCAmountΩ(variableResolver.resolve(sizeUsdDeltaExpression)),
Integer.parseInt(variableResolver.resolve(maxSlippageBpsExpression))
);
}
private static void requireNonBlank(String value, String fieldName) {
Objects.requireNonNull(value, fieldName);
if (value.isBlank()) {
throw new IllegalArgumentException(fieldName + " cannot be blank");
}
}
}
public record ResolvedPositionIncrease(
JupiterPerpsAsset asset,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ collateralUsd,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
public ResolvedPositionIncrease {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(collateralUsd, "collateralUsd");
Objects.requireNonNull(sizeUsdDelta, "sizeUsdDelta");
if (collateralUsd.signum() <= 0) {
throw new IllegalArgumentException(
"Collateral must be greater than zero: " + collateralUsd
);
}
if (sizeUsdDelta.signum() < 0) {
throw new IllegalArgumentException(
"Position size delta cannot be negative: " + sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > 10_000) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and 10000 basis points: "
+ maxSlippageBps
);
}
}
}
}
@@ -0,0 +1,157 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class JupiterPerpsPositionIncreaseAlarmActionConfigurationParser {
public static JupiterPerpsPositionIncreaseAlarmActionConfiguration parse(
Path path,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(variableResolver, "variableResolver");
List<String> lines = Files.readAllLines(path);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
path,
lines,
SUPPORTED_FORMAT_VERSION
);
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
positionIncreases = new LinkedHashMap<>();
ΩSolanaWalletIdΩ walletId = null;
String signerKeyName = null;
ΩUSDCAmountΩ reserveUsdcLimit = null;
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
if (lineNumber - 1 == formatVersionLineIndex) {
continue;
}
String line = removeComment(lines.get(lineNumber - 1)).trim();
if (line.isEmpty()) {
continue;
}
try {
String[] columns = line.split("\\s+");
String firstColumn = columns[0].toUpperCase(Locale.ROOT);
if (firstColumn.equals("WALLET_ID")) {
requireColumnCount(columns, 2, "WALLET_ID value");
if (walletId != null) {
throw new IllegalArgumentException("Duplicate WALLET_ID");
}
walletId = variableResolver.resolve(columns[1]);
} else if (firstColumn.equals("SIGNER_KEY_NAME")) {
requireColumnCount(columns, 2, "SIGNER_KEY_NAME value");
if (signerKeyName != null) {
throw new IllegalArgumentException("Duplicate SIGNER_KEY_NAME");
}
signerKeyName = variableResolver.resolve(columns[1]);
} else if (firstColumn.equals("RESERVE_USDC_LIMIT")) {
requireColumnCount(columns, 2, "RESERVE_USDC_LIMIT value");
if (reserveUsdcLimit != null) {
throw new IllegalArgumentException(
"Duplicate RESERVE_USDC_LIMIT"
);
}
reserveUsdcLimit = new ΩUSDCAmountΩ(
variableResolver.resolve(columns[1])
);
} else {
parsePositionIncrease(
columns,
positionIncreases,
variableResolver
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (walletId == null) {
throw new IllegalArgumentException("Missing WALLET_ID in " + path);
}
if (signerKeyName == null) {
throw new IllegalArgumentException("Missing SIGNER_KEY_NAME in " + path);
}
if (reserveUsdcLimit == null) {
throw new IllegalArgumentException(
"Missing RESERVE_USDC_LIMIT in " + path
);
}
return new JupiterPerpsPositionIncreaseAlarmActionConfiguration(
walletId,
signerKeyName,
reserveUsdcLimit,
positionIncreases
);
}
private static void parsePositionIncrease(
String[] columns,
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
positionIncreases,
AlarmVariableResolver variableResolver
) {
requireColumnCount(
columns,
6,
"alarm id, asset, direction, collateral, size delta, and maximum slippage"
);
int alarmId = Integer.parseInt(columns[0]);
if (positionIncreases.containsKey(alarmId)) {
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
}
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease positionIncrease =
new JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease(
columns[1],
columns[2],
columns[3],
columns[4],
columns[5]
);
positionIncrease.resolve(variableResolver);
positionIncreases.put(alarmId, positionIncrease);
}
private static void requireColumnCount(
String[] columns,
int expected,
String expectedColumns
) {
if (columns.length != expected) {
throw new IllegalArgumentException(
"Expected " + expectedColumns + ", but found " + columns.length + " column(s)"
);
}
}
private static String removeComment(String line) {
int commentStart = line.indexOf('#');
return commentStart < 0 ? line : line.substring(0, commentStart);
}
private static final int SUPPORTED_FORMAT_VERSION = 1;
private JupiterPerpsPositionIncreaseAlarmActionConfigurationParser() {
}
}
@@ -0,0 +1,20 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public enum JupiterPerpsReceiveToken {
SOL(JupiterPerpsAsset.SOL.tradedTokenMint()),
ETH(JupiterPerpsAsset.ETH.tradedTokenMint()),
BTC(JupiterPerpsAsset.BTC.tradedTokenMint()),
USDC("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
JupiterPerpsReceiveToken(
ΩSPLMintAddressΩ receiveTokenMint
) {
this.receiveTokenMint = receiveTokenMint;
}
public ΩSPLMintAddressΩ receiveTokenMint() {
return receiveTokenMint;
}
private final ΩSPLMintAddressΩ receiveTokenMint;
}
@@ -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,150 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.time.Instant;
public final class PriceAlarm {
public PriceAlarm(
PriceAlarmDefinition definition,
AlarmVariableResolver variableResolver,
AlarmAction action
) {
this.definition = definition;
this.variableResolver = variableResolver;
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"
);
}
PriceCondition condition;
try {
String resolvedConditionExpression = variableResolver.resolve(
definition.conditionExpression()
);
condition = definition.trigger() == AlarmTrigger.CROSSING
? AlarmConditionParser.parseCrossing(resolvedConditionExpression)
: AlarmConditionParser.parse(resolvedConditionExpression);
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve condition for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
if (definition.trigger() == AlarmTrigger.CROSSING) {
acceptCrossing(price, (CrossingPriceCondition) condition);
return;
}
boolean reached = condition.matches(price.priceUsd());
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, condition);
return;
}
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price, condition);
}
return;
}
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
}
private void acceptCrossing(
OraclePrice price,
CrossingPriceCondition condition
) {
boolean reached = condition.matches(price.priceUsd());
if (crossingArmed == null) {
crossingArmed = !reached;
return;
}
if (crossingArmed) {
if (reached) {
crossingArmed = false;
trigger(price, condition);
}
return;
}
if (condition.matchesRearmCondition(price.priceUsd())) {
crossingArmed = true;
}
}
public PriceAlarmDefinition definition() {
return definition;
}
public synchronized long triggerCount() {
return triggerCount;
}
private boolean persistentGracePeriodHasPassed() {
if (lastTriggeredAt == null) {
return true;
}
ΩmilliSecondsΩ gracePeriod = definition.triggerGracePeriod();
if (gracePeriod == 0) {
return true;
}
return !Instant.now().isBefore(
lastTriggeredAt.plusMillis(gracePeriod)
);
}
private void trigger(OraclePrice price, PriceCondition condition) {
triggerCount++;
lastTriggeredAt = Instant.now();
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
definition.id(),
definition.asset(),
condition,
definition.trigger(),
definition.triggerGracePeriod()
);
action.trigger(price, resolvedAlarm);
}
private final PriceAlarmDefinition definition;
private final AlarmVariableResolver variableResolver;
private final AlarmAction action;
private Instant lastTriggeredAt;
private Boolean crossingArmed;
private Boolean previousReached;
private long triggerCount;
}
@@ -0,0 +1,27 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.Objects;
public record PriceAlarmDefinition(
int id,
JupiterPerpsAsset asset,
String conditionExpression,
AlarmTrigger trigger,
ΩmilliSecondsΩ triggerGracePeriod
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(conditionExpression, "conditionExpression");
Objects.requireNonNull(trigger, "trigger");
if (conditionExpression.isBlank()) {
throw new IllegalArgumentException("Condition expression cannot be blank");
}
if (triggerGracePeriod < 0) {
throw new IllegalArgumentException(
"Trigger grace period cannot be negative: " + triggerGracePeriod
);
}
}
}
@@ -0,0 +1,42 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public enum PriceComparisonOperator {
LESS_THAN("<") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) < 0;
}
},
LESS_THAN_OR_EQUAL("<=") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) <= 0;
}
},
GREATER_THAN(">") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) > 0;
}
},
GREATER_THAN_OR_EQUAL(">=") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) >= 0;
}
};
PriceComparisonOperator(String symbol) {
this.symbol = symbol;
}
public abstract boolean matches(BigDecimal price, BigDecimal target);
public String symbol() {
return symbol;
}
private final String symbol;
}
@@ -0,0 +1,10 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public interface PriceCondition {
boolean matches(BigDecimal price);
String expression();
}
@@ -0,0 +1,123 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
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;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
public final class PushoverAlarmAction implements ConfiguredAlarmAction {
public PushoverAlarmAction(
Path configurationDirectory,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
configuration = PushoverAlarmActionConfigurationParser.parse(
configurationDirectory.resolve(getConfigurationFileName()),
variableResolver
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_Pushover.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
PushoverAlarmActionConfiguration.Notification notification =
configuration.notifications().get(alarm.id());
if (notification == null) {
return;
}
String title = "Jupiter Perps " + price.asset() + " alarm";
String message = createMessage(
price,
alarm,
notification.severity(),
variableResolver.resolve(notification.note())
);
String body = form("token", configuration.applicationToken()) + "&" +
form("user", configuration.userKey()) + "&" +
form("title", title) + "&" +
form("message", message) + "&" +
createPushoverSeverityParameters(notification.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: " + notification.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,
ResolvedPriceAlarm alarm,
AlarmSeverity severity,
String note
) {
return String.format(
"%d - %s: %s%n%n%s is %s USD.%nCondition: %s USD.%nOracle time: %s.%nSlot: %d.",
alarm.id(),
severity,
note,
price.asset(),
price.priceUsd().toPlainString(),
alarm.condition().expression(),
price.oracleTime(),
price.slot()
);
}
private final HttpClient httpClient = HttpClient.newHttpClient();
private final PushoverAlarmActionConfiguration configuration;
private final AlarmVariableResolver variableResolver;
private static final URI PUSHOVER_URI =
URI.create("https://api.pushover.net/1/messages.json");
}
@@ -0,0 +1,35 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.util.Map;
import java.util.Objects;
public record PushoverAlarmActionConfiguration(
String applicationToken,
String userKey,
Map<Integer, Notification> notifications
) {
public PushoverAlarmActionConfiguration {
Objects.requireNonNull(applicationToken, "applicationToken");
Objects.requireNonNull(userKey, "userKey");
notifications = Map.copyOf(notifications);
if (applicationToken.isBlank()) {
throw new IllegalArgumentException("Application token cannot be blank");
}
if (userKey.isBlank()) {
throw new IllegalArgumentException("User key cannot be blank");
}
}
public record Notification(
AlarmSeverity severity,
String note
) {
public Notification {
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note");
}
}
}
@@ -0,0 +1,222 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class PushoverAlarmActionConfigurationParser {
public static PushoverAlarmActionConfiguration parse(
Path path,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(variableResolver, "variableResolver");
List<String> lines = Files.readAllLines(path);
int formatVersionLineIndex = ConfigurationFormatVersionValidator.validate(
path,
lines,
SUPPORTED_FORMAT_VERSION
);
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications =
new LinkedHashMap<>();
String applicationToken = null;
String userKey = null;
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
if (lineNumber - 1 == formatVersionLineIndex) {
continue;
}
String line = lines.get(lineNumber - 1);
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
try {
Cursor cursor = new Cursor(line);
String firstToken = cursor.nextToken("setting or alarm id");
switch (firstToken.toUpperCase(Locale.ROOT)) {
case "APPLICATION_TOKEN" -> {
if (applicationToken != null) {
throw new IllegalArgumentException("Duplicate APPLICATION_TOKEN");
}
applicationToken = variableResolver.resolve(
cursor.nextToken("application token")
);
cursor.requireEndOrComment();
}
case "USER_KEY" -> {
if (userKey != null) {
throw new IllegalArgumentException("Duplicate USER_KEY");
}
userKey = variableResolver.resolve(
cursor.nextToken("user key")
);
cursor.requireEndOrComment();
}
default -> parseNotification(
firstToken,
cursor,
notifications,
variableResolver
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (applicationToken == null) {
throw new IllegalArgumentException("Missing APPLICATION_TOKEN in " + path);
}
if (userKey == null) {
throw new IllegalArgumentException("Missing USER_KEY in " + path);
}
return new PushoverAlarmActionConfiguration(
applicationToken,
userKey,
notifications
);
}
private static void parseNotification(
String alarmIdText,
Cursor cursor,
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications,
AlarmVariableResolver variableResolver
) {
int alarmId;
try {
alarmId = Integer.parseInt(alarmIdText);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Expected APPLICATION_TOKEN, USER_KEY, or alarm id: " + alarmIdText,
exception
);
}
AlarmSeverity severity = AlarmSeverity.valueOf(
variableResolver.resolve(cursor.nextToken("severity"))
.toUpperCase(Locale.ROOT)
);
String note = cursor.nextQuotedString("note");
variableResolver.resolve(note);
cursor.requireEndOrComment();
PushoverAlarmActionConfiguration.Notification previous = notifications.putIfAbsent(
alarmId,
new PushoverAlarmActionConfiguration.Notification(severity, note)
);
if (previous != null) {
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
}
}
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 requireEndOrComment() {
skipWhitespace();
if (!atEnd() && current() != '#') {
throw new IllegalArgumentException(
"Unexpected text: " + line.substring(position)
);
}
}
private void skipWhitespace() {
while (!atEnd() && Character.isWhitespace(current())) {
position++;
}
}
private boolean atEnd() {
return position >= line.length();
}
private char current() {
return line.charAt(position);
}
private final String line;
private int position;
}
private static final int SUPPORTED_FORMAT_VERSION = 1;
private PushoverAlarmActionConfigurationParser() {
}
}
@@ -0,0 +1,54 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record RangePriceCondition(
BigDecimal lowerTarget,
boolean lowerInclusive,
BigDecimal upperTarget,
boolean upperInclusive
) implements PriceCondition {
public RangePriceCondition {
Objects.requireNonNull(lowerTarget, "lowerTarget");
Objects.requireNonNull(upperTarget, "upperTarget");
if (lowerTarget.compareTo(upperTarget) > 0) {
throw new IllegalArgumentException(
"Range lower target must not exceed upper target: "
+ lowerTarget.toPlainString()
+ "-->"
+ upperTarget.toPlainString()
);
}
}
@Override
public boolean matches(BigDecimal price) {
int lowerComparison = price.compareTo(lowerTarget);
int upperComparison = price.compareTo(upperTarget);
boolean matchesLower = lowerInclusive
? lowerComparison >= 0
: lowerComparison > 0;
boolean matchesUpper = upperInclusive
? upperComparison <= 0
: upperComparison < 0;
return matchesLower && matchesUpper;
}
@Override
public String expression() {
char lowerDelimiter = lowerInclusive ? '[' : '(';
char upperDelimiter = upperInclusive ? ']' : ')';
return lowerDelimiter
+ lowerTarget.toPlainString()
+ "-->"
+ upperTarget.toPlainString()
+ upperDelimiter;
}
}
@@ -0,0 +1,10 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public record ResolvedPriceAlarm(
int id,
JupiterPerpsAsset asset,
PriceCondition condition,
AlarmTrigger trigger,
ΩmilliSecondsΩ triggerGracePeriod
) {
}
@@ -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,5 @@
package com.r35157.libs.codec;
public interface Base58Codec {
String encode(byte[] input);
}
@@ -0,0 +1,70 @@
package com.r35157.libs.codec.impl.ref;
import com.r35157.libs.codec.Base58Codec;
public class Base58CodecImpl implements Base58Codec {
public String encode(byte[] input) {
if (input.length == 0) {
return "";
}
byte[] copy = input.clone();
int zeros = 0;
while (zeros < copy.length && copy[zeros] == 0) {
zeros++;
}
char[] encoded = new char[copy.length * 2];
int outputStart = encoded.length;
int inputStart = zeros;
while (inputStart < copy.length) {
int remainder = divmod58(
copy,
inputStart
);
if (copy[inputStart] == 0) {
inputStart++;
}
encoded[--outputStart] = ALPHABET[remainder];
}
while (outputStart < encoded.length && encoded[outputStart] == ENCODED_ZERO) {
outputStart++;
}
while (zeros-- > 0) {
encoded[--outputStart] = ENCODED_ZERO;
}
return new String(
encoded,
outputStart,
encoded.length - outputStart
);
}
private static int divmod58(byte[] number, int startAt) {
int remainder = 0;
for (int i = startAt; i < number.length; i++) {
int digit = number[i] & 0xff;
int temp = remainder * 256 + digit;
number[i] = (byte) (temp / 58);
remainder = temp % 58;
}
return remainder;
}
private static final char[] ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
private static final char ENCODED_ZERO =
ALPHABET[0];
}
@@ -0,0 +1,152 @@
package com.r35157.libs.configuration;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class ConfigurationFormatVersionValidator {
public static int validate(
Path path,
List<String> lines,
int supportedVersion
) {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(lines, "lines");
if (supportedVersion <= 0) {
throw new IllegalArgumentException(
"Supported configuration format version must be a positive integer"
);
}
int firstConfigurationEntryIndex = -1;
List<FormatVersionDeclaration> declarations = new ArrayList<>();
for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) {
String trimmedLine = lines.get(lineIndex).trim();
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
continue;
}
if (firstConfigurationEntryIndex < 0) {
firstConfigurationEntryIndex = lineIndex;
}
if (trimmedLine.startsWith(FORMAT_VERSION_KEY)) {
declarations.add(parseDeclaration(path, trimmedLine, lineIndex));
}
}
if (declarations.isEmpty()) {
throw new IllegalArgumentException(
path + ": Missing " + FORMAT_VERSION_KEY + " declaration. "
+ "It must be the first actual configuration entry."
);
}
if (declarations.size() > 1) {
String lineNumbers = declarations.stream()
.map(declaration -> Integer.toString(declaration.lineIndex() + 1))
.reduce((left, right) -> left + ", " + right)
.orElseThrow();
throw new IllegalArgumentException(
path + ": Duplicate " + FORMAT_VERSION_KEY
+ " declarations on lines " + lineNumbers
);
}
FormatVersionDeclaration declaration = declarations.get(0);
if (declaration.lineIndex() != firstConfigurationEntryIndex) {
throw new IllegalArgumentException(
path + ":" + (declaration.lineIndex() + 1) + ": "
+ FORMAT_VERSION_KEY
+ " must be the first actual configuration entry"
);
}
if (declaration.version() != supportedVersion) {
throw versionMismatch(
path,
declaration.version(),
supportedVersion
);
}
return declaration.lineIndex();
}
private static FormatVersionDeclaration parseDeclaration(
Path path,
String trimmedLine,
int lineIndex
) {
Matcher matcher = FORMAT_VERSION_PATTERN.matcher(trimmedLine);
if (!matcher.matches()) {
throw malformedVersion(path, lineIndex, trimmedLine, null);
}
try {
int version = Integer.parseInt(matcher.group(1));
if (version <= 0) {
throw malformedVersion(path, lineIndex, trimmedLine, null);
}
return new FormatVersionDeclaration(lineIndex, version);
} catch (NumberFormatException exception) {
throw malformedVersion(path, lineIndex, trimmedLine, exception);
}
}
private static IllegalArgumentException malformedVersion(
Path path,
int lineIndex,
String line,
Exception cause
) {
return new IllegalArgumentException(
path + ":" + (lineIndex + 1) + ": Invalid "
+ FORMAT_VERSION_KEY + " declaration '" + line
+ "'. Expected " + FORMAT_VERSION_KEY
+ "=<positive integer>",
cause
);
}
private static IllegalArgumentException versionMismatch(
Path path,
int fileVersion,
int supportedVersion
) {
return new IllegalArgumentException(
"Configuration format version mismatch in " + path + ":\n"
+ "File version: " + fileVersion + "\n"
+ "Supported version: " + supportedVersion + "\n\n"
+ "The configuration file and the running application "
+ "are not compatible.\n"
+ "Update the configuration file or compile and deploy "
+ "the correct application version."
);
}
private record FormatVersionDeclaration(
int lineIndex,
int version
) {
}
private static final String FORMAT_VERSION_KEY = "FORMAT_VERSION";
private static final Pattern FORMAT_VERSION_PATTERN =
Pattern.compile(FORMAT_VERSION_KEY + "=(\\d+)");
private ConfigurationFormatVersionValidator() {
}
}
@@ -0,0 +1,49 @@
package com.r35157.libs.jupiter.perps;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.math.BigDecimal;
/**
* Represents a Jupiter Perps position.
*
* <p>A Jupiter Perps position is represented on-chain by a Solana account owned by
* the Jupiter Perps program. This record contains the public API view of such a
* position.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @param tradedTokenMint the mint address of the token being traded
* @param direction whether the position is long or short
* @param value the amount the position is worth if closed now
* @param size the leveraged amount used to open the contracts
* @param pnl the amount in usd in profit or loss on this position
* @param pnlPercent the profit and loss represented as a percentage
* @param leverage TODO
* @param entryPrice the entry price of the position, denominated in USDC
* @param liquidationPrice the estimated liquidation price of the position, denominated in USDC
* @param marketPrice the current spot price of the token
* @param collateral the amount of USD representing the collateral for this position
* @param totalFees the total amount of fees (TODO: is that including pending/due fees)
* @param borrowFeesDue the amount of USD that is currently outstanding
* @param closeFeePending the fee in USD for closing the account (TODO: multiple accounts - when adding collateral?)
* @param accountRent refundable amount locked for Solana account renting
*/
public record JupiterPerpsPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
ΩSPLMintAddressΩ tradedTokenMint,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ positionValue,
ΩUSDCAmountΩ positionSize,
ΩUSDCAmountΩ pnl,
BigDecimal pnlPercent,
BigDecimal leverage,
ΩUSDCPriceΩ entryPrice,
ΩUSDCPriceΩ liquidationPrice,
ΩUSDCPriceΩ marketPrice,
ΩUSDCAmountΩ collateral,
ΩUSDCAmountΩ totalFees,
ΩUSDCAmountΩ borrowFeesDue,
ΩUSDCAmountΩ closeFeePending,
ΩSolanaAmountΩ accountRent
) {
}
@@ -0,0 +1,9 @@
package com.r35157.libs.jupiter.perps;
/**
* Direction of a Jupiter Perps position.
*/
public enum JupiterPerpsPositionDirection {
LONG,
SHORT
}
@@ -0,0 +1,155 @@
package com.r35157.libs.jupiter.perps;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Set;
/**
* Service for reading Jupiter Perps data and constructing and executing
* Jupiter Perps transactions.
*
* <p>This service does not sign transactions. A compatible wallet is
* required to sign a constructed transaction before it can be executed.</p>
*/
public interface JupiterPerpsService {
/**
* Reads a Jupiter Perps position from a known position account.
*
* <p>The supplied account must be the Solana account that stores the Jupiter Perps
* position state. It is not the wallet address, token account, custody account, pool
* account, or position request account.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @return the decoded Jupiter Perps position
* @throws IOException if the position account could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching
* the position account
*/
@Nullable JupiterPerpsPosition getPosition(@NotNull ΩJupiterPerpsPositionAccountΩ positionAccount)
throws IOException, InterruptedException;
/**
* Finds open Jupiter Perps positions owned by a wallet.
*
* <p>This method returns decoded Jupiter Perps position objects. It does not return
* raw Solana accounts or account ids.</p>
*
* @param owner the wallet address that owns the Jupiter Perps positions
* @return the open Jupiter Perps positions owned by the wallet
* @throws IOException if the position accounts could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching positions
*/
@NotNull Set<JupiterPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ owner)
throws IOException, InterruptedException;
/**
* Constructs an unsigned Solana transaction for opening or increasing
* a Jupiter Perps position.
*
* <p>The transaction is constructed for the supplied wallet, traded asset,
* position direction, USDC collateral amount, position-size delta, and
* slippage limit.</p>
*
* <p>This method does not sign or submit the transaction and therefore does
* not modify blockchain state. The returned transaction must be signed by
* the supplied wallet and submitted separately.</p>
*
* @param owner the wallet that owns or will own the position
* @param tradedTokenMint the mint address of the asset being traded
* @param direction whether the position is long or short
* @param inputTokenAmount the amount of USDC to supply as collateral
* @param sizeUsdDelta the requested increase in position size, denominated in USD
* @param maxSlippageBps the maximum accepted slippage, in basis points
* @return the complete unsigned Solana transaction
* @throws IllegalArgumentException if an amount, mint, or slippage value is invalid
* @throws IOException if Jupiter cannot construct the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull SolanaUnsignedTransaction buildPositionIncreaseTransaction(
@NotNull ΩSolanaWalletIdΩ owner,
@NotNull ΩSPLMintAddressΩ tradedTokenMint,
@NotNull JupiterPerpsPositionDirection direction,
@NotNull ΩUSDCAmountΩ inputTokenAmount,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException;
/**
* Executes a signed Jupiter Perps position-increase transaction.
*
* <p>The supplied transaction must previously have been constructed by
* {@link #buildPositionIncreaseTransaction} and signed by its required
* Solana wallet.</p>
*
* <p>Unlike the build method, this operation submits the transaction and
* may therefore modify blockchain state.</p>
*
* @param transaction the complete signed position-increase transaction
* @return the Solana transaction signature
* @throws IllegalArgumentException if the transaction is missing or blank
* @throws IOException if Jupiter rejects the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull
ΩSolanaTransactionSignatureΩ executePositionIncreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
/**
* Constructs an unsigned Solana transaction for decreasing
* an existing Jupiter Perps position.
*
* <p>This method does not sign or submit the transaction and therefore
* does not modify blockchain state. The returned transaction must be
* signed by the position owner's wallet and submitted separately.</p>
*
* @param positionAccount the Jupiter Perps position to decrease
* @param receiveTokenMint the mint address of the token to receive
* @param sizeUsdDelta the requested decrease in position size, denominated in USD
* @param maxSlippageBps the maximum accepted slippage, in basis points
* @return the complete unsigned Solana transaction
* @throws IllegalArgumentException if an amount, mint, or slippage value is invalid
* @throws IOException if Jupiter cannot construct the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull SolanaUnsignedTransaction buildPositionDecreaseTransaction(
@NotNull ΩJupiterPerpsPositionAccountΩ positionAccount,
@NotNull ΩSPLMintAddressΩ receiveTokenMint,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException;
/**
* Executes a signed Jupiter Perps position-decrease transaction.
*
* <p>The supplied transaction must previously have been constructed by
* {@link #buildPositionDecreaseTransaction} and signed by its required
* Solana wallet.</p>
*
* <p>Unlike the build method, this operation submits the transaction and
* may therefore modify blockchain state.</p>
*
* @param transaction the complete signed position-decrease transaction
* @return the Solana transaction signature
* @throws IllegalArgumentException if the transaction is missing or blank
* @throws IOException if Jupiter rejects the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull
ΩSolanaTransactionSignatureΩ executePositionDecreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
}
@@ -0,0 +1,64 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.solana.SolanaAccountInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.math.BigInteger;
import java.util.Base64;
import static com.r35157.libs.jupiter.perps.impl.anchoridl.DecodingPrimitives.*;
class AnchorIdlJupiterPerpsCustodyDecoder {
@Nullable JupiterCustodyAccountInfo decode(
@NotNull ΩJupiterCustodyAccountInfoEncodedΩ encodedCustodyAccountInfo
) {
ΩJupiterPerpsPostionIdlΩ idl = decodeIdl(encodedCustodyAccountInfo);
JupiterCustodyAccountInfo cai = new JupiterCustodyAccountInfo(
decodePublicKey(idl, MINT_OFFSET),
decodeCumulativeInterestRate(idl)
);
return cai;
}
private ΩJupiterPerpsCustodyAccountIdlΩ decodeIdl(ΩJupiterCustodyAccountInfoEncodedΩ encodedCustodyAccountInfo) {
ΩJupiterPerpsCustodyAccountIdlΩ idl = base64.decode(encodedCustodyAccountInfo.data());
if (idl.length < CUMULATIVE_INTEREST_RATE_OFFSET + U128_LENGTH) {
String errMsg = "Jupiter Perps custody account data is too short: " + idl.length;
throw new IllegalArgumentException(errMsg);
}
return idl;
}
private @NotNull ΩJupiterCumulativeInterestRateΩ decodeCumulativeInterestRate(ΩJupiterPerpsCustodyAccountIdlΩ idl) {
byte[] bytes = readU128(idl, CUMULATIVE_INTEREST_RATE_OFFSET);
ΩJupiterCumulativeInterestRateΩ cumulativeInterestRate = new ΩJupiterCumulativeInterestRateΩ(1, bytes);
return cumulativeInterestRate;
}
// Offsets:
// 8 discriminator
// +32 pool
// +32 mint
// +32 token_account
// +1 decimals
// +1 is_stable
// +45 oracle
// +48 pricing
// +7 permissions
// +8 target_ratio_bps
// +48 assets
private static final int DISCRIMINATOR_LENGTH = 8;
private static final int MINT_OFFSET = DISCRIMINATOR_LENGTH + PUBLIC_KEY_LENGTH;
private static final int FUNDING_RATE_STATE_OFFSET = 262;
private static final int CUMULATIVE_INTEREST_RATE_OFFSET = FUNDING_RATE_STATE_OFFSET;
private static final Base64.Decoder base64 = Base64.getDecoder();
}
@@ -0,0 +1,141 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import com.r35157.libs.solana.SolanaAccountInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Base64;
import static com.r35157.libs.jupiter.perps.impl.anchoridl.DecodingPrimitives.*;
class AnchorIdlJupiterPerpsPositionDecoder {
@Nullable JupiterPerpsPositionInfo decode(
@NotNull ΩJupiterPerpsPositionInfoEncodedΩ encodedPerpsPositionInfo
) {
ΩJupiterPerpsPostionIdlΩ idl = decodeIdl(encodedPerpsPositionInfo);
JupiterPerpsPositionDirection direction = decodeDirection(idl);
ΩjupiterRawEntryPriceUsdΩ rawEntryPrice = ByteBuffer
.wrap(idl, PRICE_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCPriceΩ entryPrice = ΩUSDCPriceΩ
.valueOf(rawEntryPrice)
.movePointLeft(6);
ΩjupiterRawCollateralUsdΩ rawCollateralUsd = ByteBuffer
.wrap(idl, COLLATERAL_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ collateral = ΩUSDCAmountΩ
.valueOf(rawCollateralUsd)
.movePointLeft(6);
ΩjupiterRawSizeUsdΩ rawSizeUsd = ByteBuffer
.wrap(idl, SIZE_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ positionSize = ΩUSDCAmountΩ
.valueOf(rawSizeUsd)
.movePointLeft(6);
ΩJupiterPerpsCustodyAccountAddressΩ custodyAccountAddress =
decodePublicKey(idl, CUSTODY_OFFSET);
ΩJupiterPerpsCollateralCustodyAccountAddressΩ collateralCustodyAccountAddress =
decodePublicKey(idl, COLLATERAL_CUSTODY_OFFSET);
ΩJupiterCumulativeInterestSnapshotΩ cumulativeInterestSnapshot = decodeCumulativeInterestSnapshot(idl);
JupiterPerpsPositionInfo posInfo = new JupiterPerpsPositionInfo(
entryPrice,
direction,
positionSize,
collateral,
custodyAccountAddress,
collateralCustodyAccountAddress,
cumulativeInterestSnapshot
);
return posInfo;
}
private @NotNull ΩJupiterCumulativeInterestSnapshotΩ decodeCumulativeInterestSnapshot(ΩJupiterPerpsPostionIdlΩ idl) {
byte[] bytes = readU128(idl, CUMULATIVE_INTEREST_SNAPSHOT_OFFSET);
ΩJupiterCumulativeInterestSnapshotΩ cumulativeInterestSnapshot
= new ΩJupiterCumulativeInterestSnapshotΩ(1, bytes);
return cumulativeInterestSnapshot;
}
ΩJupiterPerpsCustodyAccountAddressΩ decodeCustodyAccountAddress(SolanaAccountInfo accountInfo) {
byte[] data = base64.decode(accountInfo.data());
if (data.length < CUSTODY_OFFSET + PUBLIC_KEY_LENGTH) {
String errorMsg = "Jupiter Perps position account data is too short: " + data.length;
throw new IllegalArgumentException(errorMsg);
}
ΩJupiterPerpsCustodyAccountAddressΩ addr = decodePublicKey(data, CUSTODY_OFFSET);
return addr;
}
private ΩJupiterPerpsPostionIdlΩ decodeIdl(ΩJupiterPerpsPositionInfoEncodedΩ encodedPerpsPositionInfo) {
ΩJupiterPerpsPostionIdlΩ idl = base64.decode(encodedPerpsPositionInfo.data());
// TODO: Check size correctly, as in AnchorIdlJupiterPerpsCustodyDecoder.decodeIdl()
if (idl.length == 0) {
String errorMsg = "Jupiter Perps position account data is too short: " + idl.length;
throw new IllegalArgumentException(errorMsg);
}
return idl;
}
private JupiterPerpsPositionDirection decodeDirection(ΩJupiterPerpsPostionIdlΩ idl) {
byte rawSide = idl[SIDE_OFFSET];
// 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 static final int ANCHOR_DISCRIMINATOR_LENGTH = 8;
private static final int PUBLIC_KEY_LENGTH = 32;
private static final int I64_LENGTH = 8;
private static final int SIDE_ENUM_LENGTH = 1;
private static final int U64_LENGTH = 8;
private static final int OWNER_OFFSET = ANCHOR_DISCRIMINATOR_LENGTH;
private static final int POOL_OFFSET = OWNER_OFFSET + PUBLIC_KEY_LENGTH;
private static final int CUSTODY_OFFSET = POOL_OFFSET + PUBLIC_KEY_LENGTH;
private static final int COLLATERAL_CUSTODY_OFFSET = CUSTODY_OFFSET + PUBLIC_KEY_LENGTH;
private static final int OPEN_TIME_OFFSET = COLLATERAL_CUSTODY_OFFSET + PUBLIC_KEY_LENGTH;
private static final int UPDATE_TIME_OFFSET = OPEN_TIME_OFFSET + I64_LENGTH;
private static final int SIDE_OFFSET = UPDATE_TIME_OFFSET + I64_LENGTH;
private static final int PRICE_OFFSET = SIDE_OFFSET + SIDE_ENUM_LENGTH;
private static final int SIZE_USD_OFFSET = PRICE_OFFSET + U64_LENGTH;
private static final int COLLATERAL_USD_OFFSET = SIZE_USD_OFFSET + U64_LENGTH;
private static final int REALISED_PNL_USD_OFFSET = COLLATERAL_USD_OFFSET + U64_LENGTH;
private static final int CUMULATIVE_INTEREST_SNAPSHOT_OFFSET = REALISED_PNL_USD_OFFSET + I64_LENGTH;
private static final Base64.Decoder base64 = Base64.getDecoder();
}
@@ -0,0 +1,767 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.protocol.DecreasePositionRequest;
import com.r35157.libs.jupiter.perps.protocol.DecreasePositionResponse;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionRequest;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionResponse;
import com.r35157.libs.jupiter.perps.protocol.IncreasePositionRequest;
import com.r35157.libs.jupiter.perps.protocol.IncreasePositionResponse;
import com.r35157.libs.jupiter.perps.protocol.TransactionMetadata;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
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.HashSet;
import java.util.Set;
import static java.math.BigDecimal.ZERO;
public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
public AnchorIdlJupiterPerpsServiceImpl(
SolanaBlockChain solanaBlockChain
) {
this.solanaBlockChain = solanaBlockChain;
this.positionDecoder = new AnchorIdlJupiterPerpsPositionDecoder();
this.custodyDecoder = new AnchorIdlJupiterPerpsCustodyDecoder();
}
@Override
public @Nullable JupiterPerpsPosition getPosition(@NotNull Ω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
);
}
JupiterPerpsPositionInfo perpsPositionInfo = positionDecoder.decode(accountInfo);
if (perpsPositionInfo == null) {
return null;
}
JupiterPerpsPosition pos = buildPosition(
positionAccount,
perpsPositionInfo
);
return pos;
}
@Override
public @NotNull Set<JupiterPerpsPosition> getOpenPositions(@NotNull Ω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) {
System.out.println(" Found " + accountInfos.size() + " postion candidates...");
ΩSolanaProgramIdΩ programId = accountInfo.owner();
ΩJupiterPerpsPositionAccountΩ positionAccount = accountInfo.address();
if (!JUPITER_PERPS_PROGRAM_ID.equals(programId)) {
String errorMsg = "Account '" + positionAccount + "' is not owned by Jupiter Perps program '" +
programId + "'";
throw new IllegalArgumentException(errorMsg);
}
JupiterPerpsPositionInfo perpsPositionInfo = positionDecoder.decode(accountInfo);
if (perpsPositionInfo == null || perpsPositionInfo.positionSize().compareTo(ZERO) == 0) {
continue;
}
JupiterPerpsPosition pos = buildPosition(
positionAccount,
perpsPositionInfo
);
positions.add(pos);
}
return Set.copyOf(positions);
}
@Override
public @NotNull SolanaUnsignedTransaction buildPositionIncreaseTransaction(
@NotNull ΩSolanaWalletIdΩ owner,
@NotNull ΩSPLMintAddressΩ tradedTokenMint,
@NotNull JupiterPerpsPositionDirection direction,
@NotNull ΩUSDCAmountΩ inputTokenAmount,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException {
IncreasePositionRequest request = buildIncreasePositionRequest(
owner,
tradedTokenMint,
direction,
inputTokenAmount,
sizeUsdDelta,
maxSlippageBps
);
IncreasePositionResponse response = requestPositionIncrease(request);
TransactionMetadata metadata = response.txMetadata();
if (response.serializedTxBase64() == null
|| response.serializedTxBase64().isBlank()) {
throw new IOException(
"Jupiter Perps response did not contain a serialized transaction"
);
}
if (metadata == null) {
throw new IOException(
"Jupiter Perps response did not contain transaction metadata"
);
}
try {
SolanaUnsignedTransaction utx = new SolanaUnsignedTransaction(
response.serializedTxBase64(),
metadata.blockhash(),
Long.parseLong(metadata.lastValidBlockHeight())
);
return utx;
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ
executePositionIncreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException {
if (transaction.serializedTransaction() == null
|| transaction.serializedTransaction().isBlank()) {
throw new IllegalArgumentException(
"Signed transaction must not be blank"
);
}
ExecuteTransactionRequest request =
new ExecuteTransactionRequest(
"increase-position",
transaction.serializedTransaction()
);
ExecuteTransactionResponse response =
requestTransactionExecution(request);
if (response.txid() == null || response.txid().isBlank()) {
throw new IOException(
"Jupiter transaction execution response did not "
+ "contain a transaction signature"
);
}
return response.txid();
}
@Override
public @NotNull SolanaUnsignedTransaction buildPositionDecreaseTransaction(
@NotNull ΩJupiterPerpsPositionAccountΩ positionAccount,
@NotNull ΩSPLMintAddressΩ receiveTokenMint,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException {
DecreasePositionRequest request = buildDecreasePositionRequest(
positionAccount,
receiveTokenMint,
sizeUsdDelta,
maxSlippageBps
);
DecreasePositionResponse response =
requestPositionDecrease(request);
TransactionMetadata metadata = response.txMetadata();
if (response.serializedTxBase64() == null
|| response.serializedTxBase64().isBlank()) {
throw new IOException(
"Jupiter Perps response did not contain a serialized transaction"
);
}
if (metadata == null) {
throw new IOException(
"Jupiter Perps response did not contain transaction metadata"
);
}
try {
return new SolanaUnsignedTransaction(
response.serializedTxBase64(),
metadata.blockhash(),
Long.parseLong(metadata.lastValidBlockHeight())
);
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ
executePositionDecreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException {
if (transaction.serializedTransaction() == null
|| transaction.serializedTransaction().isBlank()) {
throw new IllegalArgumentException(
"Signed transaction must not be blank"
);
}
ExecuteTransactionRequest request =
new ExecuteTransactionRequest(
"decrease-position",
transaction.serializedTransaction()
);
ExecuteTransactionResponse response =
requestTransactionExecution(request);
if (response.txid() == null || response.txid().isBlank()) {
throw new IOException(
"Jupiter transaction execution response did not "
+ "contain a transaction signature"
);
}
return response.txid();
}
private static DecreasePositionRequest buildDecreasePositionRequest(
ΩJupiterPerpsPositionAccountΩ positionAccount,
ΩSPLMintAddressΩ receiveTokenMint,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
if (positionAccount.isBlank()) {
throw new IllegalArgumentException(
"Position account must not be blank"
);
}
if (sizeUsdDelta.signum() <= 0) {
throw new IllegalArgumentException(
"Position size delta must be greater than zero: "
+ sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > BASIS_POINTS_DIVISOR) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ BASIS_POINTS_DIVISOR
+ " basis points: "
+ maxSlippageBps
);
}
return new DecreasePositionRequest(
positionAccount,
toProtocolReceiveToken(receiveTokenMint),
toMicroAmount(sizeUsdDelta),
Integer.toString(maxSlippageBps)
);
}
private JupiterPerpsPosition buildPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
JupiterPerpsPositionInfo perpsPositionInfo
) throws IOException, InterruptedException {
ΩJupiterPerpsCustodyAccountAddressΩ custodyAccountAddress = perpsPositionInfo.custodyAccountAddress();
System.out.print(" Getting account info for: " + custodyAccountAddress + "...");
ΩJupiterCustodyAccountInfoEncodedΩ encodedCustodyAccountInfo = solanaBlockChain.getAccountInfo(custodyAccountAddress);
System.out.println(" Done.");
if (encodedCustodyAccountInfo == null) {
String errorMsg = "Jupiter Perps custody account does not exist: " + custodyAccountAddress;
throw new IllegalArgumentException(errorMsg);
}
JupiterCustodyAccountInfo custodyAccountInfo = custodyDecoder.decode(encodedCustodyAccountInfo);
ΩSPLMintAddressΩ mintAddress = custodyAccountInfo.mintAddress();
ΩJupiterPerpsCollateralCustodyAccountAddressΩ collateralCustodyAccountAddress =
perpsPositionInfo.collateralCustodyAccountAddress();
System.out.print(" Getting collateral custody account Info for: " + custodyAccountAddress + "...");
ΩJupiterCustodyAccountInfoEncodedΩ encodedCollateralCustodyAccountInfo =
solanaBlockChain.getAccountInfo(collateralCustodyAccountAddress);
System.out.println(" Done.");
if (encodedCollateralCustodyAccountInfo == null) {
String errorMsg = "Jupiter Perps collateral custody account does not exist: " + collateralCustodyAccountAddress;
throw new IllegalArgumentException(errorMsg);
}
System.out.print(" Calculating fiels in position...");
JupiterCustodyAccountInfo collateralCustodyAccountInfo =
custodyDecoder.decode(encodedCollateralCustodyAccountInfo);
ΩJupiterPostionInterestΩ positionInterest = collateralCustodyAccountInfo.currentCumulativeInterestRate()
.subtract(perpsPositionInfo.cumulativeInterestSnapshot());
ΩUSDCAmountΩ positionSize = perpsPositionInfo.positionSize();
ΩUSDCAmountΩ borrowFeesDue = calculateBorrowFeesDue(positionInterest, positionSize);
ΩUSDCAmountΩ closeFeePending = calculateCloseFeePending(positionSize);
ΩUSDCPriceΩ liquidationPrice = calculateLiquidationPrice(
perpsPositionInfo.direction(),
perpsPositionInfo.entryPrice(),
perpsPositionInfo.collateral(),
closeFeePending,
borrowFeesDue,
positionSize
);
ΩUSDCAmountΩ value = ZERO; // TODO - Dummy
ΩUSDCAmountΩ pnl = ZERO; // TODO - Dummy
BigDecimal pnlPercent = ZERO; // TODO - Dummy
BigDecimal leverage = ZERO; // TODO - Dummy
ΩUSDCPriceΩ marketPrice = ZERO; // TODO - Dummy
ΩUSDCAmountΩ totalFees = ZERO; // TODO - Dummy
ΩSolanaAmountΩ accountRent = new MoneyAmount( // TODO - Dummy
ZERO,
WellKnownCurrencyTypes.SOLANA.getCurrencyType()
);
JupiterPerpsPosition pos = new JupiterPerpsPosition(
positionAccount,
mintAddress,
perpsPositionInfo.direction(),
value,
positionSize,
pnl,
pnlPercent,
leverage,
perpsPositionInfo.entryPrice(),
liquidationPrice,
marketPrice,
perpsPositionInfo.collateral(),
totalFees,
borrowFeesDue,
closeFeePending,
accountRent
);
System.out.println(" Done.");
return pos;
}
private static ΩUSDCAmountΩ calculateBorrowFeesDue(
ΩJupiterPostionInterestΩ positionInterest,
ΩUSDCAmountΩ size
) {
ΩUSDCAmountΩ fees = new BigDecimal(positionInterest)
.multiply(size)
.divide(
BigDecimal.valueOf(RATE_POWER),
USDC_DECIMALS,
RoundingMode.CEILING
);
return fees;
}
private static ΩUSDCAmountΩ calculateCloseFeePending(ΩUSDCAmountΩ size) {
ΩUSDCAmountΩ fee = size
.multiply(BigDecimal.valueOf(CLOSE_FEE_BPS))
.divide(
BigDecimal.valueOf(BASIS_POINTS_DIVISOR),
USDC_DECIMALS,
RoundingMode.CEILING
);
return fee;
}
private static ΩUSDCPriceΩ calculateLiquidationPrice(
JupiterPerpsPositionDirection direction,
ΩUSDCPriceΩ price,
ΩUSDCAmountΩ collateral,
ΩUSDCAmountΩ closeFeePending,
ΩUSDCAmountΩ borrowFeesDue,
ΩUSDCAmountΩ positionSize
) {
ΩUSDCAmountΩ minimumCollateral = positionSize.divide(
BigDecimal.valueOf(MAX_LEVERAGE),
USDC_DECIMALS,
RoundingMode.CEILING
);
ΩUSDCAmountΩ maxLoss = minimumCollateral
.add(closeFeePending)
.add(borrowFeesDue);
ΩUSDCAmountΩ maxPriceDiffAmount = maxLoss
.subtract(collateral)
.abs();
ΩUSDCPriceΩ maxPriceDiff = maxPriceDiffAmount
.multiply(price)
.divide(
positionSize,
USDC_DECIMALS,
RoundingMode.CEILING
);
boolean maxLossExceedsCollateral = maxLoss.compareTo(collateral) > 0;
if (direction == JupiterPerpsPositionDirection.LONG) {
if (maxLossExceedsCollateral) {
return price.add(maxPriceDiff);
}
return price.subtract(maxPriceDiff);
}
if (direction == JupiterPerpsPositionDirection.SHORT) {
if (maxLossExceedsCollateral) {
return price.subtract(maxPriceDiff);
}
return price.add(maxPriceDiff);
}
throw new IllegalArgumentException("Unsupported Jupiter Perps position direction: " + direction);
}
private static IncreasePositionRequest buildIncreasePositionRequest(
ΩSolanaWalletIdΩ owner,
ΩSPLMintAddressΩ tradedTokenMint,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ inputTokenAmount,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
if (inputTokenAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Input token amount must be greater than zero: " + inputTokenAmount
);
}
if (sizeUsdDelta.signum() < 0) {
throw new IllegalArgumentException(
"Position size delta must not be negative: " + sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > BASIS_POINTS_DIVISOR) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ BASIS_POINTS_DIVISOR
+ " basis points: "
+ maxSlippageBps
);
}
IncreasePositionRequest request = new IncreasePositionRequest(
toProtocolAsset(tradedTokenMint),
"USDC",
toMicroAmount(inputTokenAmount),
toProtocolSide(direction),
Integer.toString(maxSlippageBps),
toMicroAmount(sizeUsdDelta),
owner
);
return request;
}
private static String toProtocolAsset(ΩSPLMintAddressΩ tradedTokenMint) {
if (SOL_MINT.equals(tradedTokenMint)) {
return "SOL";
}
if (BTC_MINT.equals(tradedTokenMint)) {
return "BTC";
}
if (ETH_MINT.equals(tradedTokenMint)) {
return "ETH";
}
throw new IllegalArgumentException(
"Unsupported Jupiter Perps traded-token mint: " + tradedTokenMint
);
}
private static String toProtocolReceiveToken(
ΩSPLMintAddressΩ receiveTokenMint
) {
if (SOL_MINT.equals(receiveTokenMint)) {
return "SOL";
}
if (BTC_MINT.equals(receiveTokenMint)) {
return "BTC";
}
if (ETH_MINT.equals(receiveTokenMint)) {
return "ETH";
}
if (USDC_MINT.equals(receiveTokenMint)) {
return "USDC";
}
throw new IllegalArgumentException(
"Unsupported Jupiter Perps receive-token mint: "
+ receiveTokenMint
);
}
private static String toProtocolSide(
JupiterPerpsPositionDirection direction
) {
return switch (direction) {
case LONG -> "long";
case SHORT -> "short";
};
}
private static String toMicroAmount(BigDecimal amount) {
return amount
.movePointRight(USDC_DECIMALS)
.toBigIntegerExact()
.toString();
}
private static IncreasePositionResponse requestPositionIncrease(
IncreasePositionRequest request
) throws IOException, InterruptedException {
String requestJson = OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(INCREASE_POSITION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> httpResponse = HTTP_CLIENT.send(
httpRequest,
HttpResponse.BodyHandlers.ofString()
);
if (httpResponse.statusCode() < 200 || httpResponse.statusCode() >= 300) {
throw new IOException(
"Jupiter Perps increase-position request failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
IncreasePositionResponse response = OBJECT_MAPPER.readValue(
httpResponse.body(),
IncreasePositionResponse.class
);
TransactionMetadata metadata = response.txMetadata();
if (metadata.blockhash() == null || metadata.blockhash().isBlank()) {
String errorMsg = "Jupiter Perps response did not contain a blockhash";
throw new IOException(errorMsg);
}
long lastValidBlockHeight;
try {
lastValidBlockHeight =
Long.parseLong(metadata.lastValidBlockHeight());
} catch (NumberFormatException e) {
String errorMsg = "Invalid lastValidBlockHeight returned by Jupiter: " + metadata.lastValidBlockHeight();
throw new IOException(errorMsg, e);
}
if (lastValidBlockHeight <= 0) {
String errorMsg = "Invalid lastValidBlockHeight returned by Jupiter: " + lastValidBlockHeight;
throw new IOException(errorMsg);
}
return response;
}
private static DecreasePositionResponse requestPositionDecrease(
DecreasePositionRequest request
) throws IOException, InterruptedException {
String requestJson = OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(DECREASE_POSITION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> httpResponse = HTTP_CLIENT.send(
httpRequest,
HttpResponse.BodyHandlers.ofString()
);
if (httpResponse.statusCode() < 200
|| httpResponse.statusCode() >= 300) {
throw new IOException(
"Jupiter Perps decrease-position request failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
DecreasePositionResponse response = OBJECT_MAPPER.readValue(
httpResponse.body(),
DecreasePositionResponse.class
);
TransactionMetadata metadata = response.txMetadata();
if (metadata == null) {
throw new IOException(
"Jupiter Perps response did not contain transaction metadata"
);
}
if (metadata.blockhash() == null || metadata.blockhash().isBlank()) {
throw new IOException(
"Jupiter Perps response did not contain a blockhash"
);
}
long lastValidBlockHeight;
try {
lastValidBlockHeight =
Long.parseLong(metadata.lastValidBlockHeight());
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
if (lastValidBlockHeight <= 0) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ lastValidBlockHeight
);
}
return response;
}
private static ExecuteTransactionResponse requestTransactionExecution(
ExecuteTransactionRequest request
) throws IOException, InterruptedException {
String requestJson =
OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(EXECUTE_TRANSACTION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> httpResponse = HTTP_CLIENT.send(
httpRequest,
HttpResponse.BodyHandlers.ofString()
);
if (httpResponse.statusCode() < 200
|| httpResponse.statusCode() >= 300) {
throw new IOException(
"Jupiter transaction execution failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
return OBJECT_MAPPER.readValue(
httpResponse.body(),
ExecuteTransactionResponse.class
);
}
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
private static final ΩSPLMintAddressΩ ETH_MINT = "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs";
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
private static final ΩSPLMintAddressΩ USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
private static final URI EXECUTE_TRANSACTION_ENDPOINT =
URI.create(
"https://perps-api.jup.ag/v2/transaction/execute"
);
private static final URI DECREASE_POSITION_ENDPOINT =
URI.create(
"https://perps-api.jup.ag/v2/positions/decrease"
);
private static final ΩJupiterPerpsProgramIdΩ JUPITER_PERPS_PROGRAM_ID = "PERPHjGBqRHArX4DySjwM6UJHiR3sWAatqfdBS2qQJu";
private static final URI INCREASE_POSITION_ENDPOINT = URI.create("https://perps-api.jup.ag/v2/positions/increase");
private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final long RATE_POWER = 1_000_000_000L;
private static final int CLOSE_FEE_BPS = 6;
private static final int BASIS_POINTS_DIVISOR = 10_000;
private static final int POSITION_OWNER_OFFSET = 8;
private static final int MAX_LEVERAGE = 500;
private static final int USDC_DECIMALS = 6;
private final SolanaBlockChain solanaBlockChain;
private final AnchorIdlJupiterPerpsPositionDecoder positionDecoder;
private final AnchorIdlJupiterPerpsCustodyDecoder custodyDecoder;
}
@@ -0,0 +1,40 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.codec.Base58Codec;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import org.jetbrains.annotations.NotNull;
import java.math.BigInteger;
public class DecodingPrimitives {
public static byte[] readU128(byte[] data, int offset) {
byte[] bytes = new byte[U128_LENGTH];
for (int i = 0; i < U128_LENGTH; i++) {
bytes[i] = data[offset + U128_LENGTH - 1 - i];
}
return bytes;
}
public static @NotNull ΩSolanaAddressΩ decodePublicKey(ΩJupiterPerpsPostionIdlΩ data, int offset) {
byte[] bytes = new byte[PUBLIC_KEY_LENGTH];
System.arraycopy(
data,
offset,
bytes,
0,
PUBLIC_KEY_LENGTH
);
ΩSolanaAddressΩ addr = base58.encode(bytes);
return addr;
}
public static final int U128_LENGTH = 16;
public static final int PUBLIC_KEY_LENGTH = 32;
private static final Base58Codec base58 = new Base58CodecImpl();
}
@@ -0,0 +1,9 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import java.math.BigInteger;
public record JupiterCustodyAccountInfo(
ΩSPLMintAddressΩ mintAddress,
ΩJupiterCumulativeInterestRateΩ currentCumulativeInterestRate
) {
}
@@ -0,0 +1,17 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import java.math.BigDecimal;
import java.math.BigInteger;
public record JupiterPerpsPositionInfo(
ΩUSDCPriceΩ entryPrice,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ positionSize,
ΩUSDCAmountΩ collateral,
ΩJupiterPerpsCustodyAccountAddressΩ custodyAccountAddress,
ΩJupiterPerpsCollateralCustodyAccountAddressΩ collateralCustodyAccountAddress,
ΩJupiterCumulativeInterestSnapshotΩ cumulativeInterestSnapshot
) {
}
@@ -0,0 +1,20 @@
package com.r35157.libs.jupiter.perps.protocol;
/**
* Jupiter Perps protocol request for constructing an unsigned
* position-decrease transaction.
*
* @param positionPubkey the Solana account address of the position to decrease
* @param receiveToken the symbol of the token to receive
* @param sizeUsdDelta the requested position-size decrease in micro-USD,
* encoded as a decimal string
* @param maxSlippageBps the maximum accepted slippage in basis points,
* encoded as a decimal string
*/
public record DecreasePositionRequest(
String positionPubkey,
String receiveToken,
String sizeUsdDelta,
String maxSlippageBps
) {
}
@@ -0,0 +1,24 @@
package com.r35157.libs.jupiter.perps.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Relevant fields returned by Jupiter after constructing an unsigned
* position-decrease transaction.
*
* <p>Additional response fields that are not required by the current
* implementation are ignored during decoding.</p>
*
* @param positionPubkey the Solana account address of the decreased position
* @param serializedTxBase64 the complete unsigned Solana transaction,
* encoded as Base64
* @param txMetadata metadata describing the transaction's blockhash
* and validity period
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record DecreasePositionResponse(
String positionPubkey,
String serializedTxBase64,
TransactionMetadata txMetadata
) {
}
@@ -0,0 +1,14 @@
package com.r35157.libs.jupiter.perps.protocol;
/**
* Jupiter Perps protocol request for executing a signed transaction.
*
* @param action the Jupiter transaction action
* @param serializedTxBase64 the complete signed Solana transaction,
* encoded as Base64
*/
public record ExecuteTransactionRequest(
String action,
String serializedTxBase64
) {
}

Some files were not shown because too many files have changed in this diff Show More