From f1a5c87ad3ecb9fb15ede895d025fb1abf655c135c2ee8bcd7d86236cf39ca64 Mon Sep 17 00:00:00 2001 From: Minimons Date: Wed, 10 Jun 2026 08:41:11 +0200 Subject: [PATCH] Add Solana implementation --- .../impl/ref/SolanaBlockChainImpl.tjava | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava diff --git a/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava b/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava new file mode 100644 index 0000000..24ad2fb --- /dev/null +++ b/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava @@ -0,0 +1,634 @@ +package com.r35157.libs.solana.impl.ref; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.r35157.libs.solana.*; +import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress; +import com.r35157.libs.solana.valuetypes.WellKnownCurrencyTypes; +import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; +import com.r35157.libs.valuetypes.basic.CurrencyType; +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static com.r35157.libs.solana.SolanaConstants.RPC_URL; + +public class SolanaBlockChainImpl implements SolanaBlockChain { + + public SolanaBlockChainImpl() { + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + @Override + public ΩSolanaAmountΩ getBalanceInSolana(ΩSolanaAddressΩ address) throws IOException, InterruptedException { + ΩlamportsΩ lamport = getBalanceInLamport(address); + ΩAmountΩ bd = ΩAmountΩ.valueOf(lamport).divide(LAMPORTS_PER_SOL); + CurrencyType type = WellKnownCurrencyTypes.SOLANA.getCurrencyType(); + ΩSolanaAmountΩ sa = new ΩSolanaAmountΩ(bd, type); + + return sa; + } + + @Override + public ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException { + String jsonBody = """ + { + "jsonrpc":"2.0", + "id":1, + "method":"getBalance", + "params":[ + "%s", + { + "commitment":"finalized" + } + ] + } + """.formatted(address); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RPC_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException("Unexpected HTTP status: " + response.statusCode() + ", body: " + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + + JsonNode errorNode = root.get("error"); + if (errorNode != null) { + throw new IOException("RPC error: " + errorNode.toPrettyString()); + } + + JsonNode valueNode = root.path("result").path("value"); + if (valueNode.isMissingNode() || !valueNode.isNumber()) { + throw new IOException("Could not read balance from response: " + response.body()); + } + + return valueNode.longValue(); + } + + @Override + public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings( + ΩSolanaAddressΩ ownerAddress, + SolanaSPLTokenProgram splProgram + ) throws IOException, InterruptedException { + + String body = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "getTokenAccountsByOwner", + "params": [ + "%s", + { + "programId": "%s" + }, + { + "encoding": "jsonParsed" + } + ] + } + """.formatted(ownerAddress, splProgram.getAddress()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RPC_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException("RPC call failed: HTTP " + response.statusCode() + "\n" + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + + if (root.has("error")) { + throw new IOException("Solana RPC error: " + root.get("error").toPrettyString()); + } + + Map result = new HashMap<>(); + + JsonNode accounts = root.path("result").path("value"); + + for (JsonNode account : accounts) { + String tokenAccount = account.path("pubkey").asText(); + + JsonNode parsedInfo = account + .path("account") + .path("data") + .path("parsed") + .path("info"); + + String mint = parsedInfo.path("mint").asText(); + JsonNode tokenAmount = parsedInfo.path("tokenAmount"); + String rawAmount = tokenAmount.path("amount").asText(); + int decimals = tokenAmount.path("decimals").asInt(); + String uiAmountString = tokenAmount.path("uiAmountString").asText(); + BigDecimal uiAmount = new BigDecimal(uiAmountString); + + result.put(mint, new SPLTokenHolding( + tokenAccount, + mint, + uiAmount, + rawAmount, + decimals, + splProgram.getAddress() + )); + } + + return result; + } + + @Override + public Set<ΩSolanaNFTAddressΩ> getSolanaNFTCandidateAddresses( + ΩSolanaAddressΩ ownerAddress, + SolanaSPLTokenProgram splProgram + ) throws IOException, InterruptedException { + Map<ΩSPLMintAddressΩ, SPLTokenHolding> tokenHoldings = + getSPLTokenHoldings(ownerAddress, splProgram); + + Set<ΩSolanaNFTAddressΩ> nftCandidateAddresses = new HashSet<>(); + + for (SPLTokenHolding tokenHolding : tokenHoldings.values()) { + if (isSolanaNFTCandidate(tokenHolding)) { + nftCandidateAddresses.add(tokenHolding.mintAddress()); + } + } + + return Set.copyOf(nftCandidateAddresses); + } + + @Override + public SolanaProgramDerivedAddress findProgramAddress( + ΩSolanaProgramIdΩ programId, + List seeds + ) { + List seedBytes = toSeedBytes(seeds); + + return findProgramAddressFromSeedBytes(programId, seedBytes); + } + + @Override + public SolanaAccountInfo getAccountInfo( + ΩSolanaAddressΩ accountAddress + ) throws IOException, InterruptedException { + String jsonBody = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "getAccountInfo", + "params": [ + "%s", + { + "commitment": "finalized", + "encoding": "base64" + } + ] + } + """.formatted(accountAddress); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RPC_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException("RPC call failed: HTTP " + response.statusCode() + "\n" + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + + if (root.has("error")) { + throw new IOException("Solana RPC error: " + root.get("error").toPrettyString()); + } + + JsonNode value = root.path("result").path("value"); + + if (value.isMissingNode() || value.isNull()) { + return null; + } + + JsonNode ownerNode = value.path("owner"); + if (ownerNode.isMissingNode() || !ownerNode.isTextual()) { + throw new IOException("getAccountInfo response did not contain textual owner field!"); + } + + JsonNode dataNode = value.path("data"); + if (!dataNode.isArray() || dataNode.isEmpty() || !dataNode.get(0).isTextual()) { + throw new IOException("getAccountInfo response did not contain base64 data!"); + } + + return new SolanaAccountInfo( + accountAddress, + ownerNode.asText(), + dataNode.get(0).asText() + ); + } + + @Override + public ΩSolanaAddressΩ encodeSolanaAddress(byte[] addressBytes) { + if (addressBytes.length != 32) { + throw new IllegalArgumentException( + "Solana address must be 32 bytes, but was " + addressBytes.length + ); + } + + return base58Encode(addressBytes); + } + + @Override + public SPLTokenSupply getSPLTokenSupply( + ΩSPLMintAddressΩ mintAddress, + SolanaSPLTokenProgram splProgram + ) throws IOException, InterruptedException { + String jsonBody = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "getTokenSupply", + "params": [ + "%s", + { + "commitment": "finalized" + } + ] + } + """.formatted(mintAddress); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RPC_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException("RPC call failed: HTTP " + response.statusCode() + " " + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + + if (root.has("error")) { + throw new IOException("Solana RPC error: " + root.get("error").toPrettyString()); + } + + JsonNode value = root.path("result").path("value"); + + if (value.isMissingNode() || value.isNull()) { + throw new IOException("getTokenSupply response did not contain a value for mint: " + mintAddress); + } + + JsonNode rawAmountNode = value.path("amount"); + if (rawAmountNode.isMissingNode() || !rawAmountNode.isTextual()) { + throw new IOException("getTokenSupply response did not contain textual amount field!"); + } + + JsonNode decimalsNode = value.path("decimals"); + if (decimalsNode.isMissingNode() || !decimalsNode.isNumber()) { + throw new IOException("getTokenSupply response did not contain numeric decimals field!"); + } + + JsonNode uiAmountStringNode = value.path("uiAmountString"); + if (uiAmountStringNode.isMissingNode() || !uiAmountStringNode.isTextual()) { + throw new IOException("getTokenSupply response did not contain textual uiAmountString field!"); + } + + ΩRawAmountΩ rawAmount = rawAmountNode.asText(); + ΩamountDecimalsΩ decimals = decimalsNode.asInt(); + ΩAmountΩ uiAmount = new BigDecimal(uiAmountStringNode.asText()); + + return new SPLTokenSupply( + mintAddress, + uiAmount, + rawAmount, + decimals, + splProgram.getAddress() + ); + } + + private synchronized HttpResponse sendThrottled(HttpRequest request) throws IOException, InterruptedException { + waitBeforeRemoteCall(); + + try { + return httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + } finally { + previousRemoteCallTime = System.currentTimeMillis(); + } + } + + private void waitBeforeRemoteCall() throws InterruptedException { + long now = System.currentTimeMillis(); + long elapsed = now - previousRemoteCallTime; + + if (elapsed < MINIMUM_REMOTE_CALL_INTERVAL) { + ΩmilliSecondsΩ sleepTime = MINIMUM_REMOTE_CALL_INTERVAL - elapsed; + //System.out.println("Throttling Solana request for " + sleepTime + "ms..."); + Thread.sleep(sleepTime); + //System.out.println("Ready"); + } + } + + private boolean isSolanaNFTCandidate(SPLTokenHolding tokenHolding) { + // TODO This only checks the owner's token holding. + // A token with zero decimals and an owner balance of one is not guaranteed + // to be a real NFT, because the mint's total supply may still be greater than one. + // A future implementation should verify the mint supply, for example by using + // Solana getTokenSupply, before treating the result as a confirmed NFT. + return tokenHolding.decimals() == 0 + && "1".equals(tokenHolding.rawAmount()); + } + + private byte[] createProgramAddressBytes( + ΩSolanaProgramIdΩ programId, + List seeds + ) { + if (seeds.size() > 16) { + throw new IllegalArgumentException("A Solana program address can have at most 16 seeds."); + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + for (byte[] seed : seeds) { + if (seed.length > 32) { + throw new IllegalArgumentException("A Solana program address seed can be at most 32 bytes."); + } + + digest.update(seed); + } + + digest.update(base58Decode(programId)); + digest.update(PROGRAM_DERIVED_ADDRESS_MARKER); + + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available.", e); + } + } + + private boolean isOnEd25519Curve(byte[] encodedPoint) { + if (encodedPoint.length != 32) { + throw new IllegalArgumentException("Ed25519 encoded point must be 32 bytes."); + } + + byte[] yBytes = encodedPoint.clone(); + + int signBit = (yBytes[31] & 0x80) >>> 7; + yBytes[31] &= 0x7F; + + BigInteger y = littleEndianToBigInteger(yBytes); + + if (y.compareTo(ED25519_P) >= 0) { + return false; + } + + BigInteger ySquared = y.multiply(y).mod(ED25519_P); + + BigInteger numerator = ySquared + .subtract(BigInteger.ONE) + .mod(ED25519_P); + + BigInteger denominator = ED25519_D + .multiply(ySquared) + .add(BigInteger.ONE) + .mod(ED25519_P); + + if (denominator.equals(BigInteger.ZERO)) { + return false; + } + + BigInteger xSquared = numerator + .multiply(denominator.modInverse(ED25519_P)) + .mod(ED25519_P); + + if (xSquared.equals(BigInteger.ZERO)) { + return signBit == 0; + } + + return isQuadraticResidue(xSquared); + } + + private boolean isQuadraticResidue(BigInteger value) { + return value.modPow( + ED25519_P.subtract(BigInteger.ONE).shiftRight(1), + ED25519_P + ).equals(BigInteger.ONE); + } + + private BigInteger littleEndianToBigInteger(byte[] littleEndianBytes) { + byte[] bigEndianBytes = new byte[littleEndianBytes.length + 1]; + + for (int i = 0; i < littleEndianBytes.length; i++) { + bigEndianBytes[bigEndianBytes.length - 1 - i] = littleEndianBytes[i]; + } + + return new BigInteger(bigEndianBytes); + } + + private static String base58Encode(byte[] inputBytes) { + if (inputBytes.length == 0) { + return ""; + } + + byte[] input = Arrays.copyOf(inputBytes, inputBytes.length); + + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) { + zeros++; + } + + char[] encoded = new char[input.length * 2]; + int outputStart = encoded.length; + + int inputStart = zeros; + while (inputStart < input.length) { + int mod = divmod(input, inputStart, 256, 58); + + if (input[inputStart] == 0) { + inputStart++; + } + + encoded[--outputStart] = BASE58_ALPHABET[mod]; + } + + while (outputStart < encoded.length && encoded[outputStart] == BASE58_ALPHABET[0]) { + outputStart++; + } + + while (zeros-- > 0) { + encoded[--outputStart] = BASE58_ALPHABET[0]; + } + + return new String(encoded, outputStart, encoded.length - outputStart); + } + + private static byte[] base58Decode(String input) { + if (input.isEmpty()) { + return new byte[0]; + } + + byte[] input58 = new byte[input.length()]; + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + + if (c >= 128 || BASE58_INDEXES[c] < 0) { + throw new IllegalArgumentException("Invalid Base58 character: " + c); + } + + input58[i] = (byte) BASE58_INDEXES[c]; + } + + int zeros = 0; + while (zeros < input58.length && input58[zeros] == 0) { + zeros++; + } + + byte[] decoded = new byte[input.length()]; + int outputStart = decoded.length; + + int inputStart = zeros; + while (inputStart < input58.length) { + int mod = divmod(input58, inputStart, 58, 256); + + if (input58[inputStart] == 0) { + inputStart++; + } + + decoded[--outputStart] = (byte) mod; + } + + while (outputStart < decoded.length && decoded[outputStart] == 0) { + outputStart++; + } + + return Arrays.copyOfRange(decoded, outputStart - zeros, decoded.length); + } + + private static int divmod(byte[] number, int firstDigit, int base, int divisor) { + int remainder = 0; + + for (int i = firstDigit; i < number.length; i++) { + int digit = number[i] & 0xFF; + int temporary = remainder * base + digit; + + number[i] = (byte) (temporary / divisor); + remainder = temporary % divisor; + } + + return remainder; + } + + private List toSeedBytes(List seeds) { + List seedBytes = new ArrayList<>(); + + for (SolanaProgramAddressSeed seed : seeds) { + seedBytes.add(toSeedBytes(seed)); + } + + return seedBytes; + } + + private byte[] toSeedBytes(SolanaProgramAddressSeed seed) { + return switch (seed.kind()) { + case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8); + + case SOLANA_ADDRESS -> { + byte[] addressBytes = base58Decode(seed.value()); + + if (addressBytes.length != 32) { + throw new IllegalArgumentException( + "Solana address seed must decode to 32 bytes, but was " + addressBytes.length + ); + } + + yield addressBytes; + } + }; + } + + private SolanaProgramDerivedAddress findProgramAddressFromSeedBytes( + ΩSolanaProgramIdΩ programId, + List seedBytes + ) { + if (seedBytes.size() > 15) { + throw new IllegalArgumentException( + "findProgramAddress(...) can have at most 15 caller-provided seeds because the bump is added as the final seed." + ); + } + + for (int bump = 255; bump >= 0; bump--) { + List seedBytesWithBump = new ArrayList<>(seedBytes); + seedBytesWithBump.add(new byte[] { (byte) bump }); + + byte[] candidateAddress = createProgramAddressBytes( + programId, + seedBytesWithBump + ); + + if (!isOnEd25519Curve(candidateAddress)) { + return new SolanaProgramDerivedAddress( + base58Encode(candidateAddress), + bump + ); + } + } + + throw new IllegalArgumentException("Could not find valid program derived address."); + } + + private static int[] createBase58Indexes() { + int[] indexes = new int[128]; + Arrays.fill(indexes, -1); + + for (int i = 0; i < BASE58_ALPHABET.length; i++) { + indexes[BASE58_ALPHABET[i]] = i; + } + + return indexes; + } + + private static final ΩAmountΩ LAMPORTS_PER_SOL = new BigDecimal("1000000000"); + private static final ΩmilliSecondsΩ MINIMUM_REMOTE_CALL_INTERVAL = 5000L; + private static final byte[] PROGRAM_DERIVED_ADDRESS_MARKER = "ProgramDerivedAddress".getBytes(StandardCharsets.UTF_8); + private static final String BASE58_ALPHABET_STRING = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + private static final char[] BASE58_ALPHABET = BASE58_ALPHABET_STRING.toCharArray(); + private static final int[] BASE58_INDEXES = createBase58Indexes(); + private static final BigInteger ED25519_P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19)); + private static final BigInteger ED25519_D = + BigInteger.valueOf(-121665) + .multiply(BigInteger.valueOf(121666).modInverse(ED25519_P)) + .mod(ED25519_P); + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private ΩmilliSecondsΩ previousRemoteCallTime = 0L; +}