Compare commits
130
Commits
0.0.0
...
12843e1501
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12843e1501 | ||
|
|
4cc14ceef5 | ||
|
|
bf492c9db1 | ||
|
|
f5c449fe23 | ||
|
|
5afac819d8 | ||
|
|
8f2e43dc8a | ||
|
|
f42ac30b3f | ||
|
|
451b5f252c | ||
|
|
0b6b0b6974 | ||
|
|
d4ced47012 | ||
|
|
3ba92bae4b | ||
|
|
5d940d37cc | ||
|
|
d4e6d3438b | ||
|
|
bbba18dcae | ||
|
|
9a770e1801 | ||
|
|
5b50e67d8e | ||
|
|
d2f28af1bc | ||
|
|
e59aff95b8 | ||
|
|
7266a8c2dd | ||
|
|
d33dff7091 | ||
|
|
6a352e90e9 | ||
|
|
2e61011a68 | ||
|
|
ba376b6688 | ||
|
|
a0d0740f10 | ||
|
|
776f626abb | ||
|
|
228698e3e4 | ||
|
|
0166d6bcf7 | ||
|
|
87ef7c67ae | ||
|
|
e0713c9121 | ||
|
|
5c9f6251e5 | ||
|
|
f3e4947086 | ||
|
|
db20e68e93 | ||
|
|
eda12a12fd | ||
|
|
b2278b12d5 | ||
|
|
27c161bb6b | ||
|
|
f6afbd322b | ||
|
|
3e82b49f0d | ||
|
|
397028fe37 | ||
|
|
2c57b4cdb5 | ||
|
|
724b2d6eb6 | ||
|
|
aded93839b | ||
|
|
16fd99c80f | ||
|
|
e5b60f9db2 | ||
|
|
5c8da8bf8a | ||
|
|
ff96949fdc | ||
|
|
d4cac8d0bd | ||
|
|
82331e068a | ||
|
|
8f86773786 | ||
|
|
041909ae6a | ||
|
|
b21b0b081d | ||
|
|
0997c0558e | ||
|
|
eda11ed459 | ||
|
|
c05509bdbe | ||
|
|
32a2667e59 | ||
|
|
b8da2fb0f5 | ||
|
|
57875d1eeb | ||
|
|
23b644171b | ||
|
|
265a30bfa3 | ||
|
|
9e41499501 | ||
|
|
c97469a9fe | ||
|
|
5375309b08 | ||
|
|
5063a6e523 | ||
|
|
cdcbd331ec | ||
|
|
7d96848464 | ||
|
|
7075429efa | ||
|
|
329e73f3f8 | ||
|
|
ee9e4d9194 | ||
|
|
b7490b8482 | ||
|
|
1546db773f | ||
|
|
ecb0b265b5 | ||
|
|
d18190964c | ||
|
|
832785ed4d | ||
|
|
f874820aa3 | ||
|
|
fb1327f62e | ||
|
|
2445fe2722 | ||
|
|
794904bcf1 | ||
|
|
c57417288a | ||
|
|
3a17604491 | ||
|
|
a63f8776f2 | ||
|
|
e2fbc1a219 | ||
|
|
d98c5fff34 | ||
|
|
c0eca5a56d | ||
|
|
2ad0063c61 | ||
|
|
b083b924b5 | ||
|
|
aa7b3cf9d4 | ||
|
|
ff5f1456f4 | ||
|
|
4e1ba0edc1 | ||
|
|
97f0fdaedb | ||
|
|
f634336290 | ||
|
|
d1445f461e | ||
|
|
6bf35de5c6 | ||
|
|
b6fa7d5240 | ||
|
|
aff5fda1e1 | ||
|
|
aba9344b7e | ||
|
|
034f1d969d | ||
|
|
fe265aa523 | ||
|
|
3f3eb82fe6 | ||
|
|
62e8563cbd | ||
|
|
bb0a43a31f | ||
|
|
3e047ccf2d | ||
|
|
b51db22ad1 | ||
|
|
bff1fd354a | ||
|
|
115d9ca0ee | ||
|
|
179840d0ab | ||
|
|
c0cc65751b | ||
|
|
cead475afd | ||
|
|
cf73e791db | ||
|
|
578e2cf31b | ||
|
|
35d587ef98 | ||
|
|
b087052f59 | ||
|
|
04b3fec8f2 | ||
|
|
f9ed9ea00b | ||
|
|
af473f2408 | ||
|
|
e63e20955a | ||
|
|
677ff96616 | ||
|
|
d29014d806 | ||
|
|
6add00d399 | ||
|
|
8348094736 | ||
|
|
fec65a0e36 | ||
|
|
89b6a4f28d | ||
|
|
4e94320e81 | ||
|
|
2aee555d3c | ||
|
|
c22dfb4e05 | ||
|
|
b0f5430fe0 | ||
|
|
8ab1947a08 | ||
|
|
8b242c2820 | ||
|
|
938894ea26 | ||
|
|
7d113b4522 | ||
|
|
642b531553 | ||
|
|
88f147d46e |
@@ -3,3 +3,8 @@
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
logs/*.log
|
||||
logs/*.log.gz
|
||||
conf/*.conf
|
||||
conf/*.xml
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
./gradlew clean ; rm -rf build
|
||||
|
||||
Executable
+20
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<RollingFile name="File"
|
||||
fileName="logs/NenjimHub.log"
|
||||
filePattern="logs/NenjimHub-%d{yyyy-MM-dd}-%i.log.gz">
|
||||
<PatternLayout pattern="%d{ISO8601} %-5p %c - %m%n"/>
|
||||
|
||||
<Policies>
|
||||
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
||||
<SizeBasedTriggeringPolicy size="50 MB"/>
|
||||
</Policies>
|
||||
|
||||
<DefaultRolloverStrategy max="14"/>
|
||||
</RollingFile>
|
||||
</Appenders>
|
||||
|
||||
<Loggers>
|
||||
<Root level="info">
|
||||
<AppenderRef ref="File"/>
|
||||
</Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
jars=(libs/*.jar)
|
||||
if (( ${#jars[@]} == 0 )); then
|
||||
echo "ERROR: No JARs found in libs/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLASSPATH=$(IFS=:; echo "${jars[*]}")
|
||||
|
||||
DEBUG_PORT="${DEBUG_PORT:-5005}"
|
||||
DEBUG_SUSPEND="${DEBUG_SUSPEND:-y}"
|
||||
|
||||
echo "Starting NenjimHub in DEBUG mode on port ${DEBUG_PORT} (suspend=${DEBUG_SUSPEND})"
|
||||
|
||||
exec java \
|
||||
--enable-preview \
|
||||
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND},address=*:${DEBUG_PORT}" \
|
||||
-Dlog4j.configurationFile=conf/log4j2.xml \
|
||||
-cp "$CLASSPATH" \
|
||||
com.r35157.nenjim.hubd.impl.ref.Main
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
org.gradle.java.installations.auto-detect=true
|
||||
org.gradle.java.installations.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
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export VERSION=$(git describe --tags --exact-match 2>/dev/null \
|
||||
|| git symbolic-ref --short -q HEAD \
|
||||
|| git rev-parse --short HEAD)
|
||||
|
||||
echo "Building and publishing (CI/CD) $VERSION..."
|
||||
./gradlew -Pversion=$VERSION publish
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
publish.sh
|
||||
Executable
+43
@@ -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"
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
jars=(libs/*.jar)
|
||||
if (( ${#jars[@]} == 0 )); then
|
||||
echo "ERROR: No JARs found in libs/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLASSPATH=$(IFS=:; echo "${jars[*]}")
|
||||
|
||||
exec java \
|
||||
--enable-preview \
|
||||
-Dlog4j.configurationFile=conf/log4j2.xml \
|
||||
-cp "$CLASSPATH" \
|
||||
com.r35157.nenjim.hubd.impl.ref.Main
|
||||
+1
-1
@@ -1 +1 @@
|
||||
rootProject.name = "hubd-impl-ref"
|
||||
rootProject.name = "hubd-impl_ref"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.r35157.nenjim.hubd.SomeInterface;
|
||||
|
||||
public class SomeImpl implements SomeInterface {
|
||||
public String concat(String x, String y) {
|
||||
return x + y;
|
||||
}
|
||||
|
||||
public void divideByZero() throws ArithmeticException {
|
||||
int a = 0/0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.fanitas.evelyn.core;
|
||||
|
||||
import com.r35157.libs.basic.Pair;
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
|
||||
public interface DesiredPositionCalculator {
|
||||
|
||||
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> calculateRebalancingProposal(
|
||||
ΩSyrupPriceΩ currentPrice,
|
||||
ΩSolanaAmountΩ totalReadyAmountMintA,
|
||||
ΩSyrupAmountΩ totalReadyAmountMintB
|
||||
);
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateTotalDistributedSums();
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateLockedSums(ΩSyrupPriceΩ currentPrice);
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateRedistributableSums(
|
||||
ΩSyrupPriceΩ currentPrice,
|
||||
ΩSolanaAmountΩ inactiveInAccountMintA,
|
||||
ΩSolanaAmountΩ reservedForBurnMintA,
|
||||
ΩSyrupAmountΩ inactiveInAccountMintB,
|
||||
ΩSyrupAmountΩ reservedForBurnMintB
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.fanitas.evelyn.core;
|
||||
|
||||
public interface Evelyn {
|
||||
void executeService() throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.fanitas.evelyn.core;
|
||||
|
||||
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
public interface State {
|
||||
void update() throws IOException, InterruptedException;
|
||||
ΩmilliSecondsΩ getIterationInterval();
|
||||
ΩSolanaAmountΩ getSyrupOwnedByEvelyn();
|
||||
ΩSolanaAddressΩ getSolanaAddressForEvelynIOU();
|
||||
ΩSolanaAddressΩ getSolanaAddressForEvelynIOUBurner();
|
||||
ΩRaydiumLiquidityPoolIdΩ getRaydiumPoolId();
|
||||
ΩCurveWidthΩ getCurveWidth();
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> getLiquidityPositions();
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package com.fanitas.evelyn.core.impl.ref;
|
||||
|
||||
import com.r35157.libs.basic.Pair;
|
||||
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||
import com.fanitas.evelyn.core.DesiredPositionCalculator;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.*;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
|
||||
import static com.fanitas.evelyn.math.BigDecimalUtils.*;
|
||||
import static java.math.BigDecimal.ONE;
|
||||
import static java.math.BigDecimal.ZERO;
|
||||
|
||||
public class DesiredPositionCalculatorImpl implements DesiredPositionCalculator {
|
||||
|
||||
public DesiredPositionCalculatorImpl(
|
||||
ΩCurveWidthΩ curveWidth,
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions
|
||||
) {
|
||||
this.curveWidth = curveWidth;
|
||||
this.liquidityProviderPositions = liquidityProviderPositions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> calculateRebalancingProposal(
|
||||
ΩSyrupPriceΩ currentPrice,
|
||||
ΩSolanaAmountΩ totalReadyAmountMintA,
|
||||
ΩSyrupAmountΩ totalReadyAmountMintB
|
||||
) {
|
||||
List<ΩSyrupPriceΩ> intervalStarts = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||
List<ΩSyrupPriceΩ> intervalEnds = getSortedPositionIntervalToValues(liquidityProviderPositions);
|
||||
|
||||
int indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice = lookupIndexOfLastLessThanOrEqual(
|
||||
intervalStarts,
|
||||
currentPrice
|
||||
);
|
||||
|
||||
ΩSyrupPriceΩ lastLessThanOrEqual = intervalEnds.get(indexOfPositionWithAnEndPriceBeforePositionWithCurrentPrice);
|
||||
HashMap<ΩRaydiumLiquidityPoolPositionIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> desiredPositions = new HashMap<>();
|
||||
|
||||
boolean beforeNonZeroPos = true;
|
||||
|
||||
for (int i = 0; i < intervalStarts.size(); i++) {
|
||||
ΩSyrupPriceΩ iStart = intervalStarts.get(i);
|
||||
ΩSyrupPriceΩ iEnd = intervalEnds.get(i);
|
||||
|
||||
ΩSolanaAmountΩ desiredSolanaPosSize = null; // TODO: Support this
|
||||
ΩSyrupAmountΩ desiredSyrupPosSize = calculateDesiredPositionSizeForInterval(
|
||||
currentPrice,
|
||||
totalReadyAmountMintB,
|
||||
iStart,
|
||||
iEnd,
|
||||
lastLessThanOrEqual
|
||||
);
|
||||
|
||||
int desiredPositionSign = desiredSyrupPosSize.amount().compareTo(ZERO);
|
||||
|
||||
if (desiredPositionSign > 0) {
|
||||
beforeNonZeroPos = false;
|
||||
} else if (!beforeNonZeroPos && desiredPositionSign == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> desiredPositionSizes =
|
||||
new Pair<>(desiredSolanaPosSize, desiredSyrupPosSize);
|
||||
|
||||
RaydiumLiquidityPoolPositionConcentrated position = getPositionByStartPriceA(iStart);
|
||||
// TODO: What if null is return above?
|
||||
ΩRaydiumLiquidityPoolPositionNftIdΩ poolPositionId = position.nftId();
|
||||
|
||||
desiredPositions.put(poolPositionId, desiredPositionSizes);
|
||||
}
|
||||
|
||||
return desiredPositions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateTotalDistributedSums() {
|
||||
Collection<RaydiumLiquidityPoolPositionConcentrated> c = liquidityProviderPositions.values();
|
||||
|
||||
if(c.isEmpty()) {
|
||||
// TODO: I do not like this - I prefer 0 - but without any positions, do I then know the currencyType?
|
||||
throw new IllegalStateException("No positions in pool!");
|
||||
}
|
||||
|
||||
// TODO: This smells - setting to null. Oh dear!
|
||||
CurrencyType ctA = null;
|
||||
ΩAmountΩ amountA = ZERO;
|
||||
CurrencyType ctB = null;
|
||||
ΩAmountΩ amountB = ZERO;
|
||||
|
||||
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||
ctA = position.amountMintA().currencyType(); // TODO: Too redundant - please rethink
|
||||
amountA = amountA.add(position.accountingInfo().addedMintA());
|
||||
ctB = position.amountMintB().currencyType(); // TODO: Too redundant - please rethink
|
||||
amountB = amountB.add(position.accountingInfo().addedMintB());
|
||||
}
|
||||
|
||||
MoneyAmount ma = new MoneyAmount(amountA, ctA);
|
||||
MoneyAmount mb = new MoneyAmount(amountB, ctB);
|
||||
|
||||
return new Pair<>(ma, mb);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateLockedSums(ΩSyrupPriceΩ currentPrice) {
|
||||
List<ΩSyrupPriceΩ> ascendingPrices = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||
ΩAmountΩ sumA = ZERO;
|
||||
ΩAmountΩ sumB = ZERO;
|
||||
|
||||
// TODO: This smells - setting to null. Oh dear!
|
||||
CurrencyType ctA = null;
|
||||
CurrencyType ctB = null;
|
||||
|
||||
int index = lookupIndexOfFirstGreaterThanOrEqual(ascendingPrices, currentPrice);
|
||||
if(index >= 0) {
|
||||
AssetPrice startPriceOfStartPos = ascendingPrices.get(index);
|
||||
|
||||
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||
if (position.priceRange().from().compareTo(startPriceOfStartPos) >= 0) {
|
||||
ctA = position.amountMintA().currencyType();
|
||||
sumA = sumA.add(position.amountMintA().amount());
|
||||
ctB = position.amountMintB().currencyType();
|
||||
sumB = sumB.add(position.amountMintB().amount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MoneyAmount ma = new MoneyAmount(sumA, ctA);
|
||||
MoneyAmount mb = new MoneyAmount(sumB, ctB);
|
||||
|
||||
return new Pair<>(ma, mb);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> calculateRedistributableSums(
|
||||
ΩSyrupPriceΩ currentPrice,
|
||||
ΩSolanaAmountΩ inactiveInAccountMintA,
|
||||
ΩSolanaAmountΩ reservedForBurnMintA,
|
||||
ΩSyrupAmountΩ inactiveInAccountMintB,
|
||||
ΩSyrupAmountΩ reservedForBurnMintB
|
||||
) {
|
||||
List<AssetPrice> ascendingValues = getSortedPositionIntervalFromValues(liquidityProviderPositions);
|
||||
ΩAmountΩ redistSumA = ZERO;
|
||||
ΩAmountΩ redistSumB = ZERO;
|
||||
CurrencyType ctA = null;
|
||||
CurrencyType ctB = null;
|
||||
|
||||
int index = lookupIndexOfLastLessThan(ascendingValues, currentPrice);
|
||||
if(index >= 0) {
|
||||
AssetPrice startPriceOfStartPos = ascendingValues.get(index);
|
||||
|
||||
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityProviderPositions.values()) {
|
||||
if (position.priceRange().from().compareTo(startPriceOfStartPos) <= 0) {
|
||||
ctA = position.amountMintA().currencyType();
|
||||
ctB = position.amountMintB().currencyType();
|
||||
redistSumA = redistSumA.add(position.amountMintA().amount());
|
||||
redistSumB = redistSumB.add(position.amountMintB().amount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redistSumA = add(inactiveInAccountMintA, redistSumA);
|
||||
redistSumA = subtract(redistSumA, reservedForBurnMintA);
|
||||
redistSumB = add(inactiveInAccountMintB, redistSumB);
|
||||
redistSumB = subtract(redistSumB, reservedForBurnMintB);
|
||||
MoneyAmount ma = new MoneyAmount(redistSumA, ctA);
|
||||
MoneyAmount mb = new MoneyAmount(redistSumB, ctB);
|
||||
|
||||
return new Pair<>(ma, mb);
|
||||
}
|
||||
|
||||
private MoneyAmount add(ΩAmountΩ a, ΩAmountΩ b, CurrencyType ct) {
|
||||
MoneyAmount ma = new MoneyAmount(a.add(b), ct);
|
||||
return ma;
|
||||
}
|
||||
|
||||
private MoneyAmount add(MoneyAmount a, MoneyAmount b) {
|
||||
MoneyAmount ma = new MoneyAmount(a.amount().add(b.amount()), a.currencyType());
|
||||
return ma;
|
||||
}
|
||||
|
||||
private ΩAmountΩ add(MoneyAmount a, ΩAmountΩ b) {
|
||||
return a.amount().add(b);
|
||||
}
|
||||
|
||||
private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) {
|
||||
return a.subtract(b.amount());
|
||||
}
|
||||
|
||||
private @Nullable RaydiumLiquidityPoolPositionConcentrated getPositionByStartPriceA(@NotNull AssetPrice startPriceA) {
|
||||
for(RaydiumLiquidityPoolPositionConcentrated candidate : liquidityProviderPositions.values()) {
|
||||
if (candidate.priceRange().from().compareTo(startPriceA) == 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ΩSyrupAmountΩ calculateDesiredPositionSizeForInterval(
|
||||
@NotNull ΩSyrupPriceΩ currentPrice,
|
||||
@NotNull ΩSolanaAmountΩ totalSyrupToDistribution,
|
||||
@NotNull ΩSyrupPriceΩ intervalPriceFrom,
|
||||
@NotNull ΩSyrupPriceΩ intervalPriceTo,
|
||||
@NotNull ΩSyrupPriceΩ lookupPrice
|
||||
) {
|
||||
ΩPriceΩ curveStartPrice = currentPrice.price().multiply(
|
||||
ONE.subtract(curveWidth, MC),
|
||||
MC
|
||||
);
|
||||
|
||||
boolean intervalIsCompletelyBeforeCurve =
|
||||
intervalPriceTo.price().compareTo(curveStartPrice) <= 0;
|
||||
|
||||
boolean intervalIsCompletelyAfterLookupLimit =
|
||||
intervalPriceFrom.price().compareTo(lookupPrice.price()) >= 0;
|
||||
|
||||
if (intervalIsCompletelyBeforeCurve || intervalIsCompletelyAfterLookupLimit) {
|
||||
return new ΩSyrupAmountΩ(ZERO, WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType());
|
||||
}
|
||||
|
||||
ΩPriceΩ effectiveIntervalFrom = max(intervalPriceFrom.price(), curveStartPrice);
|
||||
ΩPriceΩ effectiveIntervalTo = min(intervalPriceTo.price(), lookupPrice.price());
|
||||
|
||||
BigDecimal sigma = currentPrice.price().multiply(curveWidth, MC)
|
||||
.divide(THREE, MC);
|
||||
|
||||
BigDecimal sqrtTwo = sqrt(TWO, MC);
|
||||
BigDecimal denominator = sigma.multiply(sqrtTwo, MC);
|
||||
|
||||
ΩPriceΩ normalizedEffectiveTo = effectiveIntervalTo.subtract(currentPrice.price(), MC)
|
||||
.divide(denominator, MC);
|
||||
|
||||
ΩPriceΩ normalizedEffectiveFrom = effectiveIntervalFrom.subtract(currentPrice.price(), MC)
|
||||
.divide(denominator, MC);
|
||||
|
||||
ΩPriceΩ normalizedLookupPrice = lookupPrice.price().subtract(currentPrice.price(), MC)
|
||||
.divide(denominator, MC);
|
||||
|
||||
ΩPriceΩ normalizedCurveStart = curveStartPrice.subtract(currentPrice.price(), MC)
|
||||
.divide(denominator, MC);
|
||||
|
||||
BigDecimal intervalWeight = new BigDecimal(erf(normalizedEffectiveTo) - erf(normalizedEffectiveFrom));
|
||||
BigDecimal totalWeightInActiveCurve = new BigDecimal(erf(normalizedLookupPrice) - erf(normalizedCurveStart));
|
||||
|
||||
ΩSyrupAmountΩ dps = new ΩSyrupAmountΩ(
|
||||
totalSyrupToDistribution.amount()
|
||||
.multiply(intervalWeight, MC)
|
||||
.divide(totalWeightInActiveCurve, MC),
|
||||
WellKnownCurrencyTypes.SYRUPUSDC.getCurrencyType()
|
||||
);
|
||||
|
||||
return dps;
|
||||
}
|
||||
|
||||
private static int lookupIndexOfFirstGreaterThan(
|
||||
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||
@NotNull ΩSyrupPriceΩ searchPrice
|
||||
) {
|
||||
int index = -1;
|
||||
|
||||
for (int i = ascendingPrices.size() - 1; i >= 0; i--) {
|
||||
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||
if (p.compareTo(searchPrice.price()) <= 0) {
|
||||
break;
|
||||
} else {
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1) {
|
||||
throw new IllegalArgumentException(
|
||||
"No position has a greater start price than a position containing '" + searchPrice + "'"
|
||||
);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int lookupIndexOfFirstGreaterThanOrEqual(
|
||||
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||
@NotNull ΩSyrupPriceΩ searchPrice
|
||||
) {
|
||||
int index = -1;
|
||||
|
||||
for (int i = ascendingPrices.size() - 1; i >= 0; i--) {
|
||||
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||
if (p.compareTo(searchPrice.price()) <= 0) {
|
||||
break;
|
||||
} else {
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1) {
|
||||
throw new IllegalArgumentException(
|
||||
"No position has a greater start price than a position containing '" + searchPrice + "'"
|
||||
);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int lookupIndexOfLastLessThanOrEqual(
|
||||
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||
@NotNull ΩSyrupPriceΩ searchPrice
|
||||
) {
|
||||
int index = -1;
|
||||
for (int i = 0; i < ascendingPrices.size() - 1; i++) {
|
||||
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||
if (p.compareTo(searchPrice.price()) < 0) {
|
||||
index = i - 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int lookupIndexOfLastLessThan(
|
||||
@NotNull List<ΩSyrupPriceΩ> ascendingPrices,
|
||||
@NotNull ΩSyrupPriceΩ searchPrice
|
||||
) {
|
||||
int index = -1;
|
||||
|
||||
for (int i = 0; i < ascendingPrices.size() - 1; i++) {
|
||||
ΩPriceΩ p = ascendingPrices.get(i).price();
|
||||
if (p.compareTo(searchPrice.price()) < 0) {
|
||||
index = i - 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static @NotNull List<ΩSyrupPriceΩ> getSortedPositionIntervalFromValues(
|
||||
@NotNull Map<ΩRaydiumLiquidityPoolPositionNftIdΩ,
|
||||
@NotNull RaydiumLiquidityPoolPositionConcentrated> liquidityPositions
|
||||
) {
|
||||
List<ΩSyrupPriceΩ> result = new ArrayList<>(liquidityPositions.size());
|
||||
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) {
|
||||
result.add(position.priceRange().from());
|
||||
}
|
||||
result.sort(ΩSyrupPriceΩ::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static @NotNull List<ΩSyrupPriceΩ> getSortedPositionIntervalToValues(
|
||||
@NotNull Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions
|
||||
) {
|
||||
List<ΩSyrupPriceΩ> result = new ArrayList<>(liquidityPositions.size());
|
||||
for (RaydiumLiquidityPoolPositionConcentrated position : liquidityPositions.values()) {
|
||||
result.add(position.priceRange().to());
|
||||
}
|
||||
result.sort(ΩSyrupPriceΩ::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final MathContext MC = new MathContext(20, RoundingMode.HALF_UP);
|
||||
|
||||
private final ΩCurveWidthΩ curveWidth;
|
||||
private final Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositions;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.fanitas.evelyn.core.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.*;
|
||||
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||
|
||||
import com.r35157.libs.basic.Pair;
|
||||
import com.r35157.libs.basic.Triblet;
|
||||
import com.r35157.libs.raydium.Raydium;
|
||||
import com.r35157.libs.raydium.RaydiumLiquidityPoolPrice;
|
||||
import com.r35157.libs.solana.SPLTokenHolding;
|
||||
import com.r35157.libs.solana.SolanaBlockChain;
|
||||
import com.r35157.libs.solana.SolanaConstants;
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||
import com.r35157.libs.valuetypes.basic.TradingPair;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SOLANA;
|
||||
import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SYRUPUSDC;
|
||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
||||
|
||||
public class EvelynImpl implements Evelyn {
|
||||
|
||||
public EvelynImpl(
|
||||
State state,
|
||||
DesiredPositionCalculator desiredPositionCalculator,
|
||||
Raydium raydium,
|
||||
SolanaBlockChain solanaChain
|
||||
) {
|
||||
this.state = state;
|
||||
this.desiredPositionCalculator = desiredPositionCalculator;
|
||||
this.raydium = raydium;
|
||||
this.solanaChain = solanaChain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void executeService() throws Exception {
|
||||
handleRebalancingProposal();
|
||||
}
|
||||
|
||||
private SPLTokenHolding getSPLHolding(ΩSolanaAddressΩ ownerAddress, ΩSPLMintAddressΩ splMintAddress) throws Exception {
|
||||
Map<ΩSPLMintAddressΩ, SPLTokenHolding> holdings = new HashMap<>();
|
||||
|
||||
holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, SPL_TOKEN_PROGRAM));
|
||||
holdings.putAll(solanaChain.getSPLTokenHoldings(ownerAddress, TOKEN_2022_PROGRAM));
|
||||
|
||||
SPLTokenHolding result = holdings.get(splMintAddress);
|
||||
return result;
|
||||
}
|
||||
|
||||
private MoneyAmount add(MoneyAmount a, ΩAmountΩ b) {
|
||||
MoneyAmount ma = new MoneyAmount(a.amount().add(b), a.currencyType());
|
||||
return ma;
|
||||
}
|
||||
|
||||
private MoneyAmount add(MoneyAmount a, MoneyAmount b) {
|
||||
if(a.currencyType() != b.currencyType()) {
|
||||
String errTxt = "Cannot add " + b.currencyType()
|
||||
+ " to " + a.currencyType();
|
||||
throw new IllegalArgumentException(errTxt);
|
||||
}
|
||||
|
||||
MoneyAmount ma = new MoneyAmount(a.amount().add(b.amount()), a.currencyType());
|
||||
return ma;
|
||||
}
|
||||
|
||||
private MoneyAmount subtract(MoneyAmount a, MoneyAmount b) {
|
||||
if(a.currencyType() != b.currencyType()) {
|
||||
String errTxt = "Cannot subtract " + b.currencyType()
|
||||
+ " from " + a.currencyType();
|
||||
throw new IllegalArgumentException(errTxt);
|
||||
}
|
||||
|
||||
MoneyAmount ma = new MoneyAmount(a.amount().subtract(b.amount()), a.currencyType());
|
||||
return ma;
|
||||
}
|
||||
|
||||
private MoneyAmount subtract(MoneyAmount a, ΩAmountΩ b) {
|
||||
MoneyAmount ma = new MoneyAmount(a.amount().subtract(b), a.currencyType());
|
||||
return ma;
|
||||
}
|
||||
|
||||
private ΩAmountΩ subtract(ΩAmountΩ a, ΩAmountΩ b) {
|
||||
return a.subtract(b);
|
||||
}
|
||||
|
||||
private ΩAmountΩ subtract(ΩAmountΩ a, MoneyAmount b) {
|
||||
return a.subtract(b.amount());
|
||||
}
|
||||
|
||||
private void handleRebalancingProposal() {
|
||||
try {
|
||||
ΩSolanaAddressΩ solanaAddressForEvelynIOU = state.getSolanaAddressForEvelynIOU();
|
||||
ΩSPLMintAddressΩ syrupUSDCMintAddr = SolanaConstants.SPL_TOKEN_SYRUPUSDC;
|
||||
|
||||
AssetPrice currentPriceFromRaydium;
|
||||
SPLTokenHolding syrupHolding = getSPLHolding(solanaAddressForEvelynIOU, syrupUSDCMintAddr);
|
||||
|
||||
ΩSyrupAmountΩ inactiveInAccountSyrup = new ΩSyrupAmountΩ(
|
||||
syrupHolding.uiAmount(),
|
||||
SYRUPUSDC.getCurrencyType()
|
||||
);
|
||||
|
||||
while(true) {
|
||||
state.update();
|
||||
ΩmilliSecondsΩ iterationStartTime = System.currentTimeMillis();
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
RaydiumLiquidityPoolPrice concentratedResult;
|
||||
Future<RaydiumLiquidityPoolPrice> cFuture = executor.submit(
|
||||
() -> new RaydiumLiquidityPoolPrice(
|
||||
state.getRaydiumPoolId(),
|
||||
raydium.fetchPoolPrice(state.getRaydiumPoolId())
|
||||
)
|
||||
);
|
||||
|
||||
concentratedResult = cFuture.get();
|
||||
currentPriceFromRaydium = concentratedResult.price();
|
||||
}
|
||||
|
||||
System.out.println("Iteration interval: " + (state.getIterationInterval() / 1000) + " secs");
|
||||
|
||||
System.out.println("Solana balances:");
|
||||
ΩSolanaAmountΩ solBalanceEvelynAccount = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOU());
|
||||
System.out.println(" Evelyn account: " + solBalanceEvelynAccount.amount());
|
||||
ΩSolanaAmountΩ solBalanceBurnAccount = solanaChain.getBalanceInSolana(state.getSolanaAddressForEvelynIOUBurner());
|
||||
System.out.println(" Burn account: " + solBalanceBurnAccount.amount());
|
||||
System.out.println("SYRUP owned by Evelyn: " + state.getSyrupOwnedByEvelyn().amount());
|
||||
System.out.println("SYRUP inactive on Evelyn account: " + inactiveInAccountSyrup);
|
||||
|
||||
System.out.println("Pool price: " + currentPriceFromRaydium);
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> totalDistributedSums =
|
||||
desiredPositionCalculator.calculateTotalDistributedSums();
|
||||
ΩSolanaAmountΩ totalDistributedSumSolana = totalDistributedSums.left();
|
||||
ΩSyrupAmountΩ totalDistributedSumSyrup = totalDistributedSums.right();
|
||||
System.out.println("Total amount currently distributed: " + totalDistributedSumSolana
|
||||
+ " / " + totalDistributedSumSyrup);
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> amountsLocked =
|
||||
desiredPositionCalculator.calculateLockedSums(currentPriceFromRaydium);
|
||||
ΩSolanaAmountΩ amountLockedSolana = amountsLocked.left();
|
||||
ΩSyrupAmountΩ amountLockedSyrup = amountsLocked.right();
|
||||
System.out.println("Total amount locked due to HIGH price: " + amountLockedSolana);
|
||||
System.out.println("Total amount locked due to LOW price: " + amountLockedSyrup);
|
||||
|
||||
ΩSyrupAmountΩ totalAmountSyrup = add(totalDistributedSumSyrup, inactiveInAccountSyrup);
|
||||
ΩSyrupAmountΩ reservedForBurnSyrup = subtract(totalAmountSyrup, state.getSyrupOwnedByEvelyn());
|
||||
|
||||
ΩSolanaAmountΩ readyForBurnSolana = subtract(solBalanceEvelynAccount, SOFT_LOW_LIMIT_SOLANA_BALANCE);
|
||||
System.out.println("Amount reserved for burn: "
|
||||
+ "Solana: " + readyForBurnSolana
|
||||
+ ", Syrup:" + reservedForBurnSyrup);
|
||||
|
||||
Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ> totalReadyAmount = desiredPositionCalculator
|
||||
.calculateRedistributableSums(
|
||||
currentPriceFromRaydium,
|
||||
solBalanceEvelynAccount,
|
||||
amountZeroSolana, // We do not burn Solana
|
||||
inactiveInAccountSyrup,
|
||||
reservedForBurnSyrup
|
||||
);
|
||||
ΩSolanaAmountΩ solTotalReadyAmount = totalReadyAmount.left();
|
||||
ΩSyrupAmountΩ syrupTotalReadyAmount = totalReadyAmount.right();
|
||||
System.out.println("Total amount of Syrup ready for distribution: " + syrupTotalReadyAmount);
|
||||
|
||||
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> rebalancingProposal =
|
||||
desiredPositionCalculator.calculateRebalancingProposal(
|
||||
currentPriceFromRaydium,
|
||||
solTotalReadyAmount,
|
||||
syrupTotalReadyAmount
|
||||
);
|
||||
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityPositions =
|
||||
state.getLiquidityPositions();
|
||||
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> diffs =
|
||||
calculateDiffs(rebalancingProposal, liquidityPositions);
|
||||
|
||||
String minKey = diffs.entrySet().stream()
|
||||
.min(Comparator.comparing(entry -> entry.getValue().right().amount()))
|
||||
.map(Map.Entry::getKey)
|
||||
.orElseThrow();
|
||||
|
||||
String maxKey = diffs.entrySet().stream()
|
||||
.max(Comparator.comparing(entry -> entry.getValue().right().amount()))
|
||||
.map(Map.Entry::getKey)
|
||||
.orElseThrow();
|
||||
|
||||
System.out.println("Move value from (" + maxKey + "): ");
|
||||
|
||||
RaydiumLiquidityPoolPositionConcentrated maxPos = liquidityPositions.get(maxKey);
|
||||
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> maxDiff = diffs.get(maxKey);
|
||||
System.out.println(" " + maxPos.priceRange().from() + ".."
|
||||
+ maxPos.priceRange().to()
|
||||
+ " - Current: " + maxDiff.left().amount()
|
||||
+ ", Suggested: " + maxDiff.middle().amount()
|
||||
+ ", Diff: " + maxDiff.right().amount()
|
||||
);
|
||||
|
||||
System.out.println(" --> ");
|
||||
|
||||
RaydiumLiquidityPoolPositionConcentrated minPos = liquidityPositions.get(minKey);
|
||||
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> minDiff = diffs.get(minKey);
|
||||
System.out.println(" " + minPos.priceRange().from() + ".."
|
||||
+ minPos.priceRange().to()
|
||||
+ " - Current: " + minDiff.left().amount()
|
||||
+ ", Suggested: " + minDiff.middle().amount()
|
||||
+ ", Diff: " + minDiff.right().amount());
|
||||
|
||||
ΩmilliSecondsΩ iterationExecutionTime = (System.currentTimeMillis() - iterationStartTime);
|
||||
|
||||
for(ΩmilliSecondsΩ sleepTime = state.getIterationInterval() - iterationExecutionTime; sleepTime > 0; sleepTime -= 1000) {
|
||||
System.out.print("\rSleeping for " + (sleepTime / 1000) + " secs... ");
|
||||
System.out.flush();
|
||||
Thread.sleep(1000L);
|
||||
}
|
||||
System.out.println();
|
||||
System.out.println("-------------------------------------------------------------");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> calculateDiffs(
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, Pair<ΩSolanaAmountΩ, ΩSyrupAmountΩ>> rebalancingProposal,
|
||||
Map<ΩRaydiumLiquidityPoolPositionNftIdΩ, RaydiumLiquidityPoolPositionConcentrated> liquidityProviderPositionMap
|
||||
) {
|
||||
HashMap<ΩRaydiumLiquidityPoolPositionNftIdΩ, Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ>> diffs =
|
||||
new HashMap<>(rebalancingProposal.size());
|
||||
|
||||
for(ΩRaydiumLiquidityPoolPositionNftIdΩ nftId : rebalancingProposal.keySet()) {
|
||||
RaydiumLiquidityPoolPositionConcentrated pos = liquidityProviderPositionMap.get(nftId);
|
||||
ΩSyrupAmountΩ currentlyAmountAdded = pos.amountMintB();
|
||||
ΩSyrupAmountΩ suggestedAmount = rebalancingProposal.get(nftId).right();
|
||||
ΩSyrupAmountΩ diff = subtract(currentlyAmountAdded, suggestedAmount);
|
||||
Triblet<ΩSyrupAmountΩ, ΩSyrupAmountΩ, ΩSyrupAmountΩ> t = new Triblet<>(
|
||||
currentlyAmountAdded,
|
||||
suggestedAmount,
|
||||
diff
|
||||
);
|
||||
diffs.put(nftId, t);
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
private final static ΩSolanaAmountΩ amountZeroSolana = new ΩSolanaAmountΩ(
|
||||
BigDecimal.ZERO,
|
||||
SOLANA.getCurrencyType()
|
||||
);
|
||||
|
||||
private final ΩAmountΩ SOFT_LOW_LIMIT_SOLANA_BALANCE = new BigDecimal("0.1");
|
||||
|
||||
private final State state;
|
||||
private final DesiredPositionCalculator desiredPositionCalculator;
|
||||
private final Raydium raydium;
|
||||
private final SolanaBlockChain solanaChain;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.fanitas.evelyn.core.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.Evelyn;
|
||||
import com.fanitas.evelyn.core.DesiredPositionCalculator;
|
||||
import com.fanitas.evelyn.core.State;
|
||||
import com.r35157.libs.raydium.*;
|
||||
import com.r35157.libs.raydium.impl.ref.RaydiumImpl;
|
||||
import com.r35157.libs.solana.SPLTokenHolding;
|
||||
import com.r35157.libs.solana.SPLTokenSupply;
|
||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
||||
import com.r35157.libs.solana.SolanaBlockChain;
|
||||
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
|
||||
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||
import com.r35157.libs.valuetypes.basic.Range;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class Main {
|
||||
|
||||
static void main() throws Exception {
|
||||
log.info("Initializing Evelyn...");
|
||||
SolanaBlockChain solanaChain = new SolanaBlockChainImpl();
|
||||
Raydium raydium = new RaydiumImpl(solanaChain);
|
||||
System.out.println(" Done.");
|
||||
|
||||
State state = new StateImpl(raydium);
|
||||
DesiredPositionCalculator desiredPositionCalculator = new DesiredPositionCalculatorImpl(
|
||||
state.getCurveWidth(),
|
||||
state.getLiquidityPositions()
|
||||
);
|
||||
|
||||
Evelyn evelyn = new EvelynImpl(
|
||||
state,
|
||||
desiredPositionCalculator,
|
||||
raydium,
|
||||
solanaChain
|
||||
);
|
||||
|
||||
evelyn.executeService();
|
||||
}
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Main.class);
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.fanitas.evelyn.raydium;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record RaydiumLiquidityPoolPositionAccounting(
|
||||
ΩRaydiumLiquidityPoolPositionNftIdΩ nftId,
|
||||
ΩAmountΩ addedMintA,
|
||||
ΩAmountΩ addedMintB,
|
||||
ΩAmountΩ borrowedMintA,
|
||||
ΩAmountΩ borrowedMintB
|
||||
) {
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.fanitas.evelyn.raydium;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||
import com.r35157.libs.valuetypes.basic.Range;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Represents a concentrated liquidity position in a Raydium liquidity pool.
|
||||
*
|
||||
* <p>A concentrated liquidity position is represented on Solana by a position NFT.
|
||||
* The NFT identifies the individual position, while the pool id identifies the
|
||||
* Raydium concentrated liquidity pool that the position belongs to.</p>
|
||||
*
|
||||
* <p>The position is only active within its configured price range. The two mint
|
||||
* amounts represent the token amounts associated with the position at the time
|
||||
* the position data was fetched or calculated.</p>
|
||||
*
|
||||
* @param poolId the Raydium concentrated liquidity pool id
|
||||
* @param nftId the Raydium liquidity pool position NFT id identifying this position
|
||||
* @param priceRange the price range where this concentrated liquidity position is active
|
||||
* @param accountingInfo the amount added to and borrowed from the position
|
||||
*/
|
||||
public record RaydiumLiquidityPoolPositionConcentrated(
|
||||
@NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||
@NotNull ΩRaydiumLiquidityPoolPositionNftIdΩ nftId,
|
||||
@NotNull Range<AssetPrice> priceRange,
|
||||
@NotNull MoneyAmount amountMintA,
|
||||
@NotNull MoneyAmount amountMintB,
|
||||
@NotNull RaydiumLiquidityPoolPositionAccounting accountingInfo
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.r35157.assetaz.core.service.ticker;
|
||||
|
||||
public interface TickerService {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.r35157.assetaz.core.service.ticker.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.core.service.ticker.TickerService;
|
||||
|
||||
public class TickerServiceImpl implements TickerService {
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
+211
@@ -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;
|
||||
}
|
||||
+183
@@ -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;
|
||||
}
|
||||
+150
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -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() {
|
||||
}
|
||||
}
|
||||
+123
@@ -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;
|
||||
}
|
||||
+106
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -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");
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -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];
|
||||
}
|
||||
+152
@@ -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;
|
||||
}
|
||||
+64
@@ -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();
|
||||
}
|
||||
+141
@@ -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();
|
||||
}
|
||||
+767
@@ -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();
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.r35157.libs.jupiter.perps.impl.anchoridl;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
public record JupiterCustodyAccountInfo(
|
||||
ΩSPLMintAddressΩ mintAddress,
|
||||
ΩJupiterCumulativeInterestRateΩ currentCumulativeInterestRate
|
||||
) {
|
||||
}
|
||||
+17
@@ -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
Reference in New Issue
Block a user