50: Add support for transferring SPL tokens

This commit is contained in:
2026-08-05 10:12:39 +02:00
parent 847c33000a
commit b9741d09fd
6 changed files with 464 additions and 0 deletions
@@ -0,0 +1,18 @@
package com.r35157.cryptowallet.solana;
/**
* Decides how an SPL-token transfer handles a missing recipient Associated
* Token Account.
*/
public enum MissingRecipientTokenAccountPolicy {
/**
* Reject the transfer without submitting a transaction.
*/
FAIL,
/**
* Create the recipient account in the transfer transaction, paid by the
* sending wallet.
*/
CREATE_AT_SENDER_EXPENSE
}
@@ -89,6 +89,34 @@ public interface SolanaWallet {
@NotNull ΩSolanaWalletIdΩ recipient
) throws IOException, InterruptedException;
/**
* Sends an SPL token to another wallet.
*
* <p>The token program is detected from the mint account. The wallet
* builds, signs and submits the complete transaction internally.</p>
*
* @param mintAddress the mint of the token to send
* @param recipient the wallet that should receive the token
* @param amount the token amount in its human-readable decimal unit
* @param missingRecipientTokenAccountPolicy how to handle a missing
* recipient Associated Token Account
* @return the transaction signature returned by Solana RPC
* @throws IllegalArgumentException if an argument or amount is invalid
* @throws IllegalStateException if required accounts or balances are
* missing or invalid
* @throws IOException if blockchain data cannot be read or the transaction
* cannot be built, signed or sent
* @throws InterruptedException if the calling thread is interrupted
*/
@NotNull
ΩSolanaTransactionSignatureΩ sendSPLToken(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull ΩSolanaWalletIdΩ recipient,
@NotNull ΩAmountΩ amount,
@NotNull MissingRecipientTokenAccountPolicy
missingRecipientTokenAccountPolicy
) throws IOException, InterruptedException;
/**
* Signs an unsigned Solana transaction.
*
@@ -3,10 +3,13 @@ package com.r35157.cryptowallet.solana.impl.ref;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.cryptowallet.solana.MissingRecipientTokenAccountPolicy;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SPLTokenHolding;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.SPLTokenSupply;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
@@ -15,6 +18,8 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
public class SolanaWalletImpl implements SolanaWallet {
public SolanaWalletImpl(
@@ -80,6 +85,194 @@ public class SolanaWalletImpl implements SolanaWallet {
return signAndSendTransaction(unsignedTransaction);
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ sendSPLToken(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull ΩSolanaWalletIdΩ recipient,
@NotNull ΩAmountΩ amount,
@NotNull MissingRecipientTokenAccountPolicy policy
) throws IOException, InterruptedException {
if (mintAddress == null || mintAddress.isBlank()) {
throw new IllegalArgumentException(
"SPL token mint address must not be blank"
);
}
if (recipient == null
|| recipient.isBlank()
|| recipient.equals(address)) {
throw new IllegalArgumentException(
"SPL token recipient must be present and different "
+ "from the sender"
);
}
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException(
"SPL token transfer amount must be greater than zero"
);
}
Objects.requireNonNull(
policy,
"Missing recipient token account policy must not be null"
);
SolanaAccountInfo mintAccount = solanaBlockChain.getAccountInfo(
mintAddress
);
if (mintAccount == null) {
throw new IllegalStateException(
"SPL token mint account does not exist: " + mintAddress
);
}
SolanaSPLTokenProgram splProgram = detectTokenProgram(mintAccount);
SPLTokenSupply tokenSupply = solanaBlockChain.getSPLTokenSupply(
mintAddress,
splProgram
);
SPLTokenHolding senderHolding = solanaBlockChain
.getSPLTokenHoldings(address, splProgram)
.get(mintAddress);
if (senderHolding == null) {
throw new IllegalStateException(
"Sender does not have a token account for mint "
+ mintAddress
);
}
if (senderHolding.decimals() != tokenSupply.decimals()) {
throw new IllegalStateException(
"Sender token account decimals do not match the mint"
);
}
ΩRawAmountΩ rawAmount;
try {
rawAmount = amount
.movePointRight(tokenSupply.decimals())
.toBigIntegerExact()
.toString();
} catch (ArithmeticException e) {
throw new IllegalArgumentException(
"SPL token transfer amount has more than "
+ tokenSupply.decimals()
+ " decimal places",
e
);
}
if (new java.math.BigInteger(rawAmount).compareTo(
new java.math.BigInteger(senderHolding.rawAmount())
) > 0) {
throw new IllegalStateException(
"Sender has insufficient SPL token balance"
);
}
ΩSPLTokenAccountΩ recipientTokenAccount =
solanaBlockChain.findAssociatedTokenAccount(
recipient,
mintAddress,
splProgram
);
SolanaAccountInfo recipientAccount = solanaBlockChain.getAccountInfo(
recipientTokenAccount
);
boolean createRecipientAccount = recipientAccount == null;
if (createRecipientAccount
&& policy == MissingRecipientTokenAccountPolicy.FAIL) {
throw new IllegalStateException(
"Recipient Associated Token Account does not exist: "
+ recipientTokenAccount
);
}
if (recipientAccount != null) {
validateRecipientTokenAccount(
recipientAccount,
mintAddress,
recipient,
splProgram
);
}
SolanaUnsignedTransaction transaction =
solanaBlockChain.buildSPLTokenTransferTransaction(
address,
senderHolding.tokenAccount(),
mintAddress,
recipient,
recipientTokenAccount,
rawAmount,
tokenSupply.decimals(),
splProgram,
createRecipientAccount
);
return signAndSendTransaction(transaction);
}
private void validateRecipientTokenAccount(
SolanaAccountInfo tokenAccount,
ΩSPLMintAddressΩ expectedMint,
ΩSolanaWalletIdΩ expectedOwner,
SolanaSPLTokenProgram expectedProgram
) {
if (!expectedProgram.getAddress().equals(tokenAccount.owner())) {
throw new IllegalStateException(
"Recipient token account is owned by an unexpected program"
);
}
byte[] accountData;
try {
accountData = Base64.getDecoder().decode(tokenAccount.data());
} catch (IllegalArgumentException e) {
throw new IllegalStateException(
"Recipient token account contains invalid Base64 data",
e
);
}
if (accountData.length < 64) {
throw new IllegalStateException(
"Recipient token account data is too short"
);
}
ΩSolanaAddressΩ actualMint = solanaBlockChain.encodeSolanaAddress(
java.util.Arrays.copyOfRange(accountData, 0, 32)
);
ΩSolanaAddressΩ actualOwner = solanaBlockChain.encodeSolanaAddress(
java.util.Arrays.copyOfRange(accountData, 32, 64)
);
if (!expectedMint.equals(actualMint)) {
throw new IllegalStateException(
"Recipient token account belongs to an unexpected mint"
);
}
if (!expectedOwner.equals(actualOwner)) {
throw new IllegalStateException(
"Recipient token account belongs to an unexpected owner"
);
}
}
private SolanaSPLTokenProgram detectTokenProgram(
SolanaAccountInfo mintAccount
) {
for (SolanaSPLTokenProgram program
: SolanaSPLTokenProgram.values()) {
if (program.getAddress().equals(mintAccount.owner())) {
return program;
}
}
throw new IllegalStateException(
"Mint account is not owned by a supported SPL token program: "
+ mintAccount.owner()
);
}
@Override
public @NotNull SolanaSignedTransaction signTransaction(
@NotNull SolanaUnsignedTransaction transaction
@@ -100,6 +100,43 @@ public interface SolanaBlockChain {
ΩSolanaAddressΩ recipient
) throws IOException, InterruptedException;
/**
* Builds an unsigned checked SPL-token transfer.
*
* @param sender wallet owner, authority and transaction fee payer
* @param senderTokenAccount token account from which tokens are sent
* @param mintAddress token mint
* @param recipient recipient wallet owner
* @param recipientTokenAccount recipient Associated Token Account
* @param rawAmount amount expressed in the mint's smallest unit
* @param decimals decimal count read from the mint
* @param splProgram token program owning the mint and token accounts
* @param createRecipientTokenAccount whether the transaction must first
* create the recipient Associated Token Account
* @return the unsigned serialized transaction
*/
SolanaUnsignedTransaction buildSPLTokenTransferTransaction(
ΩSolanaAddressΩ sender,
ΩSPLTokenAccountΩ senderTokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩSolanaAddressΩ recipient,
ΩSPLTokenAccountΩ recipientTokenAccount,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram,
boolean createRecipientTokenAccount
) throws IOException, InterruptedException;
/**
* Derives the Associated Token Account for an owner, mint and token
* program.
*/
ΩSPLTokenAccountΩ findAssociatedTokenAccount(
ΩSolanaAddressΩ owner,
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
);
/**
* Fetches SPL token holdings owned by a Solana address for a specific token program.
*
@@ -210,6 +210,44 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain {
return delegate.sendTransaction(transaction);
}
@Override
public SolanaUnsignedTransaction buildSPLTokenTransferTransaction(
ΩSolanaAddressΩ sender,
ΩSPLTokenAccountΩ senderTokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩSolanaAddressΩ recipient,
ΩSPLTokenAccountΩ recipientTokenAccount,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram,
boolean createRecipientTokenAccount
) throws IOException, InterruptedException {
return delegate.buildSPLTokenTransferTransaction(
sender,
senderTokenAccount,
mintAddress,
recipient,
recipientTokenAccount,
rawAmount,
decimals,
splProgram,
createRecipientTokenAccount
);
}
@Override
public ΩSPLTokenAccountΩ findAssociatedTokenAccount(
ΩSolanaAddressΩ owner,
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
) {
return delegate.findAssociatedTokenAccount(
owner,
mintAddress,
splProgram
);
}
private synchronized Set<SolanaAccountInfo> loadProgramAccounts(
ObjectCacheKey key,
ΩSolanaProgramIdΩ programId,
@@ -291,6 +291,146 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
);
}
@Override
public ΩSPLTokenAccountΩ findAssociatedTokenAccount(
ΩSolanaAddressΩ owner,
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
) {
Objects.requireNonNull(splProgram, "SPL token program must not be null");
ΩSPLTokenAccountΩ tokenAccount = findProgramAddress(
ASSOCIATED_TOKEN_PROGRAM_ADDRESS,
List.of(
SolanaProgramAddressSeed.solanaAddress(owner),
SolanaProgramAddressSeed.solanaAddress(
splProgram.getAddress()
),
SolanaProgramAddressSeed.solanaAddress(mintAddress)
)
).address();
return tokenAccount;
}
@Override
public SolanaUnsignedTransaction buildSPLTokenTransferTransaction(
ΩSolanaAddressΩ sender,
ΩSPLTokenAccountΩ senderTokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩSolanaAddressΩ recipient,
ΩSPLTokenAccountΩ recipientTokenAccount,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram,
boolean createRecipientTokenAccount
) throws IOException, InterruptedException {
Objects.requireNonNull(splProgram, "SPL token program must not be null");
BigInteger transferAmount;
try {
transferAmount = new BigInteger(rawAmount);
} catch (RuntimeException e) {
throw new IllegalArgumentException(
"Raw SPL token amount must be an integer",
e
);
}
if (transferAmount.signum() <= 0
|| transferAmount.bitLength() > 64) {
throw new IllegalArgumentException(
"Raw SPL token amount must fit in an unsigned u64 and "
+ "be greater than zero"
);
}
if (decimals < 0 || decimals > 255) {
throw new IllegalArgumentException(
"SPL token decimals must fit in an unsigned byte"
);
}
List<ΩSolanaAddressΩ> accountKeys = new ArrayList<>();
accountKeys.add(sender);
accountKeys.add(senderTokenAccount);
accountKeys.add(recipientTokenAccount);
accountKeys.add(mintAddress);
int tokenProgramIndex;
int readOnlyUnsignedAccounts;
if (createRecipientTokenAccount) {
accountKeys.add(recipient);
accountKeys.add(SYSTEM_PROGRAM_ADDRESS);
accountKeys.add(splProgram.getAddress());
accountKeys.add(ASSOCIATED_TOKEN_PROGRAM_ADDRESS);
tokenProgramIndex = 6;
readOnlyUnsignedAccounts = 5;
} else {
accountKeys.add(splProgram.getAddress());
tokenProgramIndex = 4;
readOnlyUnsignedAccounts = 2;
}
List<CompiledInstruction> instructions = new ArrayList<>();
if (createRecipientTokenAccount) {
instructions.add(new CompiledInstruction(
7,
new byte[] { 0, 2, 4, 3, 5, 6 },
new byte[] { 1 }
));
}
byte[] transferCheckedData = ByteBuffer
.allocate(10)
.order(ByteOrder.LITTLE_ENDIAN)
.put((byte) TOKEN_TRANSFER_CHECKED_INSTRUCTION)
.putLong(transferAmount.longValue())
.put((byte) decimals)
.array();
instructions.add(new CompiledInstruction(
tokenProgramIndex,
new byte[] { 1, 3, 2, 0 },
transferCheckedData
));
SolanaLatestBlockhash latestBlockhash = getLatestBlockhash();
ByteArrayOutputStream message = new ByteArrayOutputStream();
message.write(1);
message.write(0);
message.write(readOnlyUnsignedAccounts);
writeCompactU16(message, accountKeys.size());
for (String accountKey : accountKeys) {
message.writeBytes(decodeSolanaTransferAddress(
"transaction account",
accountKey
));
}
message.writeBytes(decodeSolanaTransferAddress(
"blockhash",
latestBlockhash.blockhash()
));
writeCompactU16(message, instructions.size());
for (CompiledInstruction instruction : instructions) {
message.write(instruction.programIndex());
writeCompactU16(message, instruction.accountIndexes().length);
message.writeBytes(instruction.accountIndexes());
writeCompactU16(message, instruction.data().length);
message.writeBytes(instruction.data());
}
ByteArrayOutputStream transaction = new ByteArrayOutputStream();
writeCompactU16(transaction, 1);
transaction.writeBytes(new byte[SOLANA_SIGNATURE_LENGTH]);
transaction.writeBytes(message.toByteArray());
return new SolanaUnsignedTransaction(
Base64.getEncoder().encodeToString(transaction.toByteArray()),
latestBlockhash.blockhash(),
latestBlockhash.lastValidBlockHeight()
);
}
@Override
public Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings(
ΩSolanaAddressΩ ownerAddress,
@@ -1239,9 +1379,19 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
) {
}
private record CompiledInstruction(
int programIndex,
byte[] accountIndexes,
byte[] data
) {
}
private static final String SYSTEM_PROGRAM_ADDRESS =
"11111111111111111111111111111111";
private static final String ASSOCIATED_TOKEN_PROGRAM_ADDRESS =
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
private static final int SYSTEM_TRANSFER_INSTRUCTION = 2;
private static final int TOKEN_TRANSFER_CHECKED_INSTRUCTION = 12;
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");