49: Hide transaction handling behind high-level service operations
This commit is contained in:
-235
@@ -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(
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+51
-44
@@ -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(
|
||||
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) {
|
||||
Reference in New Issue
Block a user