69: Add SPL-token burn support to SolanaWallet and SolanaBlockChain

This commit is contained in:
2026-08-11 15:42:56 +02:00
parent b86ec37bb1
commit b33db6da49
11 changed files with 517 additions and 0 deletions
@@ -117,6 +117,32 @@ public interface SolanaWallet {
missingRecipientTokenAccountPolicy
) throws IOException, InterruptedException;
/**
* Burns an amount of an SPL token owned by this wallet.
*
* <p>The token program is detected from the mint account. The amount uses
* the token's human-readable decimal unit and must convert exactly to raw
* token units. The wallet validates its holding, builds one checked burn,
* signs it, and submits it once. Normal return means submission only; this
* method does not await confirmation, retry, or close the token account.</p>
*
* @param mintAddress mint of the token to burn
* @param amount amount to burn in the token's human-readable decimal unit
* @return transaction signature returned by Solana RPC
* @throws IllegalArgumentException if the mint or amount is invalid or the
* amount has excessive decimal precision
* @throws IllegalStateException if the mint or wallet holding is missing,
* unsupported, inconsistent, or insufficient
* @throws IOException if blockchain data cannot be read or the transaction
* cannot be built, signed, or submitted
* @throws InterruptedException if the calling thread is interrupted
*/
@NotNull
ΩSolanaTransactionSignatureΩ burnSPLToken(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull ΩAmountΩ amount
) throws IOException, InterruptedException;
/**
* Signs an unsigned Solana transaction.
*
@@ -17,6 +17,7 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
@@ -211,6 +212,109 @@ public class SolanaWalletImpl implements SolanaWallet {
return signAndSendTransaction(transaction);
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ burnSPLToken(
@NotNull ΩSPLMintAddressΩ mintAddress,
@NotNull ΩAmountΩ amount
) throws IOException, InterruptedException {
if (mintAddress == null || mintAddress.isBlank()) {
throw new IllegalArgumentException(
"SPL token mint address must not be blank"
);
}
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException(
"SPL token burn amount must be greater than zero"
);
}
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
);
if (tokenSupply == null) {
throw new IllegalStateException(
"SPL token mint supply does not exist: " + mintAddress
);
}
if (tokenSupply.decimals() < 0 || tokenSupply.decimals() > 255) {
throw new IllegalStateException(
"SPL token mint decimals do not fit in an unsigned byte"
);
}
SPLTokenHolding holding = solanaBlockChain
.getSPLTokenHoldings(address, splProgram)
.get(mintAddress);
if (holding == null) {
throw new IllegalStateException(
"Wallet does not have a token account for mint "
+ mintAddress
);
}
if (holding.decimals() != tokenSupply.decimals()) {
throw new IllegalStateException(
"Wallet token account decimals do not match the mint"
);
}
BigInteger burnAmount;
try {
burnAmount = amount
.movePointRight(tokenSupply.decimals())
.toBigIntegerExact();
} catch (ArithmeticException e) {
throw new IllegalArgumentException(
"SPL token burn amount has more than "
+ tokenSupply.decimals()
+ " decimal places",
e
);
}
BigInteger holdingAmount;
try {
holdingAmount = new BigInteger(holding.rawAmount());
} catch (RuntimeException e) {
throw new IllegalStateException(
"Wallet token account contains an invalid raw balance",
e
);
}
if (holdingAmount.signum() < 0) {
throw new IllegalStateException(
"Wallet token account contains a negative raw balance"
);
}
if (burnAmount.compareTo(holdingAmount) > 0) {
throw new IllegalStateException(
"Wallet has insufficient SPL token balance"
);
}
SolanaUnsignedTransaction transaction =
solanaBlockChain.buildSPLTokenBurnTransaction(
address,
holding.tokenAccount(),
mintAddress,
burnAmount.toString(),
tokenSupply.decimals(),
splProgram
);
return signAndSendTransaction(transaction);
}
private void validateRecipientTokenAccount(
SolanaAccountInfo tokenAccount,
ΩSPLMintAddressΩ expectedMint,
@@ -128,6 +128,34 @@ public interface SolanaBlockChain {
boolean createRecipientTokenAccount
) throws IOException, InterruptedException;
/**
* Builds an unsigned checked SPL-token burn transaction.
*
* <p>The owner is the transaction fee payer, token-account authority, and
* required signer. This method only builds the transaction; it does not
* sign, submit, or await it.</p>
*
* @param owner wallet owner, authority, and transaction fee payer
* @param tokenAccount token account from which tokens are burned
* @param mintAddress token mint whose supply is reduced
* @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 account
* @return unsigned serialized checked-burn transaction
* @throws IllegalArgumentException if an address, amount, decimal count,
* or token program is invalid
* @throws IOException if a recent blockhash cannot be fetched
* @throws InterruptedException if the calling thread is interrupted
*/
SolanaUnsignedTransaction buildSPLTokenBurnTransaction(
ΩSolanaAddressΩ owner,
ΩSPLTokenAccountΩ tokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Derives the Associated Token Account for an owner, mint and token
* program.
@@ -247,6 +247,25 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain {
);
}
@Override
public SolanaUnsignedTransaction buildSPLTokenBurnTransaction(
ΩSolanaAddressΩ owner,
ΩSPLTokenAccountΩ tokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException {
return delegate.buildSPLTokenBurnTransaction(
owner,
tokenAccount,
mintAddress,
rawAmount,
decimals,
splProgram
);
}
@Override
public ΩSPLTokenAccountΩ findAssociatedTokenAccount(
ΩSolanaAddressΩ owner,
@@ -439,6 +439,95 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
);
}
@Override
public SolanaUnsignedTransaction buildSPLTokenBurnTransaction(
ΩSolanaAddressΩ owner,
ΩSPLTokenAccountΩ tokenAccount,
ΩSPLMintAddressΩ mintAddress,
ΩRawAmountΩ rawAmount,
ΩamountDecimalsΩ decimals,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException {
Objects.requireNonNull(
splProgram,
"SPL token program must not be null"
);
BigInteger burnAmount;
try {
burnAmount = new BigInteger(rawAmount);
} catch (RuntimeException e) {
throw new IllegalArgumentException(
"Raw SPL token burn amount must be an integer",
e
);
}
if (burnAmount.signum() <= 0 || burnAmount.bitLength() > 64) {
throw new IllegalArgumentException(
"Raw SPL token burn 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"
);
}
byte[] ownerBytes = decodeSolanaTransferAddress("owner", owner);
byte[] tokenAccountBytes = decodeSolanaTransferAddress(
"token account",
tokenAccount
);
byte[] mintBytes = decodeSolanaTransferAddress("mint", mintAddress);
byte[] tokenProgramBytes = decodeSolanaTransferAddress(
"token program",
splProgram.getAddress()
);
SolanaLatestBlockhash latestBlockhash = getLatestBlockhash();
byte[] blockhashBytes = decodeSolanaTransferAddress(
"blockhash",
latestBlockhash.blockhash()
);
byte[] burnCheckedData = ByteBuffer
.allocate(10)
.order(ByteOrder.LITTLE_ENDIAN)
.put((byte) TOKEN_BURN_CHECKED_INSTRUCTION)
.putLong(burnAmount.longValue())
.put((byte) decimals)
.array();
ByteArrayOutputStream message = new ByteArrayOutputStream();
message.write(1);
message.write(0);
message.write(1);
writeCompactU16(message, 4);
message.writeBytes(ownerBytes);
message.writeBytes(tokenAccountBytes);
message.writeBytes(mintBytes);
message.writeBytes(tokenProgramBytes);
message.writeBytes(blockhashBytes);
writeCompactU16(message, 1);
message.write(3);
writeCompactU16(message, 3);
message.writeBytes(new byte[] { 1, 2, 0 });
writeCompactU16(message, burnCheckedData.length);
message.writeBytes(burnCheckedData);
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,
@@ -1744,6 +1833,7 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
private static final int SYSTEM_TRANSFER_INSTRUCTION = 2;
private static final int TOKEN_TRANSFER_CHECKED_INSTRUCTION = 12;
private static final int TOKEN_BURN_CHECKED_INSTRUCTION = 15;
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");