From 1d63cdc026ac3d79214f6053cf1044b01dab1298 Mon Sep 17 00:00:00 2001 From: Minimons Date: Tue, 28 Jul 2026 18:58:36 +0200 Subject: [PATCH] 49: Hide transaction handling behind high-level service operations --- .../SolanaTransferTransactionBuilder.tjava | 235 -------------- .../solana/impl/ref/SolanaWalletImpl.tjava | 141 +-------- .../r35157/libs/solana/SolanaBlockChain.tjava | 34 ++ .../impl/cached/CachedSolanaBlockChain.tjava | 25 ++ .../impl/ref/SolanaBlockChainImpl.tjava | 296 ++++++++++++++++++ .../impl/ref/SolanaBlockChainImplTest.java} | 95 +++--- 6 files changed, 412 insertions(+), 414 deletions(-) delete mode 100644 src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava rename src/test/java/com/r35157/{cryptowallet/solana/impl/ref/SolanaWalletImplTest.java => libs/solana/impl/ref/SolanaBlockChainImplTest.java} (69%) 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 deleted file mode 100644 index b5b6719..0000000 --- a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaTransferTransactionBuilder.tjava +++ /dev/null @@ -1,235 +0,0 @@ -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 1305903..9eb6037 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,13 +5,10 @@ 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; @@ -60,16 +57,10 @@ public class SolanaWalletImpl implements SolanaWallet { @NotNull ΩSolanaWalletIdΩ recipient, @NotNull ΩSolanaAmountΩ amount ) throws IOException, InterruptedException { - validateRecipient(recipient); - - ΩlamportsΩ lamports = toLamports(amount); - SolanaLatestBlockhash latestBlockhash = - solanaBlockChain.getLatestBlockhash(); - - return buildSolanaTransferTransaction( + return solanaBlockChain.buildSolanaTransferTransaction( + address, recipient, - latestBlockhash, - lamports + amount ); } @@ -77,56 +68,9 @@ public class SolanaWalletImpl implements SolanaWallet { 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 + return solanaBlockChain.buildSolanaTransferAllTransaction( + address, + recipient ); } @@ -215,75 +159,6 @@ 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 @@ -291,10 +166,6 @@ 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; diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava index 879e2f7..26b96c0 100644 --- a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava @@ -66,6 +66,40 @@ public interface SolanaBlockChain { ΩBase64StringΩ serializedMessage ) throws IOException, InterruptedException; + /** + * Builds an unsigned transaction that transfers a specific amount of SOL. + * + * @param sender the address that sends the SOL and pays the fee + * @param recipient the address that receives the SOL + * @param amount the amount of SOL to transfer + * @return the unsigned serialized transfer transaction + * @throws IllegalArgumentException if an address or amount is invalid + * @throws IOException if a recent blockhash cannot be fetched + * @throws InterruptedException if the calling thread is interrupted + */ + SolanaUnsignedTransaction buildSolanaTransferTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient, + ΩSolanaAmountΩ amount + ) throws IOException, InterruptedException; + + /** + * Builds an unsigned transaction that transfers the sender's complete + * native SOL balance after paying the exact transaction fee. + * + * @param sender the address that sends the SOL and pays the fee + * @param recipient the address that receives the SOL + * @return the unsigned serialized transfer transaction + * @throws IllegalArgumentException if an address 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 + */ + SolanaUnsignedTransaction buildSolanaTransferAllTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient + ) throws IOException, InterruptedException; + /** * Fetches SPL token holdings owned by a Solana address for a specific token program. * 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 4d371b3..384d5f2 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 @@ -10,6 +10,7 @@ import com.r35157.libs.solana.SolanaLatestBlockhash; import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter; import com.r35157.libs.solana.SolanaProgramAddressSeed; import com.r35157.libs.solana.SolanaSignedTransaction; +import com.r35157.libs.solana.SolanaUnsignedTransaction; import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress; import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; import com.r35157.libs.valuetypes.basic.MoneyAmount; @@ -58,6 +59,30 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain { return delegate.getFeeForMessage(serializedMessage); } + @Override + public SolanaUnsignedTransaction buildSolanaTransferTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient, + ΩSolanaAmountΩ amount + ) throws IOException, InterruptedException { + return delegate.buildSolanaTransferTransaction( + sender, + recipient, + amount + ); + } + + @Override + public SolanaUnsignedTransaction buildSolanaTransferAllTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient + ) throws IOException, InterruptedException { + return delegate.buildSolanaTransferAllTransaction( + sender, + recipient + ); + } + @Override public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings( ΩSolanaAddressΩ ownerAddress, 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 4718c63..288f059 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 @@ -11,6 +11,7 @@ import com.r35157.libs.valuetypes.basic.CurrencyType; import com.r35157.libs.valuetypes.basic.MoneyAmount; import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; @@ -18,6 +19,8 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -215,6 +218,79 @@ public class SolanaBlockChainImpl implements SolanaBlockChain { return valueNode.longValue(); } + @Override + public SolanaUnsignedTransaction buildSolanaTransferTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient, + ΩSolanaAmountΩ amount + ) throws IOException, InterruptedException { + validateSolanaTransferAddresses(sender, recipient); + + return buildSolanaTransferTransaction( + sender, + recipient, + getLatestBlockhash(), + toLamports(amount) + ); + } + + @Override + public SolanaUnsignedTransaction buildSolanaTransferAllTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient + ) throws IOException, InterruptedException { + validateSolanaTransferAddresses(sender, recipient); + + ΩlamportsΩ balance = getBalanceInLamport(sender); + + if (balance <= 0) { + throw new IllegalStateException( + "Wallet does not contain any SOL to transfer" + ); + } + + SolanaLatestBlockhash latestBlockhash = getLatestBlockhash(); + + // The transfer amount is always serialized as one fixed-width u64. + // Replacing it with balance - fee therefore cannot change the fee. + SerializedSolanaTransfer feeCandidate = + serializeSolanaTransferTransaction( + sender, + recipient, + latestBlockhash, + balance + ); + + ΩlamportsΩ fee = 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( + sender, + recipient, + latestBlockhash, + transferableLamports + ); + } + @Override public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings( ΩSolanaAddressΩ ownerAddress, @@ -617,6 +693,215 @@ public class SolanaBlockChainImpl implements SolanaBlockChain { return objectMapper.writeValueAsString(request); } + private SolanaUnsignedTransaction buildSolanaTransferTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient, + SolanaLatestBlockhash latestBlockhash, + ΩlamportsΩ lamports + ) { + SerializedSolanaTransfer transfer = + serializeSolanaTransferTransaction( + sender, + recipient, + latestBlockhash, + lamports + ); + + return new SolanaUnsignedTransaction( + transfer.serializedTransaction(), + latestBlockhash.blockhash(), + latestBlockhash.lastValidBlockHeight() + ); + } + + private SerializedSolanaTransfer serializeSolanaTransferTransaction( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient, + SolanaLatestBlockhash latestBlockhash, + ΩlamportsΩ lamports + ) { + if (lamports <= 0) { + throw new IllegalArgumentException( + "SOL transfer amount must be greater than zero lamports" + ); + } + + byte[] senderBytes = decodeSolanaTransferAddress( + "sender", + sender + ); + byte[] recipientBytes = decodeSolanaTransferAddress( + "recipient", + recipient + ); + byte[] systemProgramBytes = decodeSolanaTransferAddress( + "System Program", + SYSTEM_PROGRAM_ADDRESS + ); + byte[] blockhashBytes = decodeSolanaTransferAddress( + "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 SerializedSolanaTransfer( + serializedMessage, + serializedTransaction + ); + } + + private ΩlamportsΩ toLamports(ΩSolanaAmountΩ amount) { + if (amount == null || amount.amount() == null) { + throw new IllegalArgumentException( + "SOL transfer amount must not be null" + ); + } + + if (!WellKnownCurrencyTypes.SOLANA + .getCurrencyType() + .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 validateSolanaTransferAddresses( + ΩSolanaAddressΩ sender, + ΩSolanaAddressΩ recipient + ) { + if (sender == null || sender.isBlank()) { + throw new IllegalArgumentException( + "Solana sender must not be blank" + ); + } + + if (recipient == null || recipient.isBlank()) { + throw new IllegalArgumentException( + "SOL transfer recipient must not be blank" + ); + } + + if (recipient.equals(sender)) { + throw new IllegalArgumentException( + "SOL transfer recipient must be different from the sender" + ); + } + } + + private byte[] decodeSolanaTransferAddress( + 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 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 String createGetProgramAccountsBody( ΩSolanaProgramIdΩ programId, Set filters @@ -948,6 +1233,17 @@ public class SolanaBlockChainImpl implements SolanaBlockChain { return indexes; } + private record SerializedSolanaTransfer( + Ω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 Ω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); diff --git a/src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java b/src/test/java/com/r35157/libs/solana/impl/ref/SolanaBlockChainImplTest.java similarity index 69% rename from src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java rename to src/test/java/com/r35157/libs/solana/impl/ref/SolanaBlockChainImplTest.java index 528ac39..43eb3bc 100644 --- a/src/test/java/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImplTest.java +++ b/src/test/java/com/r35157/libs/solana/impl/ref/SolanaBlockChainImplTest.java @@ -1,12 +1,10 @@ -package com.r35157.cryptowallet.solana.impl.ref; +package com.r35157.libs.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; @@ -19,17 +17,18 @@ 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 { +class SolanaBlockChainImplTest { @Test void buildsExpectedLegacySolTransferTransaction() throws Exception { - SolanaWalletImpl wallet = new SolanaWalletImpl( - SENDER, - "unused-test-key", - createBlockChain(0, 0, new AtomicReference<>()) + SolanaBlockChainImpl blockChain = createBlockChain( + 0, + 0, + new AtomicReference<>() ); SolanaUnsignedTransaction transaction = - wallet.buildSolanaTransferTransaction( + blockChain.buildSolanaTransferTransaction( + SENDER, RECIPIENT, new MoneyAmount( new BigDecimal("0.123456789"), @@ -48,14 +47,17 @@ class SolanaWalletImplTest { @Test void transferAllSubtractsExactFeeFromBalance() throws Exception { AtomicReference feeMessage = new AtomicReference<>(); - SolanaWalletImpl wallet = new SolanaWalletImpl( - SENDER, - "unused-test-key", - createBlockChain(10_000, 5_000, feeMessage) + SolanaBlockChainImpl blockChain = createBlockChain( + 10_000, + 5_000, + feeMessage ); SolanaUnsignedTransaction transaction = - wallet.buildSolanaTransferAllTransaction(RECIPIENT); + blockChain.buildSolanaTransferAllTransaction( + SENDER, + RECIPIENT + ); byte[] transactionBytes = Base64.getDecoder().decode( transaction.serializedTransaction() @@ -98,15 +100,18 @@ class SolanaWalletImplTest { @Test void transferAllRejectsBalanceThatOnlyCoversFee() { - SolanaWalletImpl wallet = new SolanaWalletImpl( - SENDER, - "unused-test-key", - createBlockChain(5_000, 5_000, new AtomicReference<>()) + SolanaBlockChainImpl blockChain = createBlockChain( + 5_000, + 5_000, + new AtomicReference<>() ); IllegalStateException exception = assertThrows( IllegalStateException.class, - () -> wallet.buildSolanaTransferAllTransaction(RECIPIENT) + () -> blockChain.buildSolanaTransferAllTransaction( + SENDER, + RECIPIENT + ) ); assertEquals( @@ -119,15 +124,16 @@ class SolanaWalletImplTest { @Test void rejectsAmountsSmallerThanOneLamport() { - SolanaWalletImpl wallet = new SolanaWalletImpl( - SENDER, - "unused-test-key", - createBlockChain(0, 0, new AtomicReference<>()) + SolanaBlockChainImpl blockChain = createBlockChain( + 0, + 0, + new AtomicReference<>() ); assertThrows( IllegalArgumentException.class, - () -> wallet.buildSolanaTransferTransaction( + () -> blockChain.buildSolanaTransferTransaction( + SENDER, RECIPIENT, new MoneyAmount( new BigDecimal("0.0000000001"), @@ -137,30 +143,31 @@ class SolanaWalletImplTest { ); } - private static SolanaBlockChain createBlockChain( + private static SolanaBlockChainImpl 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() - ); - } - ); + return new SolanaBlockChainImpl() { + @Override + public long getBalanceInLamport(String address) { + return balance; + } + + @Override + public SolanaLatestBlockhash getLatestBlockhash() { + return new SolanaLatestBlockhash( + BLOCKHASH, + LAST_VALID_BLOCK_HEIGHT + ); + } + + @Override + public long getFeeForMessage(String serializedMessage) { + feeMessage.set(serializedMessage); + return fee; + } + }; } private static long readLastLongLittleEndian(byte[] bytes) {