From b33db6da49c69ac56c5a77a937c1e39b2a7aadb7 Mon Sep 17 00:00:00 2001 From: Minimons Date: Tue, 11 Aug 2026 15:19:44 +0200 Subject: [PATCH] 69: Add SPL-token burn support to SolanaWallet and SolanaBlockChain --- .../.openspec.yaml | 2 + .../design.md | 55 +++++++++ .../proposal.md | 25 +++++ .../specs/solana-spl-token-burning/spec.md | 72 ++++++++++++ .../tasks.md | 22 ++++ .../specs/solana-spl-token-burning/spec.md | 74 +++++++++++++ .../cryptowallet/solana/SolanaWallet.tjava | 26 +++++ .../solana/impl/ref/SolanaWalletImpl.tjava | 104 ++++++++++++++++++ .../r35157/libs/solana/SolanaBlockChain.tjava | 28 +++++ .../impl/cached/CachedSolanaBlockChain.tjava | 19 ++++ .../impl/ref/SolanaBlockChainImpl.tjava | 90 +++++++++++++++ 11 files changed, 517 insertions(+) create mode 100644 openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/design.md create mode 100644 openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/proposal.md create mode 100644 openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/specs/solana-spl-token-burning/spec.md create mode 100644 openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/tasks.md create mode 100644 openspec/specs/solana-spl-token-burning/spec.md diff --git a/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/.openspec.yaml b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/.openspec.yaml new file mode 100644 index 0000000..a8821c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/design.md b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/design.md new file mode 100644 index 0000000..a0fbe7b --- /dev/null +++ b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/design.md @@ -0,0 +1,55 @@ +## Context + +The current SPL transfer flow separates responsibilities cleanly: `SolanaBlockChain` serializes unsigned low-level transactions, while `SolanaWallet` resolves mint and holding state, validates human-readable amounts, signs, and submits. The transaction serializer already supports legacy-message account ordering, compact-u16 encoding, recent blockhash metadata, and selecting either supported SPL token program. + +## Goals / Non-Goals + +**Goals:** + +- Extend the existing API/implementation boundary with checked SPL-token burning. +- Keep all state-dependent ownership and balance checks in the wallet and all wire serialization in the blockchain implementation. +- Use identical behavior for the original SPL Token Program and Token-2022 except for the selected program address. +- Preserve the existing submission-only signing flow. + +**Non-Goals:** + +- Confirmation awaiting, retry, account closure, rent recovery, multisignatures, delegates, or permanent delegates. +- Native or wrapped SOL burning, a burn-all convenience API, or integration with Evelyn and Discord. +- New ValueTags, configuration, dependencies, tests, or changes to unrelated consumers. + +## Decisions + +### Mirror the existing transfer responsibility split + +The wallet will reuse its existing mint-program detection, supply loading, holding lookup, decimal consistency checks, exact decimal conversion, and `signAndSendTransaction()` flow. The blockchain method accepts already-resolved raw units and program identity and only validates/serializes those low-level inputs. + +Moving state lookup into the transaction builder was rejected because it would duplicate wallet policy and blur the existing API boundary. Building the transaction directly in the wallet was rejected because low-level Solana serialization belongs to `SolanaBlockChain`. + +### Encode one standard BurnChecked instruction + +The unsigned legacy transaction will contain four account keys in privilege order: owner as writable signer/fee payer, token account and mint as writable unsigned accounts, and the selected token program as read-only unsigned. Its one compiled instruction will invoke program index 3 with account indexes token account, mint, owner and data consisting of the `BurnChecked` discriminator, little-endian unsigned-u64 amount bits, and unsigned-byte decimals. The message declares one required signature, zero read-only signed accounts, and one read-only unsigned account. + +This follows the repository's current `TransferChecked` serializer instead of introducing a transaction framework or external dependency. Although Java `long` is signed, a validated unsigned-u64 `BigInteger` is serialized through its low 64 bits with `longValue()`, preserving the complete bit pattern. + +### Validate all local builder inputs before RPC access + +The builder will parse and range-check the raw amount, range-check decimals, require a non-null supported enum program, and decode all supplied addresses before requesting a recent blockhash. This keeps invalid input deterministic and avoids unnecessary RPC traffic. The same shared address decoder used by existing transaction builders provides Base58 and 32-byte validation. + +### Treat complete-balance burn as an ordinary amount + +Wallet balance validation rejects only raw amounts greater than the holding, so equality is deliberately allowed. No close instruction is appended; the token account remains open with a zero balance. + +### Delegate construction through the cache boundary + +`CachedSolanaBlockChain` will forward the call directly because each build requires a fresh recent blockhash. Caching or deduplicating transaction construction could return stale blockhashes and is therefore unsafe. + +## Risks / Trade-offs + +- [Hand-written Solana wire encoding can be sensitive to account ordering and instruction discriminators] → Mirror the existing compiled-instruction serializer and use the standard `BurnChecked` layout with explicit constants. +- [Unsigned-u64 values above signed-long maximum appear negative as Java longs] → Validate with `BigInteger` first and serialize the low 64-bit two's-complement representation in little-endian order. +- [Submission success does not establish on-chain burn success] → Keep the API explicitly submission-only; callers may separately await the returned signature when their domain policy requires it. +- [Mint and holding state can change between validation and execution] → Let Solana's checked instruction and runtime enforce final state; do not retry automatically. + +## Migration Plan + +Add the API methods, implementations, and direct cached delegation in one source-compatible change. Existing callers need no migration because the new methods are additive. Rollback removes those additions; no data or configuration migration is involved. diff --git a/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/proposal.md b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/proposal.md new file mode 100644 index 0000000..af06efd --- /dev/null +++ b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/proposal.md @@ -0,0 +1,25 @@ +## Why + +Future Evelyn functionality needs a generic way to burn wallet-owned SPL tokens, including EVE under Token-2022. The existing wallet/blockchain separation for SPL transfers provides the right boundary for adding checked burn construction and a high-level validate-sign-submit operation without coupling confirmation or account cleanup to submission. + +## What Changes + +- Add a public blockchain operation that builds one unsigned `BurnChecked` SPL-token transaction for either the legacy SPL Token Program or Token-2022. +- Add a public wallet operation that validates blockchain state and an exact human-readable burn amount, then builds, signs, and submits the transaction once. +- Delegate burn transaction construction directly through the cached blockchain decorator without caching. +- Preserve submission-only behavior: no confirmation awaiting, retry, token-account closure, or rent recovery. +- Reuse the existing `ΩRawAmountΩ`, `ΩamountDecimalsΩ`, address, amount, and transaction ValueTags without changing Detag configuration. + +## Capabilities + +### New Capabilities + +- `solana-spl-token-burning`: Defines safe checked SPL-token burn construction and wallet-owned submission for legacy SPL Token and Token-2022 mints. + +### Modified Capabilities + +None. + +## Impact + +The public `SolanaBlockChain` and `SolanaWallet` APIs gain burn operations. Their reference implementations and `CachedSolanaBlockChain` gain corresponding construction, validation, submission, and direct-delegation behavior. No consumers, configuration, tests, generated sources, or unrelated services change. diff --git a/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/specs/solana-spl-token-burning/spec.md b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/specs/solana-spl-token-burning/spec.md new file mode 100644 index 0000000..0841cd2 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/specs/solana-spl-token-burning/spec.md @@ -0,0 +1,72 @@ +## Purpose + +Defines safe construction and wallet submission of checked SPL-token burns for both supported Solana token programs. + +## ADDED Requirements + +### Requirement: Checked SPL-token burn transaction construction +The blockchain API SHALL build an unsigned transaction containing exactly one checked SPL-token burn instruction for a supplied owner, token account, mint, raw amount, decimal count, and supported token program. The owner SHALL be the fee payer, token-account authority, and sole required signer. The transaction SHALL burn from the supplied token account and mint, use a recent blockhash, and remain unsigned and unsubmitted. + +#### Scenario: Legacy SPL Token burn construction +- **WHEN** valid burn inputs identify the original SPL Token Program +- **THEN** the returned unsigned transaction contains one `BurnChecked` instruction owned by that program with the supplied account, mint, amount, and decimals + +#### Scenario: Token-2022 burn construction +- **WHEN** valid burn inputs identify Token-2022 +- **THEN** the returned unsigned transaction contains one `BurnChecked` instruction owned by Token-2022 with the supplied account, mint, amount, and decimals + +#### Scenario: Builder does not submit +- **WHEN** a checked burn transaction is built successfully +- **THEN** the blockchain returns the unsigned serialized transaction and recent blockhash metadata without signing or submitting it + +### Requirement: Low-level burn input validation +Before fetching a blockhash, the blockchain SHALL reject null, blank, malformed, or unsupported required arguments. The raw amount SHALL be a valid positive integer representable as an unsigned 64-bit value, and the decimal count SHALL be representable as an unsigned byte. + +#### Scenario: Invalid raw amount +- **WHEN** the raw amount is null, non-numeric, zero, negative, or greater than unsigned 64-bit maximum +- **THEN** transaction construction fails with `IllegalArgumentException` before fetching a blockhash + +#### Scenario: Invalid decimals +- **WHEN** the decimal count is less than zero or greater than 255 +- **THEN** transaction construction fails with `IllegalArgumentException` before fetching a blockhash + +#### Scenario: Invalid address or program +- **WHEN** the owner, token account, or mint is null, blank, malformed, or does not decode to a Solana address, or the token program is null +- **THEN** transaction construction fails locally before fetching a blockhash + +### Requirement: Wallet-owned burn validation and exact conversion +The wallet SHALL accept a mint and positive human-readable amount, load the mint account, detect whether it is owned by the original SPL Token Program or Token-2022, obtain the mint decimals, and find its own holding under that program. It SHALL require the holding decimals to equal the mint decimals and SHALL convert the requested amount exactly to positive raw units without rounding. It SHALL reject unsupported mints, missing or inconsistent state, excessive precision, and amounts exceeding the wallet balance before transaction construction or submission. + +#### Scenario: Exact partial balance burn +- **WHEN** a supported mint and positive amount are exactly representable with the mint decimals and do not exceed the wallet holding +- **THEN** the wallet supplies the corresponding exact raw amount, its own token account, detected program, and mint decimals to checked burn construction + +#### Scenario: Complete balance burn +- **WHEN** the exact raw burn amount equals the wallet's complete token balance +- **THEN** the wallet permits the burn without requesting token-account closure or rent recovery + +#### Scenario: Amount has excessive precision +- **WHEN** the requested human-readable amount contains a non-zero fraction beyond the mint decimal precision +- **THEN** the wallet rejects it with `IllegalArgumentException` before building, signing, or submitting a transaction + +#### Scenario: Blockchain state cannot authorize burn +- **WHEN** the mint is missing or unsupported, the wallet has no holding, the holding decimals differ from the mint decimals, or the amount exceeds the holding balance +- **THEN** the wallet rejects the operation with `IllegalStateException` before building, signing, or submitting a transaction + +### Requirement: Single submission without automatic completion handling +After successful validation, the wallet SHALL build the burn through the blockchain API, sign it through its existing signing flow, submit it exactly once through its existing submission flow, and return the resulting transaction signature. It SHALL return immediately after submission without awaiting confirmation, retrying, rebuilding, re-signing, resubmitting, or closing the token account. + +#### Scenario: Burn is submitted successfully +- **WHEN** validation, construction, signing, and submission succeed +- **THEN** the wallet returns the transaction signature from the single submission without invoking transaction awaiting + +#### Scenario: Burn flow is interrupted or fails +- **WHEN** blockchain access, signing, or submission throws an I/O error or the calling thread is interrupted +- **THEN** the corresponding exception propagates and no automatic retry or subsequent completion action occurs + +### Requirement: Cached blockchain delegates burn construction +The cached blockchain decorator SHALL delegate every burn transaction construction call directly to its underlying blockchain without caching, reuse, or deduplication. + +#### Scenario: Repeated construction through cached decorator +- **WHEN** callers request burn transaction construction multiple times through the cached decorator +- **THEN** every invocation reaches the underlying blockchain independently diff --git a/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/tasks.md b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/tasks.md new file mode 100644 index 0000000..0ead5f2 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-69-add-spl-token-burn-support/tasks.md @@ -0,0 +1,22 @@ +## 1. Public APIs + +- [x] 1.1 Add the documented checked-burn transaction builder to `SolanaBlockChain` using the existing burn input ValueTags. +- [x] 1.2 Add the documented human-readable SPL-token burn operation to `SolanaWallet` with submission-only semantics. + +## 2. Blockchain Construction + +- [x] 2.1 Implement pre-RPC raw amount, decimal, program, and Solana address validation in `SolanaBlockChainImpl`. +- [x] 2.2 Serialize one unsigned `BurnChecked` transaction with owner authority/fee-payer semantics, a recent blockhash, and selectable legacy SPL Token or Token-2022 program. +- [x] 2.3 Add direct, uncached burn-construction delegation to `CachedSolanaBlockChain`. + +## 3. Wallet Burn Flow + +- [x] 3.1 Implement mint lookup, supported-program detection, supply/holding lookup, decimal consistency, and exact human-to-raw amount conversion in `SolanaWalletImpl`. +- [x] 3.2 Enforce missing-state and balance failures while permitting an exact complete-balance burn without closing the token account. +- [x] 3.3 Build, sign, and submit exactly once through existing flows, return the signature immediately, and add no awaiting, retry, or resubmission. + +## 4. Verification + +- [x] 4.1 Compile main and test source sets without adding or modifying tests and without executing a live burn or submission. +- [x] 4.2 Run strict OpenSpec validation and `git diff --check`. +- [x] 4.3 Review the complete diff for both token programs, exact amount conversion, complete-balance support, direct cached delegation, submission-only behavior, no generated/test changes, and no unrelated modifications. diff --git a/openspec/specs/solana-spl-token-burning/spec.md b/openspec/specs/solana-spl-token-burning/spec.md new file mode 100644 index 0000000..46ff02e --- /dev/null +++ b/openspec/specs/solana-spl-token-burning/spec.md @@ -0,0 +1,74 @@ +# solana-spl-token-burning Specification + +## Purpose + +Defines safe construction and wallet submission of checked SPL-token burns for both supported Solana token programs. + +## Requirements + +### Requirement: Checked SPL-token burn transaction construction +The blockchain API SHALL build an unsigned transaction containing exactly one checked SPL-token burn instruction for a supplied owner, token account, mint, raw amount, decimal count, and supported token program. The owner SHALL be the fee payer, token-account authority, and sole required signer. The transaction SHALL burn from the supplied token account and mint, use a recent blockhash, and remain unsigned and unsubmitted. + +#### Scenario: Legacy SPL Token burn construction +- **WHEN** valid burn inputs identify the original SPL Token Program +- **THEN** the returned unsigned transaction contains one `BurnChecked` instruction owned by that program with the supplied account, mint, amount, and decimals + +#### Scenario: Token-2022 burn construction +- **WHEN** valid burn inputs identify Token-2022 +- **THEN** the returned unsigned transaction contains one `BurnChecked` instruction owned by Token-2022 with the supplied account, mint, amount, and decimals + +#### Scenario: Builder does not submit +- **WHEN** a checked burn transaction is built successfully +- **THEN** the blockchain returns the unsigned serialized transaction and recent blockhash metadata without signing or submitting it + +### Requirement: Low-level burn input validation +Before fetching a blockhash, the blockchain SHALL reject null, blank, malformed, or unsupported required arguments. The raw amount SHALL be a valid positive integer representable as an unsigned 64-bit value, and the decimal count SHALL be representable as an unsigned byte. + +#### Scenario: Invalid raw amount +- **WHEN** the raw amount is null, non-numeric, zero, negative, or greater than unsigned 64-bit maximum +- **THEN** transaction construction fails with `IllegalArgumentException` before fetching a blockhash + +#### Scenario: Invalid decimals +- **WHEN** the decimal count is less than zero or greater than 255 +- **THEN** transaction construction fails with `IllegalArgumentException` before fetching a blockhash + +#### Scenario: Invalid address or program +- **WHEN** the owner, token account, or mint is null, blank, malformed, or does not decode to a Solana address, or the token program is null +- **THEN** transaction construction fails locally before fetching a blockhash + +### Requirement: Wallet-owned burn validation and exact conversion +The wallet SHALL accept a mint and positive human-readable amount, load the mint account, detect whether it is owned by the original SPL Token Program or Token-2022, obtain the mint decimals, and find its own holding under that program. It SHALL require the holding decimals to equal the mint decimals and SHALL convert the requested amount exactly to positive raw units without rounding. It SHALL reject unsupported mints, missing or inconsistent state, excessive precision, and amounts exceeding the wallet balance before transaction construction or submission. + +#### Scenario: Exact partial balance burn +- **WHEN** a supported mint and positive amount are exactly representable with the mint decimals and do not exceed the wallet holding +- **THEN** the wallet supplies the corresponding exact raw amount, its own token account, detected program, and mint decimals to checked burn construction + +#### Scenario: Complete balance burn +- **WHEN** the exact raw burn amount equals the wallet's complete token balance +- **THEN** the wallet permits the burn without requesting token-account closure or rent recovery + +#### Scenario: Amount has excessive precision +- **WHEN** the requested human-readable amount contains a non-zero fraction beyond the mint decimal precision +- **THEN** the wallet rejects it with `IllegalArgumentException` before building, signing, or submitting a transaction + +#### Scenario: Blockchain state cannot authorize burn +- **WHEN** the mint is missing or unsupported, the wallet has no holding, the holding decimals differ from the mint decimals, or the amount exceeds the holding balance +- **THEN** the wallet rejects the operation with `IllegalStateException` before building, signing, or submitting a transaction + +### Requirement: Single submission without automatic completion handling +After successful validation, the wallet SHALL build the burn through the blockchain API, sign it through its existing signing flow, submit it exactly once through its existing submission flow, and return the resulting transaction signature. It SHALL return immediately after submission without awaiting confirmation, retrying, rebuilding, re-signing, resubmitting, or closing the token account. + +#### Scenario: Burn is submitted successfully +- **WHEN** validation, construction, signing, and submission succeed +- **THEN** the wallet returns the transaction signature from the single submission without invoking transaction awaiting + +#### Scenario: Burn flow is interrupted or fails +- **WHEN** blockchain access, signing, or submission throws an I/O error or the calling thread is interrupted +- **THEN** the corresponding exception propagates and no automatic retry or subsequent completion action occurs + +### Requirement: Cached blockchain delegates burn construction +The cached blockchain decorator SHALL delegate every burn transaction construction call directly to its underlying blockchain without caching, reuse, or deduplication. + +#### Scenario: Repeated construction through cached decorator +- **WHEN** callers request burn transaction construction multiple times through the cached decorator +- **THEN** every invocation reaches the underlying blockchain independently diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava index 060a051..e3f0a22 100644 --- a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava +++ b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava @@ -117,6 +117,32 @@ public interface SolanaWallet { missingRecipientTokenAccountPolicy ) throws IOException, InterruptedException; + /** + * Burns an amount of an SPL token owned by this wallet. + * + *

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.

+ * + * @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. * 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 bd60c95..88521fe 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 @@ -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, diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava index 5bfddc7..5017f10 100644 --- a/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava +++ b/src/main/tjava/com/r35157/libs/solana/SolanaBlockChain.tjava @@ -128,6 +128,34 @@ public interface SolanaBlockChain { boolean createRecipientTokenAccount ) throws IOException, InterruptedException; + /** + * Builds an unsigned checked SPL-token burn transaction. + * + *

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.

+ * + * @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. 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 7008b13..696d18c 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 @@ -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, diff --git a/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava b/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava index a1691e7..b67327b 100644 --- a/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava +++ b/src/main/tjava/com/r35157/libs/solana/impl/ref/SolanaBlockChainImpl.tjava @@ -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");