49: Hide transaction handling behind high-level service operations

This commit is contained in:
2026-08-05 10:12:39 +02:00
parent 4b913d4c83
commit 1d63cdc026
6 changed files with 412 additions and 414 deletions
@@ -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();
}
@@ -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(
return solanaBlockChain.buildSolanaTransferAllTransaction(
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
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;
@@ -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.
*
@@ -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,
@@ -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<SolanaProgramAccountMemcmpFilter> 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);
@@ -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<String> 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<String> 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(
return new SolanaBlockChainImpl() {
@Override
public long getBalanceInLamport(String address) {
return balance;
}
@Override
public SolanaLatestBlockhash getLatestBlockhash() {
return 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()
);
@Override
public long getFeeForMessage(String serializedMessage) {
feeMessage.set(serializedMessage);
return fee;
}
);
};
}
private static long readLastLongLittleEndian(byte[] bytes) {