15 Commits

15 changed files with 562 additions and 307 deletions
+1
View File
@@ -44,6 +44,7 @@ dependencies {
runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0")
runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0")
implementation("com.r35157.nenjim:hubd-api:0.1-dev")
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.6")
implementation("com.fazecast:jSerialComm:2.11.4")
implementation("com.google.code.gson:gson:2.14.0")
@@ -13,6 +13,7 @@ public final class AlarmConfigurationParser {
public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
List<PriceAlarmDefinition> alarms = new ArrayList<>();
Map<String, String> constants = new LinkedHashMap<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = lines.get(lineNumber - 1);
@@ -59,8 +60,8 @@ public final class AlarmConfigurationParser {
BigDecimal target = parseTarget(cursor.nextToken("target"));
AlarmTrigger trigger = AlarmTrigger.valueOf(
cursor.nextToken("trigger").toUpperCase(Locale.ROOT)
TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger")
);
AlarmSeverity severity = AlarmSeverity.valueOf(
@@ -81,7 +82,8 @@ public final class AlarmConfigurationParser {
asset,
direction,
target,
trigger,
triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(),
severity,
note
);
@@ -109,6 +111,50 @@ public final class AlarmConfigurationParser {
return target;
}
private static TriggerConfiguration parseTrigger(String triggerText) {
String normalized = triggerText.toUpperCase(Locale.ROOT);
if (normalized.equals("ONETIME")) {
return new TriggerConfiguration(AlarmTrigger.ONETIME, 0);
}
if (normalized.equals("PERSISTENT")) {
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0);
}
if (normalized.startsWith("PERSISTENT:")) {
String graceText = normalized.substring("PERSISTENT:".length());
if (graceText.isEmpty()) {
throw new IllegalArgumentException("Missing persistent grace period: " + triggerText);
}
ΩsecondsΩ gracePeriodSeconds;
try {
gracePeriodSeconds = Integer.parseInt(graceText);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Invalid persistent grace period: " + triggerText,
exception
);
}
if (gracePeriodSeconds < 0) {
throw new IllegalArgumentException(
"Persistent grace period cannot be negative: " + triggerText
);
}
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, gracePeriodSeconds);
}
if (normalized.startsWith("ONETIME:")) {
throw new IllegalArgumentException("ONETIME cannot have a grace period: " + triggerText);
}
throw new IllegalArgumentException("Unknown trigger: " + triggerText);
}
private static void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
@@ -274,8 +320,12 @@ public final class AlarmConfigurationParser {
}
}
private AlarmConfigurationParser() {
private record TriggerConfiguration(
AlarmTrigger trigger,
ΩsecondsΩ gracePeriod
) {
}
private static Map<String, String> constants = new LinkedHashMap<>();
private AlarmConfigurationParser() {
}
}
@@ -48,25 +48,6 @@ public final class DovesAgPriceFeedDecoder {
);
}
public static void selfTest() {
byte[] data = new byte[ACCOUNT_SIZE];
System.arraycopy(AG_PRICE_FEED_DISCRIMINATOR, 0, data, 0, 8);
putUnsignedLongLittleEndian(data, PRICE_OFFSET, new BigInteger("123456789"));
data[EXPONENT_OFFSET] = (byte) -6;
ByteBuffer.wrap(data, TIMESTAMP_OFFSET, Long.BYTES)
.order(ByteOrder.LITTLE_ENDIAN)
.putLong(1_700_000_000L);
OraclePrice decoded = decode(JupiterPerpsAsset.SOL, data, 42L, "self-test");
if (decoded.priceUsd().compareTo(new BigDecimal("123.456789")) != 0) {
throw new IllegalStateException("Decoder self-test failed: " + decoded.priceUsd());
}
if (!decoded.oracleTime().equals(Instant.ofEpochSecond(1_700_000_000L))) {
throw new IllegalStateException("Timestamp self-test failed");
}
}
private static BigInteger readUnsignedLongLittleEndian(byte[] data, int offset) {
byte[] positiveBigEndian = new byte[Long.BYTES + 1];
for (int index = 0; index < Long.BYTES; index++) {
@@ -21,12 +21,6 @@ public final class JupiterPerpsAlarmImpl {
throw new IllegalStateException(errMsg, exception);
}
if (config.selfTest()) {
SelfTest.run();
System.out.println("All self-tests passed.");
return;
}
List<PriceAlarmDefinition> definitions;
try {
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
@@ -126,7 +120,6 @@ public final class JupiterPerpsAlarmImpl {
Options:
--config=<path> Default: price-alarms.conf
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
--self-test Test parser, alarm semantics and oracle decoder
Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path
@@ -140,11 +133,9 @@ public final class JupiterPerpsAlarmImpl {
Path alarmConfiguration,
List<URI> webSocketEndpoints,
String pushoverToken,
String pushoverUserKey,
boolean selfTest
String pushoverUserKey
) {
private static Config parse(String[] args, Map<String, String> environment) {
boolean selfTest = Arrays.asList(args).contains("--self-test");
Map<String, String> options = Arrays.stream(args)
.filter(argument -> argument.startsWith("--") && argument.contains("="))
.map(argument -> argument.substring(2).split("=", 2))
@@ -184,8 +175,7 @@ public final class JupiterPerpsAlarmImpl {
Path.of(configurationText),
endpoints,
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
blankToNull(environment.get("PUSHOVER_USER_KEY")),
selfTest
blankToNull(environment.get("PUSHOVER_USER_KEY"))
);
}
@@ -1,5 +1,7 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.time.Instant;
public final class PriceAlarm {
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
@@ -25,16 +27,28 @@ public final class PriceAlarm {
previousReached = reached;
if (!enteredTriggeredSide) {
if (!reached) {
previousReached = false;
return;
}
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) {
if (definition.trigger() == AlarmTrigger.ONETIME) {
if (!enteredTriggeredSide || triggerCount > 0) {
return;
}
triggerCount++;
action.trigger(price, definition);
trigger(price);
return;
}
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price);
}
return;
}
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
}
public PriceAlarmDefinition definition() {
@@ -45,9 +59,32 @@ public final class PriceAlarm {
return triggerCount;
}
private boolean persistentGracePeriodHasPassed() {
if (lastTriggeredAt == null) {
return true;
}
ΩsecondsΩ gracePeriod = definition.triggerGracePeriod();
if (gracePeriod == 0) {
return true;
}
return !Instant.now().isBefore(
lastTriggeredAt.plusSeconds(gracePeriod)
);
}
private void trigger(OraclePrice price) {
triggerCount++;
lastTriggeredAt = Instant.now();
action.trigger(price, definition);
}
private final PriceAlarmDefinition definition;
private final AlarmAction action;
private Instant lastTriggeredAt;
private Boolean previousReached;
private long triggerCount;
}
@@ -11,6 +11,7 @@ public record PriceAlarmDefinition(
PriceDirection direction,
BigDecimal target,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
) {
@@ -24,5 +25,11 @@ public record PriceAlarmDefinition(
if (target.signum() < 0) {
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
}
if (triggerGracePeriod < 0) {
throw new IllegalArgumentException(
"Trigger grace period cannot be negative: " + triggerGracePeriod
);
}
}
}
@@ -1,121 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public final class SelfTest {
public static void run() {
DovesAgPriceFeedDecoder.selfTest();
configurationParserTest();
oneTimeAlarmTest();
persistentAlarmTest();
initialTriggerTest();
}
private static void configurationParserTest() {
PriceAlarmDefinition alarm = AlarmConfigurationParser.parseLine(
"SOL ABOVE 75.7 ONETIME CRITICAL \"ALARM: Risiko for likvidering!\""
);
require(alarm.asset() == JupiterPerpsAsset.SOL, "Asset parsing failed");
require(alarm.direction() == PriceDirection.ABOVE, "Direction parsing failed");
require(alarm.target().compareTo(new BigDecimal("75.7")) == 0, "Target parsing failed");
require(alarm.trigger() == AlarmTrigger.ONETIME, "Trigger parsing failed");
require(alarm.severity() == AlarmSeverity.CRITICAL, "Severity parsing failed");
require(alarm.note().equals("ALARM: Risiko for likvidering!"), "Note parsing failed");
}
private static void oneTimeAlarmTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.ABOVE, "100", AlarmTrigger.ONETIME),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "90");
accept(alarm, "100");
accept(alarm, "110");
accept(alarm, "90");
accept(alarm, "101");
require(triggeredPrices.equals(List.of(new BigDecimal("100"))), "ONETIME semantics failed");
}
private static void persistentAlarmTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.BELOW, "100", AlarmTrigger.PERSISTENT),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "110");
accept(alarm, "99");
accept(alarm, "98");
accept(alarm, "101");
accept(alarm, "100");
require(
triggeredPrices.equals(List.of(new BigDecimal("99"), new BigDecimal("100"))),
"PERSISTENT crossing semantics failed"
);
}
private static void initialTriggerTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.ABOVE, "100", AlarmTrigger.PERSISTENT),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "105");
accept(alarm, "106");
require(
triggeredPrices.equals(List.of(new BigDecimal("105"))),
"Initial satisfied alarm did not trigger exactly once"
);
}
private static PriceAlarmDefinition definition(
PriceDirection direction,
String target,
AlarmTrigger trigger
) {
return new PriceAlarmDefinition(
123,
JupiterPerpsAsset.SOL,
direction,
new BigDecimal(target),
trigger,
AlarmSeverity.CRITICAL,
"ignored for now"
);
}
private static void accept(PriceAlarm alarm, String price) {
BigDecimal decimal = new BigDecimal(price);
alarm.accept(new OraclePrice(
JupiterPerpsAsset.SOL,
decimal.unscaledValue().abs(),
-decimal.scale(),
decimal,
Instant.ofEpochSecond(1_700_000_000L),
1L,
"self-test"
));
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private SelfTest() {
}
}
@@ -0,0 +1,2 @@
package com.r35157.jupiterperpsalarm.impl.ref;
@@ -0,0 +1,70 @@
package com.r35157.libs.codec.impl.ref;
import com.r35157.libs.codec.Base58Codec;
public class Base58CodecImpl implements Base58Codec {
public String encode(byte[] input) {
if (input.length == 0) {
return "";
}
byte[] copy = input.clone();
int zeros = 0;
while (zeros < copy.length && copy[zeros] == 0) {
zeros++;
}
char[] encoded = new char[copy.length * 2];
int outputStart = encoded.length;
int inputStart = zeros;
while (inputStart < copy.length) {
int remainder = divmod58(
copy,
inputStart
);
if (copy[inputStart] == 0) {
inputStart++;
}
encoded[--outputStart] = ALPHABET[remainder];
}
while (outputStart < encoded.length && encoded[outputStart] == ENCODED_ZERO) {
outputStart++;
}
while (zeros-- > 0) {
encoded[--outputStart] = ENCODED_ZERO;
}
return new String(
encoded,
outputStart,
encoded.length - outputStart
);
}
private static int divmod58(byte[] number, int startAt) {
int remainder = 0;
for (int i = startAt; i < number.length; i++) {
int digit = number[i] & 0xff;
int temp = remainder * 256 + digit;
number[i] = (byte) (temp / 58);
remainder = temp % 58;
}
return remainder;
}
private static final char[] ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
private static final char ENCODED_ZERO =
ALPHABET[0];
}
@@ -0,0 +1,50 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.codec.Base58Codec;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.solana.SolanaAccountInfo;
import java.util.Base64;
class AnchorIdlJupiterPerpsCustodyDecoder {
ΩSPLMintAddressΩ decodeMint(
SolanaAccountInfo custodyAccountInfo
) {
byte[] data = Base64.getDecoder().decode(custodyAccountInfo.dataBase64());
if (data.length < MINT_OFFSET + PUBLIC_KEY_LENGTH) {
throw new IllegalArgumentException(
"Jupiter Perps custody account data is too short: " + data.length
);
}
return readPublicKey(data, MINT_OFFSET);
}
private ΩSPLMintAddressΩ readPublicKey(
byte[] data,
int offset
) {
byte[] publicKeyBytes = new byte[PUBLIC_KEY_LENGTH];
System.arraycopy(
data,
offset,
publicKeyBytes,
0,
PUBLIC_KEY_LENGTH
);
return base58.encode(publicKeyBytes);
}
private static final int ANCHOR_DISCRIMINATOR_LENGTH = 8;
private static final int PUBLIC_KEY_LENGTH = 32;
private static final int MINT_OFFSET =
ANCHOR_DISCRIMINATOR_LENGTH
+ PUBLIC_KEY_LENGTH; // pool
private static final Base58Codec base58 = new Base58CodecImpl();
}
@@ -0,0 +1,114 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.codec.Base58Codec;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import com.r35157.libs.solana.SolanaAccountInfo;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Base64;
class AnchorIdlJupiterPerpsPositionDecoder {
JupiterPerpsPosition decode(
ΩJupiterPerpsPositionAccountΩ positionAccount,
SolanaAccountInfo accountInfo,
ΩSPLMintAddressΩ tradedTokenMint
) {
byte[] data = Base64.getDecoder().decode(accountInfo.dataBase64());
if (data.length < PRICE_OFFSET + U64_LENGTH) {
throw new IllegalArgumentException(
"Jupiter Perps position account data is too short: " + data.length
);
}
JupiterPerpsPositionDirection direction =
decodeDirection(data[SIDE_OFFSET]);
long rawEntryPrice = ByteBuffer
.wrap(data, PRICE_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCPriceΩ entryPrice = BigDecimal
.valueOf(rawEntryPrice)
.movePointLeft(6);
JupiterPerpsPosition pos = new JupiterPerpsPosition(
positionAccount,
entryPrice,
direction,
tradedTokenMint
);
return pos;
}
ΩSolanaAddressΩ decodeCustodyAccount(SolanaAccountInfo accountInfo) {
byte[] data = Base64.getDecoder().decode(accountInfo.dataBase64());
if (data.length < CUSTODY_OFFSET + PUBLIC_KEY_LENGTH) {
throw new IllegalArgumentException(
"Jupiter Perps position account data is too short: " + data.length
);
}
return readPublicKey(
data,
CUSTODY_OFFSET
);
}
private JupiterPerpsPositionDirection decodeDirection(
byte rawSide
) {
// Jupiter Perps position side values are encoded as 1 = LONG, 2 = SHORT.
JupiterPerpsPositionDirection direction = switch (rawSide) {
case 1 -> JupiterPerpsPositionDirection.LONG;
case 2 -> JupiterPerpsPositionDirection.SHORT;
default -> throw new IllegalArgumentException(
"Unknown Jupiter Perps position side: " + rawSide
);
};
return direction;
}
private ΩSolanaAddressΩ readPublicKey(
byte[] data,
int offset
) {
byte[] publicKeyBytes = new byte[PUBLIC_KEY_LENGTH];
System.arraycopy(
data,
offset,
publicKeyBytes,
0,
PUBLIC_KEY_LENGTH
);
return base58.encode(publicKeyBytes);
}
private static final int ANCHOR_DISCRIMINATOR_LENGTH = 8;
private static final int PUBLIC_KEY_LENGTH = 32;
private static final int I64_LENGTH = 8;
private static final int SIDE_ENUM_LENGTH = 1;
private static final int U64_LENGTH = 8;
private static final int OWNER_OFFSET = ANCHOR_DISCRIMINATOR_LENGTH;
private static final int POOL_OFFSET = OWNER_OFFSET + PUBLIC_KEY_LENGTH;
private static final int CUSTODY_OFFSET = POOL_OFFSET + PUBLIC_KEY_LENGTH;
private static final int COLLATERAL_CUSTODY_OFFSET = CUSTODY_OFFSET + PUBLIC_KEY_LENGTH; // custody
private static final int OPEN_TIME_OFFSET = COLLATERAL_CUSTODY_OFFSET + PUBLIC_KEY_LENGTH;
private static final int UPDATE_TIME_OFFSET = OPEN_TIME_OFFSET + I64_LENGTH; // openTime
private static final int SIDE_OFFSET = UPDATE_TIME_OFFSET + I64_LENGTH;
private static final int PRICE_OFFSET = SIDE_OFFSET + SIDE_ENUM_LENGTH;
private static final Base58Codec base58 = new Base58CodecImpl();
}
@@ -0,0 +1,99 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
public AnchorIdlJupiterPerpsServiceImpl(
SolanaBlockChain solanaBlockChain
) {
this.solanaBlockChain = solanaBlockChain;
this.positionDecoder = new AnchorIdlJupiterPerpsPositionDecoder();
this.custodyDecoder = new AnchorIdlJupiterPerpsCustodyDecoder();
}
@Override
public JupiterPerpsPosition getPosition(ΩJupiterPerpsPositionAccountΩ positionAccount)
throws IOException, InterruptedException {
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionAccount);
if (accountInfo == null) {
throw new IllegalArgumentException("Jupiter Perps position account does not exist: " + positionAccount);
}
if (!JUPITER_PERPS_PROGRAM_ID.equals(accountInfo.owner())) {
throw new IllegalArgumentException(
"Account is not owned by Jupiter Perps program: " + positionAccount
);
}
ΩSPLMintAddressΩ tradedTokenMint = getTradedTokenMint(accountInfo);
JupiterPerpsPosition pos = positionDecoder.decode(positionAccount, accountInfo, tradedTokenMint);
return pos;
}
@Override
public Set<JupiterPerpsPosition> getOpenPositions(ΩSolanaWalletIdΩ owner)
throws IOException, InterruptedException {
Set<SolanaAccountInfo> accountInfos = solanaBlockChain.getProgramAccounts(
JUPITER_PERPS_PROGRAM_ID,
Set.of(new SolanaProgramAccountMemcmpFilter(
POSITION_OWNER_OFFSET,
owner
))
);
Set<JupiterPerpsPosition> positions = new HashSet<>();
for (SolanaAccountInfo accountInfo : accountInfos) {
ΩSolanaAddressΩ address = accountInfo.address();
ΩSolanaProgramIdΩ programId = accountInfo.owner();
if (!JUPITER_PERPS_PROGRAM_ID.equals(programId)) {
String errorMsg = "Account '" + address + "' is not owned by Jupiter Perps program '" +
programId + "'";
throw new IllegalArgumentException(errorMsg);
}
ΩSPLMintAddressΩ tradedTokenMint = getTradedTokenMint(accountInfo);
JupiterPerpsPosition position = positionDecoder.decode(address, accountInfo, tradedTokenMint);
positions.add(position);
}
return Set.copyOf(positions);
}
private ΩSPLMintAddressΩ getTradedTokenMint(SolanaAccountInfo positionAccountInfo)
throws IOException, InterruptedException
{
ΩSolanaAddressΩ custodyAccount = positionDecoder.decodeCustodyAccount(positionAccountInfo);
SolanaAccountInfo custodyAccountInfo = solanaBlockChain.getAccountInfo(custodyAccount);
if (custodyAccountInfo == null) {
throw new IllegalArgumentException(
"Jupiter Perps custody account does not exist: " + custodyAccount
);
}
ΩSPLMintAddressΩ mintAddress = custodyDecoder.decodeMint(custodyAccountInfo);
return mintAddress;
}
private static final ΩJupiterPerpsProgramIdΩ JUPITER_PERPS_PROGRAM_ID =
"PERPHjGBqRHArX4DySjwM6UJHiR3sWAatqfdBS2qQJu";
private static final int POSITION_OWNER_OFFSET = 8;
private final SolanaBlockChain solanaBlockChain;
private final AnchorIdlJupiterPerpsPositionDecoder positionDecoder;
private final AnchorIdlJupiterPerpsCustodyDecoder custodyDecoder;
}
@@ -1,146 +0,0 @@
package com.r35157.libs.solana;
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Provides read-oriented access to the Solana blockchain.
*
* <p>This interface exposes the Solana operations needed by higher-level
* integrations. It can fetch native SOL balances, SPL token holdings, NFT-like
* token holding candidates, account information and program derived addresses.</p>
*
* <p>The interface is intentionally generic and does not contain Raydium-specific
* logic. Higher-level integrations are expected to interpret Solana accounts,
* token holdings and derived addresses according to their own domain rules.</p>
*/
public interface SolanaBlockChain {
/**
* Fetches the native SOL balance for a Solana address.
*
* @param address the Solana address to inspect
* @return the native SOL balance for the address
* @throws IOException if the balance could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
*/
ΩSolanaAmountΩ getBalanceInSolana(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
/**
* Fetches the native SOL balance for a Solana address in lamports.
*
* <p>Lamports are the smallest unit of native SOL.</p>
*
* @param address the Solana address to inspect
* @return the native SOL balance for the address in lamports
* @throws IOException if the balance could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
*/
ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
/**
* Fetches SPL token holdings owned by a Solana address for a specific token program.
*
* <p>The supplied token program decides which token accounts are inspected. For example,
* callers may query the original SPL Token Program or the Token-2022 Program depending
* on which token accounts they need to discover.</p>
*
* @param ownerAddress the Solana owner address whose token holdings should be inspected
* @param splProgramId the SPL token program to query
* @return a map of SPL mint addresses to token holding information
* @throws IOException if the token holdings could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching token holdings
*/
Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings(
ΩSolanaAddressΩ ownerAddress,
SolanaSPLTokenProgram splProgramId
) throws IOException, InterruptedException;
/**
* Fetches NFT-like token mint address candidates owned by a Solana address for a specific token program.
*
* <p>This method identifies token holdings that look like NFTs within the supplied token
* program. A returned address is only a candidate. Higher-level integrations are responsible
* for deciding whether the returned address has domain-specific meaning.</p>
*
* // TODO This method currently identifies candidates from the owner's token holdings only.
* // A token with zero decimals and an owner balance of one is not guaranteed to be a real NFT,
* // because the mint's total supply may still be greater than one. A future implementation
* // should verify the mint supply, for example by using Solana getTokenSupply, before treating
* // the result as a confirmed NFT.
*
* @param ownerAddress the Solana owner address whose NFT-like holdings should be inspected
* @param splProgram the SPL token program to query
* @return the NFT-like Solana mint address candidates owned by the address
* @throws IOException if the NFT candidate addresses could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching NFT candidate addresses
*/
Set<ΩSolanaNFTAddressΩ> getSolanaNFTCandidateAddresses(
ΩSolanaAddressΩ ownerAddress,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Finds a Solana program derived address for a program id and a set of seeds.
*
* <p>The seeds describe the logical inputs used to derive the address. The implementation
* is responsible for converting each seed into the byte representation required by Solana.</p>
*
* @param programId the Solana program id used to derive the address
* @param seeds the seeds used when deriving the program address
* @return the derived Solana address together with its bump value
*/
SolanaProgramDerivedAddress findProgramAddress(
ΩSolanaProgramIdΩ programId,
List<SolanaProgramAddressSeed> seeds
);
/**
* Fetches account information for a Solana account address.
*
* <p>If the account does not exist, this method returns {@code null}. If the account exists,
* the returned value contains the account address, the owning Solana program id and the
* account data encoded as Base64.</p>
*
* @param accountAddress the Solana account address to inspect
* @return account information, or {@code null} if the account does not exist
* @throws IOException if the account information could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching account information
*/
SolanaAccountInfo getAccountInfo(ΩSolanaAddressΩ accountAddress)
throws IOException, InterruptedException;
/**
* Encodes a raw 32-byte Solana address into its textual Solana address representation.
*
* <p>This method is intended for callers that have obtained raw Solana address bytes from
* account data and need the normal string representation used elsewhere in the API.</p>
*
* @param addressBytes the raw 32-byte Solana address
* @return the encoded Solana address
* @throws IllegalArgumentException if the supplied byte array is not a valid Solana address length
*/
ΩSolanaAddressΩ encodeSolanaAddress(byte[] addressBytes);
/**
* Fetches the total supply of an SPL token mint for a specific token program.
*
* <p>The supplied token program identifies which SPL token program owns the mint,
* for example the original SPL Token Program or the Token-2022 Program.</p>
*
* @param mintAddress the SPL mint address whose supply should be fetched
* @param splProgram the SPL token program to query
* @return the SPL token supply for the mint
* @throws IOException if the token supply could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching token supply
*/
SPLTokenSupply getSPLTokenSupply(
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
}
@@ -331,6 +331,113 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
);
}
@Override
public Set<SolanaAccountInfo> getProgramAccounts(
ΩSolanaProgramIdΩ programId,
Set<SolanaProgramAccountMemcmpFilter> filters
) throws IOException, InterruptedException {
String jsonBody = createGetProgramAccountsBody(programId, filters);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(RPC_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = sendThrottled(request);
if (response.statusCode() != 200) {
throw new IOException("RPC call failed: HTTP " + response.statusCode() + "\n" + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
if (root.has("error")) {
throw new IOException("Solana RPC error: " + root.get("error").toPrettyString());
}
JsonNode result = root.path("result");
if (!result.isArray()) {
throw new IOException("getProgramAccounts response did not contain result array!");
}
Set<SolanaAccountInfo> accountInfos = new HashSet<>();
for (JsonNode accountNode : result) {
JsonNode pubkeyNode = accountNode.path("pubkey");
JsonNode account = accountNode.path("account");
if (!pubkeyNode.isTextual()) {
throw new IOException("getProgramAccounts response contained account without textual pubkey!");
}
JsonNode ownerNode = account.path("owner");
if (!ownerNode.isTextual()) {
throw new IOException("getProgramAccounts response contained account without textual owner!");
}
JsonNode dataNode = account.path("data");
if (!dataNode.isArray() || dataNode.isEmpty() || !dataNode.get(0).isTextual()) {
throw new IOException("getProgramAccounts response contained account without base64 data!");
}
accountInfos.add(new SolanaAccountInfo(
pubkeyNode.asText(),
ownerNode.asText(),
dataNode.get(0).asText()
));
}
return Set.copyOf(accountInfos);
}
private String createGetProgramAccountsBody(
ΩSolanaProgramIdΩ programId,
Set<SolanaProgramAccountMemcmpFilter> filters
) throws IOException {
StringBuilder filtersJson = new StringBuilder();
boolean first = true;
for (SolanaProgramAccountMemcmpFilter filter : filters) {
if (!first) {
filtersJson.append(",");
}
filtersJson.append("""
{
"memcmp": {
"offset": %d,
"bytes": "%s"
}
}
""".formatted(
filter.offset(),
filter.bytes()
));
first = false;
}
return """
{
"jsonrpc": "2.0",
"id": 1,
"method": "getProgramAccounts",
"params": [
"%s",
{
"commitment": "finalized",
"encoding": "base64",
"filters": [
%s
]
}
]
}
""".formatted(programId, filtersJson);
}
private synchronized HttpResponse<String> sendThrottled(HttpRequest request) throws IOException, InterruptedException {
waitBeforeRemoteCall();
@@ -1,5 +1,10 @@
package com.r35157.nenjim.hubd.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
import com.r35157.nenjim.hubd.ctx.Context;
import com.r35157.nenjim.hubd.NenjimHub;
import org.slf4j.Logger;
@@ -12,12 +17,21 @@ import com.r35157.nenjim.hubd.journal.JournalManager;
import com.r35157.nenjim.hubd.impl.ref.JournalManagerImpl;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
public class Main {
// TODO: Consider if we really need a Main class or we just need to move the main method to NenjimHubImpl?
static void main(String[] args) throws Exception {
NenjimHubImpl nenjimHub = new NenjimHubImpl();
/*
SolanaBlockChain sbc = new SolanaBlockChainImpl();
JupiterPerpsService jupiter = new AnchorIdlJupiterPerpsServiceImpl(sbc);
ΩSolanaWalletIdΩ walletId = "vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf";
Set<JupiterPerpsPosition> positions = jupiter.getOpenPositions(walletId);
int a=0;
*/
nenjimHub.awaitShutdown();
/* try {