48: Add SOL transfer support to SolanaWallet
This commit is contained in:
@@ -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<JavaCompile>().configureEach {
|
||||
)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
val generatedDetagMain = layout.buildDirectory.dir("generated/sources/detag/main/java")
|
||||
|
||||
val cleanGeneratedDetagMain = tasks.register<Delete>("cleanGeneratedDetagMain") {
|
||||
|
||||
@@ -52,6 +52,44 @@ public interface SolanaWallet {
|
||||
@NotNull SolanaSPLTokenProgram splProgram
|
||||
) throws IOException, InterruptedException;
|
||||
|
||||
/**
|
||||
* Builds an unsigned transaction that transfers a specific amount of SOL.
|
||||
*
|
||||
* <p>The current wallet is used as fee payer and required signer. This
|
||||
* method does not sign or submit the transaction.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>The current wallet is used as fee payer and required signer. This
|
||||
* method does not sign or submit the transaction.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
|
||||
+235
@@ -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();
|
||||
}
|
||||
@@ -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,6 +291,10 @@ 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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> 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<String> 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,
|
||||
|
||||
@@ -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<String> 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<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()
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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=";
|
||||
}
|
||||
Reference in New Issue
Block a user