50: Add support for transferring SPL tokens
This commit is contained in:
+18
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user