diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/MissingRecipientTokenAccountPolicy.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/MissingRecipientTokenAccountPolicy.tjava new file mode 100644 index 0000000..0c188c5 --- /dev/null +++ b/src/main/tjava/com/r35157/cryptowallet/solana/MissingRecipientTokenAccountPolicy.tjava @@ -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 +} diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava index 5b311cc..060a051 100644 --- a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava +++ b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava @@ -89,6 +89,34 @@ public interface SolanaWallet { @NotNull ΩSolanaWalletIdΩ recipient ) throws IOException, InterruptedException; + /** + * Sends an SPL token to another wallet. + * + *
The token program is detected from the mint account. The wallet + * builds, signs and submits the complete transaction internally.
+ * + * @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. * diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava index f9b9496..bd60c95 100644 --- a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava +++ b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava @@ -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 diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava index 26b96c0..4e55ff7 100644 --- a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava @@ -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. * diff --git a/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava index 384d5f2..3f96dc2 100644 --- a/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/impl/cached/CachedSolanaBlockChain.tjava @@ -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