69: Add SPL-token burn support to SolanaWallet and SolanaBlockChain
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user