From 280024c3402ad87d60dd4dbed0738896b7aa5cc8 Mon Sep 17 00:00:00 2001 From: Minimons Date: Mon, 27 Jul 2026 20:51:51 +0200 Subject: [PATCH] 48: Add SOL transfer support to SolanaWallet --- build.gradle.kts | 6 + .../cryptowallet/solana/SolanaWallet.tjava | 40 ++- .../SolanaTransferTransactionBuilder.tjava | 235 ++++++++++++++++++ .../solana/impl/ref/SolanaWalletImpl.tjava | 153 +++++++++++- .../r35157/libs/solana/SolanaBlockChain.tjava | 25 +- .../libs/solana/SolanaLatestBlockhash.tjava | 13 + .../impl/cached/CachedSolanaBlockChain.tjava | 16 +- .../impl/ref/SolanaBlockChainImpl.tjava | 127 ++++++++++ .../solana/impl/ref/SolanaWalletImplTest.java | 190 ++++++++++++++ 9 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava create mode 100644 src/main/tjava/com/r35157/libs/solana/SolanaLatestBlockhash.tjava create mode 100644 src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 05d3f38..3c1bbad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { 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") + + testImplementation("org.junit.jupiter:junit-jupiter:5.13.4") } java { @@ -69,6 +71,10 @@ tasks.withType().configureEach { ) } +tasks.withType().configureEach { + useJUnitPlatform() +} + val generatedDetagMain = layout.buildDirectory.dir("generated/sources/detag/main/java") val cleanGeneratedDetagMain = tasks.register("cleanGeneratedDetagMain") { diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava index ad94282..c0fb335 100644 --- a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava +++ b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava @@ -52,6 +52,44 @@ public interface SolanaWallet { @NotNull SolanaSPLTokenProgram splProgram ) throws IOException, InterruptedException; + /** + * Builds an unsigned transaction that transfers a specific amount of SOL. + * + *

The current wallet is used as fee payer and required signer. This + * method does not sign or submit the transaction.

+ * + * @param recipient the wallet that should receive the SOL + * @param amount the amount of SOL to transfer + * @return the unsigned serialized transfer transaction + * @throws IllegalArgumentException if the recipient or amount is invalid + * @throws IOException if a recent blockhash cannot be fetched + * @throws InterruptedException if the calling thread is interrupted + */ + @NotNull + SolanaUnsignedTransaction buildSolanaTransferTransaction( + @NotNull ΩSolanaWalletIdΩ recipient, + @NotNull ΩSolanaAmountΩ amount + ) throws IOException, InterruptedException; + + /** + * Builds an unsigned transaction that transfers the wallet's complete + * native SOL balance after paying the exact transaction fee. + * + *

The current wallet is used as fee payer and required signer. This + * method does not sign or submit the transaction.

+ * + * @param recipient the wallet that should receive the SOL + * @return the unsigned serialized transfer transaction + * @throws IllegalArgumentException if the recipient is invalid + * @throws IllegalStateException if the balance cannot cover a transfer + * @throws IOException if the balance, blockhash or fee cannot be fetched + * @throws InterruptedException if the calling thread is interrupted + */ + @NotNull + SolanaUnsignedTransaction buildSolanaTransferAllTransaction( + @NotNull ΩSolanaWalletIdΩ recipient + ) throws IOException, InterruptedException; + /** * Signs an unsigned Solana transaction. * @@ -70,4 +108,4 @@ public interface SolanaWallet { SolanaSignedTransaction signTransaction( @NotNull SolanaUnsignedTransaction transaction ) throws IOException, InterruptedException; -} \ No newline at end of file +} diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava new file mode 100644 index 0000000..b5b6719 --- /dev/null +++ b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava @@ -0,0 +1,235 @@ +package com.r35157.cryptowallet.solana.impl.ref; + +import com.r35157.libs.solana.SolanaLatestBlockhash; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Base64; + +final class SolanaTransferTransactionBuilder { + private SolanaTransferTransactionBuilder() { + } + + static SerializedTransfer build( + ΩSolanaWalletIdΩ sender, + ΩSolanaWalletIdΩ recipient, + SolanaLatestBlockhash latestBlockhash, + ΩlamportsΩ lamports + ) { + if (lamports <= 0) { + throw new IllegalArgumentException( + "SOL transfer amount must be greater than zero lamports" + ); + } + + byte[] senderBytes = decodeAddress("sender", sender); + byte[] recipientBytes = decodeAddress("recipient", recipient); + byte[] systemProgramBytes = decodeAddress( + "System Program", + SYSTEM_PROGRAM_ADDRESS + ); + byte[] blockhashBytes = decodeAddress( + "blockhash", + latestBlockhash.blockhash() + ); + + ByteArrayOutputStream message = new ByteArrayOutputStream(); + + message.write(1); + message.write(0); + message.write(1); + + writeCompactU16(message, 3); + message.writeBytes(senderBytes); + message.writeBytes(recipientBytes); + message.writeBytes(systemProgramBytes); + message.writeBytes(blockhashBytes); + + writeCompactU16(message, 1); + message.write(2); + writeCompactU16(message, 2); + message.write(0); + message.write(1); + + byte[] instructionData = ByteBuffer + .allocate(12) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(SYSTEM_TRANSFER_INSTRUCTION) + .putLong(lamports) + .array(); + + writeCompactU16(message, instructionData.length); + message.writeBytes(instructionData); + + byte[] messageBytes = message.toByteArray(); + ByteArrayOutputStream transaction = new ByteArrayOutputStream(); + + writeCompactU16(transaction, 1); + transaction.writeBytes(new byte[SOLANA_SIGNATURE_LENGTH]); + transaction.writeBytes(messageBytes); + + ΩBase64StringΩ serializedMessage = + Base64.getEncoder().encodeToString(messageBytes); + ΩBase64StringΩ serializedTransaction = + Base64.getEncoder().encodeToString( + transaction.toByteArray() + ); + + return new SerializedTransfer( + serializedMessage, + serializedTransaction + ); + } + + private static byte[] decodeAddress( + String description, + String address + ) { + if (address == null || address.isBlank()) { + throw new IllegalArgumentException( + "Solana " + description + " must not be blank" + ); + } + + byte[] decoded = base58Decode(address); + + if (decoded.length != SOLANA_ADDRESS_LENGTH) { + throw new IllegalArgumentException( + "Solana " + + description + + " must decode to " + + SOLANA_ADDRESS_LENGTH + + " bytes, but was " + + decoded.length + ); + } + + return decoded; + } + + private static void writeCompactU16( + ByteArrayOutputStream output, + int value + ) { + if (value < 0 || value > 0xffff) { + throw new IllegalArgumentException( + "Compact-u16 value is outside the supported range: " + + value + ); + } + + int remaining = value; + + do { + int nextByte = remaining & 0x7f; + remaining >>>= 7; + + if (remaining != 0) { + nextByte |= 0x80; + } + + output.write(nextByte); + } while (remaining != 0); + } + + private static byte[] base58Decode(String input) { + byte[] input58 = new byte[input.length()]; + + for (int i = 0; i < input.length(); i++) { + char character = input.charAt(i); + + if (character >= BASE58_INDEXES.length + || BASE58_INDEXES[character] < 0) { + throw new IllegalArgumentException( + "Invalid Base58 character: " + character + ); + } + + input58[i] = (byte) BASE58_INDEXES[character]; + } + + 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 remainder = divmod( + input58, + inputStart, + 58, + 256 + ); + + if (input58[inputStart] == 0) { + inputStart++; + } + + decoded[--outputStart] = (byte) remainder; + } + + 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 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; + } + + record SerializedTransfer( + ΩBase64StringΩ serializedMessage, + ΩBase64StringΩ serializedTransaction + ) { + } + + private static final String SYSTEM_PROGRAM_ADDRESS = + "11111111111111111111111111111111"; + private static final int SYSTEM_TRANSFER_INSTRUCTION = 2; + private static final int SOLANA_ADDRESS_LENGTH = 32; + private static final int SOLANA_SIGNATURE_LENGTH = 64; + private static final char[] BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + .toCharArray(); + private static final int[] BASE58_INDEXES = createBase58Indexes(); +} diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava index e27adb8..1305903 100644 --- a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava +++ b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava @@ -5,10 +5,13 @@ 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.SolanaLatestBlockhash; 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.CurrencyType; import com.r35157.libs.valuetypes.basic.MoneyAmount; +import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,6 +55,81 @@ public class SolanaWalletImpl implements SolanaWallet { : tokenHolding.uiAmount(); } + @Override + public @NotNull SolanaUnsignedTransaction buildSolanaTransferTransaction( + @NotNull ΩSolanaWalletIdΩ recipient, + @NotNull ΩSolanaAmountΩ amount + ) throws IOException, InterruptedException { + validateRecipient(recipient); + + ΩlamportsΩ lamports = toLamports(amount); + SolanaLatestBlockhash latestBlockhash = + solanaBlockChain.getLatestBlockhash(); + + return buildSolanaTransferTransaction( + recipient, + latestBlockhash, + lamports + ); + } + + @Override + public @NotNull SolanaUnsignedTransaction buildSolanaTransferAllTransaction( + @NotNull ΩSolanaWalletIdΩ recipient + ) throws IOException, InterruptedException { + validateRecipient(recipient); + + ΩlamportsΩ balance = + solanaBlockChain.getBalanceInLamport(address); + + if (balance <= 0) { + throw new IllegalStateException( + "Wallet does not contain any SOL to transfer" + ); + } + + SolanaLatestBlockhash latestBlockhash = + solanaBlockChain.getLatestBlockhash(); + + // The transfer amount is always serialized as one fixed-width u64. + // Replacing it with balance - fee therefore cannot change the fee. + SolanaTransferTransactionBuilder.SerializedTransfer feeCandidate = + SolanaTransferTransactionBuilder.build( + address, + recipient, + latestBlockhash, + balance + ); + + ΩlamportsΩ fee = solanaBlockChain.getFeeForMessage( + feeCandidate.serializedMessage() + ); + + if (fee < 0) { + throw new IllegalStateException( + "Solana transaction fee must not be negative" + ); + } + + ΩlamportsΩ transferableLamports = balance - fee; + + if (transferableLamports <= 0) { + throw new IllegalStateException( + "Wallet balance of " + + balance + + " lamports cannot cover the transaction fee of " + + fee + + " lamports and a positive transfer amount" + ); + } + + return buildSolanaTransferTransaction( + recipient, + latestBlockhash, + transferableLamports + ); + } + @Override public @NotNull SolanaSignedTransaction signTransaction( @NotNull SolanaUnsignedTransaction transaction @@ -137,6 +215,75 @@ public class SolanaWalletImpl implements SolanaWallet { return sst; } + private SolanaUnsignedTransaction buildSolanaTransferTransaction( + ΩSolanaWalletIdΩ recipient, + SolanaLatestBlockhash latestBlockhash, + ΩlamportsΩ lamports + ) { + SolanaTransferTransactionBuilder.SerializedTransfer transfer = + SolanaTransferTransactionBuilder.build( + address, + recipient, + latestBlockhash, + lamports + ); + + return new SolanaUnsignedTransaction( + transfer.serializedTransaction(), + latestBlockhash.blockhash(), + latestBlockhash.lastValidBlockHeight() + ); + } + + private ΩlamportsΩ toLamports(ΩSolanaAmountΩ amount) { + if (amount == null || amount.amount() == null) { + throw new IllegalArgumentException( + "SOL transfer amount must not be null" + ); + } + + if (!SOLANA_CURRENCY.equals(amount.currencyType())) { + throw new IllegalArgumentException( + "SOL transfer amount must use the SOL currency type" + ); + } + + BigDecimal lamports = + amount.amount().multiply(LAMPORTS_PER_SOL); + + try { + ΩlamportsΩ result = lamports.longValueExact(); + + if (result <= 0) { + throw new IllegalArgumentException( + "SOL transfer amount must be greater than zero" + ); + } + + return result; + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "SOL transfer amount must be expressible as a whole " + + "number of lamports", + e + ); + } + } + + private void validateRecipient(ΩSolanaWalletIdΩ recipient) { + if (recipient == null || recipient.isBlank()) { + throw new IllegalArgumentException( + "SOL transfer recipient must not be blank" + ); + } + + if (recipient.equals(address)) { + throw new IllegalArgumentException( + "SOL transfer recipient must be different from the sender" + ); + } + } + private record SignerResponse( String signer, ΩBase64StringΩ signedTransaction @@ -144,8 +291,12 @@ public class SolanaWalletImpl implements SolanaWallet { } private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final BigDecimal LAMPORTS_PER_SOL = + new BigDecimal("1000000000"); + private static final CurrencyType SOLANA_CURRENCY = + WellKnownCurrencyTypes.SOLANA.getCurrencyType(); private final ΩSolanaWalletIdΩ address; private final String signerKeyName; private final SolanaBlockChain solanaBlockChain; -} \ No newline at end of file +} diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava index 724486a..879e2f7 100644 --- a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava @@ -43,6 +43,29 @@ public interface SolanaBlockChain { */ ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException; + /** + * Fetches a recent blockhash for building a new Solana transaction. + * + * @return the recent blockhash and its last valid block height + * @throws IOException if the blockhash could not be fetched or parsed + * @throws InterruptedException if the calling thread is interrupted + */ + SolanaLatestBlockhash getLatestBlockhash() + throws IOException, InterruptedException; + + /** + * Fetches the transaction fee for a serialized Solana message. + * + * @param serializedMessage the Base64 encoded legacy or versioned message + * @return the fee in lamports + * @throws IllegalArgumentException if the serialized message is blank + * @throws IOException if the fee could not be fetched or the blockhash has expired + * @throws InterruptedException if the calling thread is interrupted + */ + ΩlamportsΩ getFeeForMessage( + ΩBase64StringΩ serializedMessage + ) throws IOException, InterruptedException; + /** * Fetches SPL token holdings owned by a Solana address for a specific token program. * @@ -183,4 +206,4 @@ public interface SolanaBlockChain { ΩSolanaTransactionSignatureΩ sendTransaction( SolanaSignedTransaction transaction ) throws IOException, InterruptedException; -} \ No newline at end of file +} diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaLatestBlockhash.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaLatestBlockhash.tjava new file mode 100644 index 0000000..8f92b2b --- /dev/null +++ b/src/main/tjava/com/r35157/libs/solana/SolanaLatestBlockhash.tjava @@ -0,0 +1,13 @@ +package com.r35157.libs.solana; + +/** + * A recent Solana blockhash and the last block height at which it is valid. + * + * @param blockhash the recent blockhash + * @param lastValidBlockHeight the last block height at which the blockhash is valid + */ +public record SolanaLatestBlockhash( + ΩSolanaBlockhashΩ blockhash, + long lastValidBlockHeight +) { +} diff --git a/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava index d1adac0..4d371b3 100644 --- a/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava @@ -6,6 +6,7 @@ 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.SolanaLatestBlockhash; import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter; import com.r35157.libs.solana.SolanaProgramAddressSeed; import com.r35157.libs.solana.SolanaSignedTransaction; @@ -44,6 +45,19 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain { return delegate.getBalanceInLamport(address); } + @Override + public SolanaLatestBlockhash getLatestBlockhash() + throws IOException, InterruptedException { + return delegate.getLatestBlockhash(); + } + + @Override + public ΩlamportsΩ getFeeForMessage( + ΩBase64StringΩ serializedMessage + ) throws IOException, InterruptedException { + return delegate.getFeeForMessage(serializedMessage); + } + @Override public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings( ΩSolanaAddressΩ ownerAddress, @@ -238,4 +252,4 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain { private final SolanaBlockChain delegate; private final ObjectCache objectCache; -} \ No newline at end of file +} 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 index 5f5bc61..4718c63 100644 --- a/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava +++ b/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava @@ -88,6 +88,133 @@ public class SolanaBlockChainImpl implements SolanaBlockChain { return valueNode.longValue(); } + @Override + public SolanaLatestBlockhash getLatestBlockhash() + throws IOException, InterruptedException { + String jsonBody = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestBlockhash", + "params": [ + { + "commitment": "confirmed" + } + ] + } + """; + + 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( + "Solana getLatestBlockhash RPC call failed: HTTP " + + response.statusCode() + + "\n" + + response.body() + ); + } + + JsonNode root = objectMapper.readTree(response.body()); + JsonNode errorNode = root.get("error"); + + if (errorNode != null && !errorNode.isNull()) { + throw new IOException( + "Solana getLatestBlockhash RPC error: " + + errorNode.toPrettyString() + ); + } + + JsonNode valueNode = root.path("result").path("value"); + JsonNode blockhashNode = valueNode.path("blockhash"); + JsonNode lastValidBlockHeightNode = + valueNode.path("lastValidBlockHeight"); + + if (!blockhashNode.isTextual() + || blockhashNode.asText().isBlank() + || !lastValidBlockHeightNode.isIntegralNumber()) { + throw new IOException( + "Solana getLatestBlockhash response did not contain " + + "a blockhash and last valid block height: " + + response.body() + ); + } + + ΩSolanaBlockhashΩ blockhash = blockhashNode.asText(); + + return new SolanaLatestBlockhash( + blockhash, + lastValidBlockHeightNode.longValue() + ); + } + + @Override + public ΩlamportsΩ getFeeForMessage( + ΩBase64StringΩ serializedMessage + ) throws IOException, InterruptedException { + if (serializedMessage == null || serializedMessage.isBlank()) { + throw new IllegalArgumentException( + "Serialized Solana message must not be blank" + ); + } + + ObjectNode rpcRequest = objectMapper.createObjectNode(); + rpcRequest.put("jsonrpc", "2.0"); + rpcRequest.put("id", 1); + rpcRequest.put("method", "getFeeForMessage"); + + ArrayNode params = rpcRequest.putArray("params"); + params.add(serializedMessage); + params.addObject().put("commitment", "confirmed"); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RPC_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + objectMapper.writeValueAsString(rpcRequest) + )) + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException( + "Solana getFeeForMessage RPC call failed: HTTP " + + response.statusCode() + + "\n" + + response.body() + ); + } + + JsonNode root = objectMapper.readTree(response.body()); + JsonNode errorNode = root.get("error"); + + if (errorNode != null && !errorNode.isNull()) { + throw new IOException( + "Solana getFeeForMessage RPC error: " + + errorNode.toPrettyString() + ); + } + + JsonNode valueNode = root.path("result").path("value"); + + if (!valueNode.isIntegralNumber()) { + throw new IOException( + "Solana getFeeForMessage response did not contain a fee; " + + "the transaction blockhash may have expired: " + + response.body() + ); + } + + return valueNode.longValue(); + } + @Override public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings( ΩSolanaAddressΩ ownerAddress, diff --git a/src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java b/src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java new file mode 100644 index 0000000..528ac39 --- /dev/null +++ b/src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java @@ -0,0 +1,190 @@ +package com.r35157.cryptowallet.solana.impl.ref; + +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.SolanaLatestBlockhash; +import com.r35157.libs.solana.SolanaUnsignedTransaction; +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; + +import static com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes.SOLANA; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SolanaWalletImplTest { + @Test + void buildsExpectedLegacySolTransferTransaction() throws Exception { + SolanaWalletImpl wallet = new SolanaWalletImpl( + SENDER, + "unused-test-key", + createBlockChain(0, 0, new AtomicReference<>()) + ); + + SolanaUnsignedTransaction transaction = + wallet.buildSolanaTransferTransaction( + RECIPIENT, + new MoneyAmount( + new BigDecimal("0.123456789"), + SOLANA.getCurrencyType() + ) + ); + + assertEquals(EXPECTED_TRANSACTION, transaction.serializedTransaction()); + assertEquals(BLOCKHASH, transaction.blockhash()); + assertEquals( + LAST_VALID_BLOCK_HEIGHT, + transaction.lastValidBlockHeight() + ); + } + + @Test + void transferAllSubtractsExactFeeFromBalance() throws Exception { + AtomicReference feeMessage = new AtomicReference<>(); + SolanaWalletImpl wallet = new SolanaWalletImpl( + SENDER, + "unused-test-key", + createBlockChain(10_000, 5_000, feeMessage) + ); + + SolanaUnsignedTransaction transaction = + wallet.buildSolanaTransferAllTransaction(RECIPIENT); + + byte[] transactionBytes = Base64.getDecoder().decode( + transaction.serializedTransaction() + ); + long transferredLamports = ByteBuffer + .wrap( + transactionBytes, + transactionBytes.length - Long.BYTES, + Long.BYTES + ) + .order(ByteOrder.LITTLE_ENDIAN) + .getLong(); + + assertEquals(5_000, transferredLamports); + + byte[] feeMessageBytes = Base64.getDecoder().decode( + feeMessage.get() + ); + byte[] finalMessageBytes = Arrays.copyOfRange( + transactionBytes, + 1 + 64, + transactionBytes.length + ); + + assertArrayEquals( + Arrays.copyOf( + feeMessageBytes, + feeMessageBytes.length - Long.BYTES + ), + Arrays.copyOf( + finalMessageBytes, + finalMessageBytes.length - Long.BYTES + ) + ); + assertEquals( + 10_000, + readLastLongLittleEndian(feeMessageBytes) + ); + } + + @Test + void transferAllRejectsBalanceThatOnlyCoversFee() { + SolanaWalletImpl wallet = new SolanaWalletImpl( + SENDER, + "unused-test-key", + createBlockChain(5_000, 5_000, new AtomicReference<>()) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> wallet.buildSolanaTransferAllTransaction(RECIPIENT) + ); + + assertEquals( + "Wallet balance of 5000 lamports cannot cover the " + + "transaction fee of 5000 lamports and a positive " + + "transfer amount", + exception.getMessage() + ); + } + + @Test + void rejectsAmountsSmallerThanOneLamport() { + SolanaWalletImpl wallet = new SolanaWalletImpl( + SENDER, + "unused-test-key", + createBlockChain(0, 0, new AtomicReference<>()) + ); + + assertThrows( + IllegalArgumentException.class, + () -> wallet.buildSolanaTransferTransaction( + RECIPIENT, + new MoneyAmount( + new BigDecimal("0.0000000001"), + SOLANA.getCurrencyType() + ) + ) + ); + } + + private static SolanaBlockChain createBlockChain( + long balance, + long fee, + AtomicReference feeMessage + ) { + return (SolanaBlockChain) Proxy.newProxyInstance( + SolanaBlockChain.class.getClassLoader(), + new Class[] { SolanaBlockChain.class }, + (proxy, method, arguments) -> switch (method.getName()) { + case "getBalanceInLamport" -> balance; + case "getLatestBlockhash" -> new SolanaLatestBlockhash( + BLOCKHASH, + LAST_VALID_BLOCK_HEIGHT + ); + case "getFeeForMessage" -> { + feeMessage.set((String) arguments[0]); + yield fee; + } + default -> throw new AssertionError( + "Unexpected SolanaBlockChain call: " + + method.getName() + ); + } + ); + } + + private static long readLastLongLittleEndian(byte[] bytes) { + return ByteBuffer + .wrap( + bytes, + bytes.length - Long.BYTES, + Long.BYTES + ) + .order(ByteOrder.LITTLE_ENDIAN) + .getLong(); + } + + private static final String SENDER = + "So11111111111111111111111111111111111111112"; + private static final String RECIPIENT = + "SysvarRent111111111111111111111111111111111"; + private static final String BLOCKHASH = + "11111111111111111111111111111111"; + private static final long LAST_VALID_BLOCK_HEIGHT = 123_456; + private static final String EXPECTED_TRANSACTION = + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAABAAEDBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDw" + + "AAAAAAEGp9UXGSxcUSGMyUw9SvF/WNruCJuh/UTj29mKAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAABAgIAAQwCAAAAFc1bBwAAAAA="; +}