@unclaimedsol/spl-burn-close-sdk 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 1.4.0
4
+
5
+ - Add `buildCloseEmptyTokenAccountsInstruction` and
6
+ `buildCloseEmptyTokenAccountsTransactions` for opt-in execution-time empty-account
7
+ checks. They emit exact data `[2]` with the Rust program's existing token account
8
+ layout, support classic SPL Token and Token-2022, and never fall back to burning.
9
+ - Reuse unsigned batching, `BuildOptions`, blockhash/expiry handling, fee payer
10
+ support and the 1,232-byte limit. The instruction helper makes no RPC calls.
11
+ - Verify the ABI against Rust commit `1f2b10ae668c47de97627de69cdf656878ed9b0e`
12
+ and its deterministic fixture. Extend regression, browser-data and packed-package
13
+ coverage across CJS, ESM and TypeScript bundler resolution.
14
+ - Document deployment prerequisites, skipped targets, funded/unsynchronized WSOL
15
+ exclusion, unchanged fees and the limits of preview and signature-based reporting.
16
+
17
+ Existing burn-and-close APIs, selector `[0]`, defaults and behavior are preserved.
18
+ Consumers must explicitly switch builders after the program upgrade is deployed;
19
+ an SDK dependency update alone does not protect existing cleanup calls. This release
20
+ does not add checked buffer approvals or guarantee exact preview proceeds.
21
+
22
+ ## 1.3.0
23
+
24
+ - Preserve the fetched blockhash's `lastValidBlockHeight` on built transactions and add
25
+ an optional matching height for caller-supplied blockhashes.
26
+ - Reject non-finite, non-positive, and fractional `chunkSize` values at build time,
27
+ including on empty input. The default remains 12.
28
+ - Validate unsigned serialization during building. Oversized transactions raise a clear
29
+ size error at build time instead of failing later when sent; other serialization failures
30
+ (for example an invalid blockhash or account key) report the original error as `cause`.
31
+ Choose a smaller chunk size; additional caller instructions still count against 1,232 bytes.
32
+ - Fix CommonJS TypeScript resolution with separate import/require declaration exports.
33
+ - Correct the documented hybrid fee and confirmation examples; describe compute-budget
34
+ considerations without adding a default compute-budget instruction.
35
+ - Add instruction, batching, expiry, documentation and packed-package tests with CI.
36
+
37
+ Existing instruction encoding, asynchronous helpers and return types remain unchanged.
package/README.md CHANGED
@@ -1,14 +1,25 @@
1
1
  # Burn and Close Solana SPL Tokens SDK
2
2
 
3
- A lightweight Solana SDK to **burn SPL tokens and close token accounts** with the June 2026 Unclaimed SOL token cleanup fee policy.
3
+ A lightweight Solana SDK for intentional token burning and opt-in empty-token-account cleanup, with the existing Unclaimed SOL token cleanup fee policy.
4
4
  This library only **builds unsigned transactions** – you handle signing and sending them, whether in the browser (Wallet Adapter) or server (Keypair).
5
5
 
6
+ `buildBurnAndCloseInstruction` and `buildBurnAndCloseTransactions` intentionally burn
7
+ and close (selector 0). They keep their existing behavior, including burning tokens
8
+ that arrive after an RPC balance check. Updating this dependency alone does not
9
+ change existing callers.
10
+
11
+ `buildCloseEmptyTokenAccountsInstruction` and `buildCloseEmptyTokenAccountsTransactions`
12
+ request execution-time empty-account checks (selector 2) and never burn. The program
13
+ upgrade must be deployed before consumers use these builders. This SDK does not
14
+ probe deployment or fall back to burn-and-close if selector 2 is unsupported.
15
+
6
16
  ## ✨ Features
7
17
 
8
18
  - 🔥 Burn all tokens in provided ATAs.
9
19
  - 🧹 Close token accounts, reclaiming rent to the user.
20
+ - Opt-in empty-account cleanup that preserves tokens deposited after a preview.
10
21
  - 💸 On-chain fee recipient is enforced by the program.
11
- - 🧾 Standard token cleanup lets the user keep up to 0.002 SOL per closed account; normal-account fee is 39,280 lamports.
22
+ - 🧾 Hybrid cleanup fee: `max(floor(gross * 200 / 10000), gross - 2,000,000)` lamports per closed account. For 2,039,280 lamports recovered, the fee is 40,785 lamports.
12
23
  - ⚡ Automatic batching (default 12 ATAs per transaction).
13
24
  - 🪙 Supports SPL Token and Token-2022.
14
25
  - 🔐 Supports **frontend partial signing** and **backend full signing** workflows.
@@ -18,9 +29,10 @@ This library only **builds unsigned transactions** – you handle signing and se
18
29
 
19
30
  ## 🧩 How It Works
20
31
 
21
- 1. The SDK prepares TransactionInstructions for your on-chain burn+close program.
22
- 2. Program burns tokens in each ATA, then closes it, sending rent back to the owner.
23
- 3. The program sends recovered rent above 2,000,000 lamports per successfully closed account to the hardcoded fee recipient.
32
+ 1. The SDK prepares instructions for the selected cleanup path.
33
+ 2. The legacy path burns and closes. The new close-empty path closes only eligible
34
+ empty accounts, returning their lamports to the wallet without burning tokens.
35
+ 3. The program sends the greater of 2% of recovered rent (rounded down) or the amount above 2,000,000 lamports to the hardcoded fee recipient, per successfully closed account.
24
36
  4. The SDK never asks for private keys — you sign & send transactions yourself.
25
37
 
26
38
  ---
@@ -33,13 +45,80 @@ npm i @unclaimedsol/spl-burn-close-sdk
33
45
 
34
46
  We use @solana/web3.js in peerDependencies to avoid version conflicts, so make sure you already have it installed.
35
47
 
36
- Requires Node 18+.
48
+ Requires Node 18+. On Node 18, use a compatible locked dependency graph for web3.js;
49
+ fresh dependency resolution can select an ESM-only `uuid` through `rpc-websockets`
50
+ that breaks CommonJS loading. CI tests the repository’s locked dependency graph.
37
51
 
38
52
  ---
39
53
 
40
54
  ## 🔑 Usage
41
55
 
42
- ### Signing with Keypair
56
+ ### Empty-account cleanup (requires the program upgrade)
57
+
58
+ ```typescript
59
+ import { Connection, PublicKey } from "@solana/web3.js";
60
+ import {
61
+ buildCloseEmptyTokenAccountsInstruction,
62
+ buildCloseEmptyTokenAccountsTransactions,
63
+ PROGRAM_ID,
64
+ TOKEN_PROGRAM_ID,
65
+ } from "@unclaimedsol/spl-burn-close-sdk";
66
+
67
+ const connection = new Connection("https://api.mainnet-beta.solana.com");
68
+ declare const user: PublicKey;
69
+ declare const pairs: { mint: PublicKey; ata: PublicKey }[];
70
+
71
+ // Instruction only: no RPC calls. Returns null for an empty list.
72
+ const ix = await buildCloseEmptyTokenAccountsInstruction(
73
+ PROGRAM_ID, user, pairs, TOKEN_PROGRAM_ID
74
+ );
75
+
76
+ // Unsigned batches: fetches one blockhash unless supplied in BuildOptions.
77
+ // Returns [] for an empty list. The default token program is classic SPL Token.
78
+ const txs = await buildCloseEmptyTokenAccountsTransactions(
79
+ connection, PROGRAM_ID, user, pairs, { chunkSize: 12 }, TOKEN_PROGRAM_ID
80
+ );
81
+ // Sign and send using your wallet only after confirming upgrade availability.
82
+ ```
83
+
84
+ Use `TOKEN_2022_PROGRAM_ID` for Token-2022. Each instruction uses one token program;
85
+ group accounts by program and build separate instructions or batches. `Pair.ata`
86
+ can also name a non-ATA token account. Both transaction builders use the same
87
+ `BuildOptions`, expiry metadata, fee payer defaults and packet-size checks below.
88
+
89
+ Eligibility is enforced by the upgraded program at execution, not by SDK RPC
90
+ balance checks. Ineligible targets may be skipped while eligible siblings close;
91
+ an entirely skipped batch may succeed. A built transaction or successful signature
92
+ alone does not prove every requested account closed. Check resulting account state
93
+ and execution results before reporting recovered amounts.
94
+
95
+ Funded WSOL, including unsynchronized SOL deposits, is excluded. A native account
96
+ must have zero token amount and lamports exactly equal to its recorded reserve.
97
+ The program does not burn, unwrap funded principal, or call `SyncNative` on this path.
98
+ Token-2022 support is conservative: supported account extensions are `ImmutableOwner`
99
+ and `TransferFeeAmount`, with `TransferFeeConfig` on mints. Other extensions are
100
+ skipped. Eligible withheld transfer fees may be harvested to the mint before closure.
101
+
102
+ The existing per-account fee policy applies only to successful closures; skipped
103
+ targets incur no service fee, though network transaction fees still apply. This
104
+ change addresses empty-token cleanup. It does not introduce checked buffer approvals
105
+ or guarantee exact preview proceeds. Buffer, reward and stake paths are outside this API.
106
+
107
+ Selector 2 validates effective transaction-wide privileges. Keep the fee recipient,
108
+ mints and token accounts nonsigners, and the token/system programs read-only. A
109
+ custom fee payer must not alias these accounts; the signing wallet may be the payer
110
+ but cannot also be the fee recipient. Instructions added to the same transaction
111
+ must preserve these requirements.
112
+
113
+ The ABI is verified against Rust commit
114
+ [`1f2b10ae668c47de97627de69cdf656878ed9b0e`](https://github.com/nedim1511/unclaimed-sol-close-token-program/commit/1f2b10ae668c47de97627de69cdf656878ed9b0e),
115
+ its [instruction specification](https://github.com/nedim1511/unclaimed-sol-close-token-program/blob/1f2b10ae668c47de97627de69cdf656878ed9b0e/docs/close-empty-token-accounts.md)
116
+ and [deterministic fixture](https://github.com/nedim1511/unclaimed-sol-close-token-program/blob/1f2b10ae668c47de97627de69cdf656878ed9b0e/tests/fixtures/close_empty_token_accounts.json).
117
+ Exact data is `[2]`; metas are writable fee recipient, writable signing wallet,
118
+ read-only token and system programs, then writable mint/token-account pairs.
119
+ Source and local runtime verification do not establish deployment.
120
+
121
+ ### Intentional burning: signing with Keypair
43
122
 
44
123
  ```typescript
45
124
  import { Connection, Keypair, PublicKey } from "@solana/web3.js";
@@ -65,19 +144,19 @@ const txs = await buildBurnAndCloseTransactions(
65
144
  for (const tx of txs) {
66
145
  tx.partialSign(keypair);
67
146
  const sig = await connection.sendRawTransaction(tx.serialize());
68
- let latestBlockhash = await connection.getLatestBlockhash(
69
- connection.commitment
70
- );
71
- await connection.confirmTransaction({
147
+ const confirmation = await connection.confirmTransaction({
72
148
  signature: sig,
73
- blockhash: latestBlockhash.blockhash,
74
- lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
149
+ blockhash: tx.recentBlockhash!,
150
+ lastValidBlockHeight: tx.lastValidBlockHeight!,
75
151
  });
76
- console.log("✅ Sent:", sig);
152
+ if (confirmation.value.err) {
153
+ throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
154
+ }
155
+ console.log("✅ Confirmed:", sig);
77
156
  }
78
157
  ```
79
158
 
80
- ### Signing with Wallet Adapter
159
+ ### Intentional burning: signing with Wallet Adapter
81
160
 
82
161
  ```typescript
83
162
  import { Connection, PublicKey } from "@solana/web3.js";
@@ -109,7 +188,7 @@ const txs = await buildBurnAndCloseTransactions(
109
188
  // PROGRAM_ID,
110
189
  // user,
111
190
  // pairs,
112
- // { chunkSize: 2 },
191
+ // { chunkSize: 3 },
113
192
  // TOKEN_2022_PROGRAM_ID
114
193
  // );
115
194
 
@@ -119,27 +198,72 @@ const signed = wallet.signAllTransactions
119
198
  : await Promise.all(txs.map((t) => wallet.signTransaction!(t)));
120
199
 
121
200
  // Send
122
- for (const stx of signed) {
201
+ for (const [index, stx] of signed.entries()) {
202
+ // Wallets may reconstruct transactions and omit expiry metadata.
203
+ const tx = txs[index];
123
204
  const sig = await connection.sendRawTransaction(stx.serialize());
124
- let latestBlockhash = await connection.getLatestBlockhash(
125
- connection.commitment
126
- );
127
- await connection.confirmTransaction({
205
+ const confirmation = await connection.confirmTransaction({
128
206
  signature: sig,
129
- blockhash: latestBlockhash.blockhash,
130
- lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
207
+ blockhash: tx.recentBlockhash!,
208
+ lastValidBlockHeight: tx.lastValidBlockHeight!,
131
209
  });
132
- console.log("✅ Sent:", sig);
210
+ if (confirmation.value.err) {
211
+ throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
212
+ }
213
+ console.log("✅ Confirmed:", sig);
133
214
  }
134
215
  ```
135
216
 
136
217
  ---
137
218
 
219
+ ## Batching and blockhash expiry
220
+
221
+ `chunkSize` defaults to 12 and must be a finite positive integer. Invalid values and
222
+ transactions that cannot serialize within the legacy 1,232-byte limit now throw during
223
+ building. Size depends on unique accounts and signers, including a separate fee payer;
224
+ there is no fixed pair cap. Instructions added afterwards (including compute budget or
225
+ memo instructions) count against the same limit, so recheck size after adding them.
226
+
227
+ The app uses batches of 10 classic token accounts or 3 Token-2022 accounts and also adds
228
+ a compute-unit limit. These are production configuration values, not a guarantee for
229
+ every workload; this release did not simulate their compute usage. The SDK adds no
230
+ compute-budget instruction. Choose a batch size and compute budget for your workload.
231
+
232
+ Fetched transactions carry `recentBlockhash` and its matching `lastValidBlockHeight`.
233
+ If you supply `recentBlockhash`, supply its matching `lastValidBlockHeight` too when using
234
+ the confirmation examples above. Without an explicit height it remains undefined; the
235
+ SDK never fetches a height for a caller-supplied blockhash. A height supplied without a
236
+ blockhash is ignored in favor of the fetched pair. Confirm using the original transaction
237
+ metadata, even if the wallet returns a reconstructed signed transaction.
238
+
239
+ Instruction data is a browser-safe `Uint8Array` typed as `Buffer` for web3.js compatibility;
240
+ do not call Buffer-only methods on it.
241
+
138
242
  ## 🌐 Cluster support
139
243
 
140
244
  This SDK targets the Unclaimed SOL mainnet program (`PROGRAM_ID`) and fee recipient (`FEE_RECIPIENT`).
141
245
  Transactions will only succeed on a cluster where that program is deployed.
142
246
 
247
+ ## Verification
248
+
249
+ Run `npm test` for encoding, unsigned transaction behavior and legacy regressions.
250
+ Run `npm run test:package` to pack the actual package, verify its contents, load
251
+ CJS/ESM exports and compile consumers with Node16 and bundler type resolution.
252
+
253
+ For local runtime evidence, run `npm run test:runtime` with the Rust repository
254
+ at `../unclaimed-sol-rust-program`, its verified SBF artifact already built, and
255
+ `solana-test-validator` installed. An alternative Rust checkout can be supplied
256
+ with `npm run test:runtime -- /path/to/unclaimed-sol-rust-program`.
257
+ The harness checks the pinned Rust commit and artifact hash, starts an isolated
258
+ validator on local ports 18899–18940, and uses generated test keys and synthetic
259
+ accounts. It verifies closures, fees, late deposits, WSOL exclusions and legacy
260
+ burning for both token programs. Temporary ledger/log files are retained at the
261
+ printed path. The runtime harness is excluded from the published package.
262
+
263
+ Local runtime success does not verify a deployed cluster or certify future token
264
+ program versions. See [the 1.4.0 release handoff](https://github.com/unclaimed-sol/spl-burn-close-sdk/blob/main/docs/release-1.4.0.md)
265
+ in the source repository for evidence limits and MCP adoption steps.
266
+
143
267
  ---
144
268
 
145
269
  ## 💻 Demo
@@ -150,7 +274,7 @@ There is a nice tutorial guide with a demo repository that shows the usage of th
150
274
 
151
275
  ## 📜 Disclaimer
152
276
 
153
- This SDK only **builds transactions** to burn tokens and close accounts. You are solely responsible for signing and sending them. We are not liable for any loss of funds.
277
+ This SDK only **builds instructions and unsigned transactions** for the selected cleanup path. You are solely responsible for signing and sending them. We are not liable for any loss of funds.
154
278
 
155
279
  ---
156
280
 
package/dist/index.cjs CHANGED
@@ -2,7 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TOKEN_2022_PROGRAM_ID = exports.TOKEN_PROGRAM_ID = exports.FEE_RECIPIENT = exports.PROGRAM_ID = void 0;
4
4
  exports.buildBurnAndCloseTransactions = buildBurnAndCloseTransactions;
5
+ exports.buildCloseEmptyTokenAccountsTransactions = buildCloseEmptyTokenAccountsTransactions;
5
6
  exports.buildBurnAndCloseInstruction = buildBurnAndCloseInstruction;
7
+ exports.buildCloseEmptyTokenAccountsInstruction = buildCloseEmptyTokenAccountsInstruction;
6
8
  const web3_js_1 = require("@solana/web3.js");
7
9
  /** Exporting on-chain program ID for convenience */
8
10
  exports.PROGRAM_ID = new web3_js_1.PublicKey("UNCaXzXkR3vp8mbCJyxWUvwuRk5uHgzrwe6jcWPfiUR");
@@ -10,6 +12,17 @@ exports.PROGRAM_ID = new web3_js_1.PublicKey("UNCaXzXkR3vp8mbCJyxWUvwuRk5uHgzrwe
10
12
  exports.FEE_RECIPIENT = new web3_js_1.PublicKey("uncNCRycMtNPj2kXHfwiCgNrzCTnC9xU5FS8ZeWyn3M");
11
13
  exports.TOKEN_PROGRAM_ID = new web3_js_1.PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
12
14
  exports.TOKEN_2022_PROGRAM_ID = new web3_js_1.PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
15
+ /** Legacy transaction wire limit, including the signature-count prefix and signature slots. */
16
+ const PACKET_DATA_SIZE = 1232;
17
+ function packetSizeError(operation) {
18
+ return new Error(`${operation}: transaction could not be serialized within the 1,232-byte limit. ` +
19
+ "Reduce chunkSize.");
20
+ }
21
+ /** web3.js reports oversized messages as "Transaction too large" or a buffer overrun. */
22
+ function isPacketSizeError(error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return /Transaction too large|encoding overruns (?:Uint8Array|Buffer)/i.test(message);
25
+ }
13
26
  /**
14
27
  * Build *unsigned* transactions that call the on-chain burn+close program.
15
28
  * You sign and send them yourself (frontend wallet or backend keypair).
@@ -23,42 +36,58 @@ exports.TOKEN_2022_PROGRAM_ID = new web3_js_1.PublicKey("TokenzQdBNbLqP5VEhdkAS6
23
36
  * @returns Array of *unsigned* Transaction objects
24
37
  */
25
38
  async function buildBurnAndCloseTransactions(connection, programId, user, pairs, options = {}, tokenProgramId = exports.TOKEN_PROGRAM_ID) {
39
+ return buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, 0);
40
+ }
41
+ /**
42
+ * Build unsigned transactions requesting execution-time empty-account checks.
43
+ * Requires the upgraded program's selector 2. It never burns or falls back to
44
+ * burn-and-close. Ineligible targets can be skipped, including funded WSOL.
45
+ * Only a blockhash is fetched; the program enforces account eligibility.
46
+ */
47
+ async function buildCloseEmptyTokenAccountsTransactions(connection, programId, user, pairs, options = {}, tokenProgramId = exports.TOKEN_PROGRAM_ID) {
48
+ return buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, 2);
49
+ }
50
+ async function buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, selector) {
51
+ const operation = selector === 0 ? "Burn and close" : "Close empty token accounts";
52
+ const chunkSize = options.chunkSize ?? 12;
53
+ if (!Number.isFinite(chunkSize) || !Number.isInteger(chunkSize) || chunkSize <= 0) {
54
+ throw new Error(`${operation}: chunkSize must be a finite positive integer.`);
55
+ }
26
56
  if (!pairs.length)
27
57
  return [];
28
- const chunkSize = Math.max(1, options.chunkSize ?? 12);
29
58
  const commitment = options.commitment ?? "confirmed";
30
59
  const feePayer = options.feePayer ?? user;
31
- // Split into chunks so we stay under CU / account limits
60
+ // Split into batches; callers must choose a suitable compute budget.
32
61
  const chunks = [];
33
62
  for (let i = 0; i < pairs.length; i += chunkSize) {
34
63
  chunks.push(pairs.slice(i, i + chunkSize));
35
64
  }
36
- // Instruction data: [selector=0]
37
65
  // Use Uint8Array (browser-safe) and cast for web3.js' Buffer typing.
38
- const data = new Uint8Array([0]);
39
- const recentBlockhash = options.recentBlockhash ??
40
- (await connection.getLatestBlockhash({ commitment })).blockhash;
66
+ const data = new Uint8Array([selector]);
67
+ const { blockhash, lastValidBlockHeight } = options.recentBlockhash !== undefined
68
+ ? { blockhash: options.recentBlockhash, lastValidBlockHeight: options.lastValidBlockHeight }
69
+ : await connection.getLatestBlockhash({ commitment });
41
70
  const txs = [];
42
71
  for (const chunk of chunks) {
43
- const keys = [
44
- // NOTE: Mark the user as signer in the instruction meta;
45
- // the caller must actually sign the Transaction later.
46
- { pubkey: exports.FEE_RECIPIENT, isSigner: false, isWritable: true },
47
- { pubkey: user, isSigner: true, isWritable: true },
48
- { pubkey: tokenProgramId, isSigner: false, isWritable: false },
49
- { pubkey: web3_js_1.SystemProgram.programId, isSigner: false, isWritable: false },
50
- ];
51
- for (const { mint, ata } of chunk) {
52
- keys.push({ pubkey: mint, isSigner: false, isWritable: true }, { pubkey: ata, isSigner: false, isWritable: true });
53
- }
54
- const ix = new web3_js_1.TransactionInstruction({
55
- programId,
56
- keys,
57
- data,
58
- });
72
+ const ix = tokenAccountInstruction(programId, user, chunk, tokenProgramId, data);
59
73
  const tx = new web3_js_1.Transaction().add(ix);
60
74
  tx.feePayer = feePayer;
61
- tx.recentBlockhash = recentBlockhash;
75
+ tx.recentBlockhash = blockhash;
76
+ tx.lastValidBlockHeight = lastValidBlockHeight;
77
+ let wireSize;
78
+ try {
79
+ wireSize = tx.serialize({ requireAllSignatures: false }).length;
80
+ }
81
+ catch (error) {
82
+ if (isPacketSizeError(error)) {
83
+ throw packetSizeError(operation);
84
+ }
85
+ throw new Error(`${operation}: transaction could not be serialized. ` +
86
+ "Check the supplied transaction inputs, including recentBlockhash and account keys.", { cause: error });
87
+ }
88
+ if (wireSize > PACKET_DATA_SIZE) {
89
+ throw packetSizeError(operation);
90
+ }
62
91
  txs.push(tx);
63
92
  }
64
93
  return txs;
@@ -70,13 +99,29 @@ async function buildBurnAndCloseTransactions(connection, programId, user, pairs,
70
99
  * Unlike `buildBurnAndCloseTransactions`, this helper does **not**
71
100
  * create or sign full transactions — it only constructs one
72
101
  * instruction for a batch of (mint, ATA) pairs.
102
+ * Instruction data is a browser-safe Uint8Array, typed as Buffer for web3.js;
103
+ * do not rely on Buffer-only methods on the returned data.
73
104
  */
74
105
  async function buildBurnAndCloseInstruction(programId, user, pairs, tokenProgramId) {
75
106
  if (!pairs.length) {
76
107
  return null;
77
108
  }
78
- // instruction data selector: 0 = burn_and_close_token_accounts
79
- const data = new Uint8Array([0]);
109
+ return tokenAccountInstruction(programId, user, pairs, tokenProgramId, new Uint8Array([0]));
110
+ }
111
+ /**
112
+ * Build one close-empty instruction for SPL Token or Token-2022, without RPC.
113
+ * Returns null for empty input. Requires deployed selector 2 support; eligibility
114
+ * is checked by the program at execution, and ineligible targets may be skipped.
115
+ * This path never burns, including when tokens arrive after a preview.
116
+ * Data is a browser-safe Uint8Array typed as Buffer; avoid Buffer-only methods.
117
+ */
118
+ async function buildCloseEmptyTokenAccountsInstruction(programId, user, pairs, tokenProgramId) {
119
+ if (!pairs.length) {
120
+ return null;
121
+ }
122
+ return tokenAccountInstruction(programId, user, pairs, tokenProgramId, new Uint8Array([2]));
123
+ }
124
+ function tokenAccountInstruction(programId, user, pairs, tokenProgramId, data) {
80
125
  // accounts order MUST match the on-chain program's expectation:
81
126
  // [0] fee_recipient_account (your fee recipient)
82
127
  // [1] user_wallet_account (the signer)
@@ -0,0 +1,68 @@
1
+ import { Connection, PublicKey, Transaction, TransactionInstruction, Commitment } from "@solana/web3.js";
2
+ /** Exporting on-chain program ID for convenience */
3
+ export declare const PROGRAM_ID: PublicKey;
4
+ /** Hardcoded fee recipient on-chain expectation */
5
+ export declare const FEE_RECIPIENT: PublicKey;
6
+ export declare const TOKEN_PROGRAM_ID: PublicKey;
7
+ export declare const TOKEN_2022_PROGRAM_ID: PublicKey;
8
+ export type Pair = {
9
+ mint: PublicKey;
10
+ ata: PublicKey;
11
+ };
12
+ export type BuildOptions = {
13
+ /** Max pairs per transaction: a finite positive integer (default: 12). */
14
+ chunkSize?: number;
15
+ /** Use this commitment when fetching blockhash (default: "confirmed") */
16
+ commitment?: Commitment;
17
+ /**
18
+ * Optional explicit fee payer (defaults to `user`)
19
+ * Note: whoever signs must cover fees on-chain.
20
+ */
21
+ feePayer?: PublicKey;
22
+ /**
23
+ * Provide your own recent blockhash (advanced). If omitted,
24
+ * we fetch a fresh one and reuse it for all built transactions.
25
+ */
26
+ recentBlockhash?: string;
27
+ /** Expiry height matching recentBlockhash; ignored when the blockhash is fetched. */
28
+ lastValidBlockHeight?: number;
29
+ };
30
+ /**
31
+ * Build *unsigned* transactions that call the on-chain burn+close program.
32
+ * You sign and send them yourself (frontend wallet or backend keypair).
33
+ *
34
+ * @param connection Solana RPC connection
35
+ * @param programId Your deployed program ID
36
+ * @param user The user whose ATAs will be burned/closed
37
+ * @param pairs Array of { mint, ata } to process
38
+ * @param options Chunking / blockhash / payer tweaks
39
+ * @param tokenProgramId Support for multiple Token programs
40
+ * @returns Array of *unsigned* Transaction objects
41
+ */
42
+ export declare function buildBurnAndCloseTransactions(connection: Connection, programId: PublicKey, user: PublicKey, pairs: Pair[], options?: BuildOptions, tokenProgramId?: PublicKey): Promise<Transaction[]>;
43
+ /**
44
+ * Build unsigned transactions requesting execution-time empty-account checks.
45
+ * Requires the upgraded program's selector 2. It never burns or falls back to
46
+ * burn-and-close. Ineligible targets can be skipped, including funded WSOL.
47
+ * Only a blockhash is fetched; the program enforces account eligibility.
48
+ */
49
+ export declare function buildCloseEmptyTokenAccountsTransactions(connection: Connection, programId: PublicKey, user: PublicKey, pairs: Pair[], options?: BuildOptions, tokenProgramId?: PublicKey): Promise<Transaction[]>;
50
+ /**
51
+ * Build a single **TransactionInstruction** that calls the on-chain
52
+ * burn+close program for a specific Token Program (either SPL Token or Token-2022).
53
+ *
54
+ * Unlike `buildBurnAndCloseTransactions`, this helper does **not**
55
+ * create or sign full transactions — it only constructs one
56
+ * instruction for a batch of (mint, ATA) pairs.
57
+ * Instruction data is a browser-safe Uint8Array, typed as Buffer for web3.js;
58
+ * do not rely on Buffer-only methods on the returned data.
59
+ */
60
+ export declare function buildBurnAndCloseInstruction(programId: PublicKey, user: PublicKey, pairs: Pair[], tokenProgramId: PublicKey): Promise<TransactionInstruction | null>;
61
+ /**
62
+ * Build one close-empty instruction for SPL Token or Token-2022, without RPC.
63
+ * Returns null for empty input. Requires deployed selector 2 support; eligibility
64
+ * is checked by the program at execution, and ineligible targets may be skipped.
65
+ * This path never burns, including when tokens arrive after a preview.
66
+ * Data is a browser-safe Uint8Array typed as Buffer; avoid Buffer-only methods.
67
+ */
68
+ export declare function buildCloseEmptyTokenAccountsInstruction(programId: PublicKey, user: PublicKey, pairs: Pair[], tokenProgramId: PublicKey): Promise<TransactionInstruction | null>;
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export type Pair = {
10
10
  ata: PublicKey;
11
11
  };
12
12
  export type BuildOptions = {
13
- /** Max pairs per transaction (default: 12) */
13
+ /** Max pairs per transaction: a finite positive integer (default: 12). */
14
14
  chunkSize?: number;
15
15
  /** Use this commitment when fetching blockhash (default: "confirmed") */
16
16
  commitment?: Commitment;
@@ -24,6 +24,8 @@ export type BuildOptions = {
24
24
  * we fetch a fresh one and reuse it for all built transactions.
25
25
  */
26
26
  recentBlockhash?: string;
27
+ /** Expiry height matching recentBlockhash; ignored when the blockhash is fetched. */
28
+ lastValidBlockHeight?: number;
27
29
  };
28
30
  /**
29
31
  * Build *unsigned* transactions that call the on-chain burn+close program.
@@ -38,6 +40,13 @@ export type BuildOptions = {
38
40
  * @returns Array of *unsigned* Transaction objects
39
41
  */
40
42
  export declare function buildBurnAndCloseTransactions(connection: Connection, programId: PublicKey, user: PublicKey, pairs: Pair[], options?: BuildOptions, tokenProgramId?: PublicKey): Promise<Transaction[]>;
43
+ /**
44
+ * Build unsigned transactions requesting execution-time empty-account checks.
45
+ * Requires the upgraded program's selector 2. It never burns or falls back to
46
+ * burn-and-close. Ineligible targets can be skipped, including funded WSOL.
47
+ * Only a blockhash is fetched; the program enforces account eligibility.
48
+ */
49
+ export declare function buildCloseEmptyTokenAccountsTransactions(connection: Connection, programId: PublicKey, user: PublicKey, pairs: Pair[], options?: BuildOptions, tokenProgramId?: PublicKey): Promise<Transaction[]>;
41
50
  /**
42
51
  * Build a single **TransactionInstruction** that calls the on-chain
43
52
  * burn+close program for a specific Token Program (either SPL Token or Token-2022).
@@ -45,5 +54,15 @@ export declare function buildBurnAndCloseTransactions(connection: Connection, pr
45
54
  * Unlike `buildBurnAndCloseTransactions`, this helper does **not**
46
55
  * create or sign full transactions — it only constructs one
47
56
  * instruction for a batch of (mint, ATA) pairs.
57
+ * Instruction data is a browser-safe Uint8Array, typed as Buffer for web3.js;
58
+ * do not rely on Buffer-only methods on the returned data.
48
59
  */
49
60
  export declare function buildBurnAndCloseInstruction(programId: PublicKey, user: PublicKey, pairs: Pair[], tokenProgramId: PublicKey): Promise<TransactionInstruction | null>;
61
+ /**
62
+ * Build one close-empty instruction for SPL Token or Token-2022, without RPC.
63
+ * Returns null for empty input. Requires deployed selector 2 support; eligibility
64
+ * is checked by the program at execution, and ineligible targets may be skipped.
65
+ * This path never burns, including when tokens arrive after a preview.
66
+ * Data is a browser-safe Uint8Array typed as Buffer; avoid Buffer-only methods.
67
+ */
68
+ export declare function buildCloseEmptyTokenAccountsInstruction(programId: PublicKey, user: PublicKey, pairs: Pair[], tokenProgramId: PublicKey): Promise<TransactionInstruction | null>;
package/dist/index.js CHANGED
@@ -5,6 +5,17 @@ export const PROGRAM_ID = new PublicKey("UNCaXzXkR3vp8mbCJyxWUvwuRk5uHgzrwe6jcWP
5
5
  export const FEE_RECIPIENT = new PublicKey("uncNCRycMtNPj2kXHfwiCgNrzCTnC9xU5FS8ZeWyn3M");
6
6
  export const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
7
7
  export const TOKEN_2022_PROGRAM_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
8
+ /** Legacy transaction wire limit, including the signature-count prefix and signature slots. */
9
+ const PACKET_DATA_SIZE = 1232;
10
+ function packetSizeError(operation) {
11
+ return new Error(`${operation}: transaction could not be serialized within the 1,232-byte limit. ` +
12
+ "Reduce chunkSize.");
13
+ }
14
+ /** web3.js reports oversized messages as "Transaction too large" or a buffer overrun. */
15
+ function isPacketSizeError(error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return /Transaction too large|encoding overruns (?:Uint8Array|Buffer)/i.test(message);
18
+ }
8
19
  /**
9
20
  * Build *unsigned* transactions that call the on-chain burn+close program.
10
21
  * You sign and send them yourself (frontend wallet or backend keypair).
@@ -18,42 +29,58 @@ export const TOKEN_2022_PROGRAM_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC
18
29
  * @returns Array of *unsigned* Transaction objects
19
30
  */
20
31
  export async function buildBurnAndCloseTransactions(connection, programId, user, pairs, options = {}, tokenProgramId = TOKEN_PROGRAM_ID) {
32
+ return buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, 0);
33
+ }
34
+ /**
35
+ * Build unsigned transactions requesting execution-time empty-account checks.
36
+ * Requires the upgraded program's selector 2. It never burns or falls back to
37
+ * burn-and-close. Ineligible targets can be skipped, including funded WSOL.
38
+ * Only a blockhash is fetched; the program enforces account eligibility.
39
+ */
40
+ export async function buildCloseEmptyTokenAccountsTransactions(connection, programId, user, pairs, options = {}, tokenProgramId = TOKEN_PROGRAM_ID) {
41
+ return buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, 2);
42
+ }
43
+ async function buildTokenAccountTransactions(connection, programId, user, pairs, options, tokenProgramId, selector) {
44
+ const operation = selector === 0 ? "Burn and close" : "Close empty token accounts";
45
+ const chunkSize = options.chunkSize ?? 12;
46
+ if (!Number.isFinite(chunkSize) || !Number.isInteger(chunkSize) || chunkSize <= 0) {
47
+ throw new Error(`${operation}: chunkSize must be a finite positive integer.`);
48
+ }
21
49
  if (!pairs.length)
22
50
  return [];
23
- const chunkSize = Math.max(1, options.chunkSize ?? 12);
24
51
  const commitment = options.commitment ?? "confirmed";
25
52
  const feePayer = options.feePayer ?? user;
26
- // Split into chunks so we stay under CU / account limits
53
+ // Split into batches; callers must choose a suitable compute budget.
27
54
  const chunks = [];
28
55
  for (let i = 0; i < pairs.length; i += chunkSize) {
29
56
  chunks.push(pairs.slice(i, i + chunkSize));
30
57
  }
31
- // Instruction data: [selector=0]
32
58
  // Use Uint8Array (browser-safe) and cast for web3.js' Buffer typing.
33
- const data = new Uint8Array([0]);
34
- const recentBlockhash = options.recentBlockhash ??
35
- (await connection.getLatestBlockhash({ commitment })).blockhash;
59
+ const data = new Uint8Array([selector]);
60
+ const { blockhash, lastValidBlockHeight } = options.recentBlockhash !== undefined
61
+ ? { blockhash: options.recentBlockhash, lastValidBlockHeight: options.lastValidBlockHeight }
62
+ : await connection.getLatestBlockhash({ commitment });
36
63
  const txs = [];
37
64
  for (const chunk of chunks) {
38
- const keys = [
39
- // NOTE: Mark the user as signer in the instruction meta;
40
- // the caller must actually sign the Transaction later.
41
- { pubkey: FEE_RECIPIENT, isSigner: false, isWritable: true },
42
- { pubkey: user, isSigner: true, isWritable: true },
43
- { pubkey: tokenProgramId, isSigner: false, isWritable: false },
44
- { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
45
- ];
46
- for (const { mint, ata } of chunk) {
47
- keys.push({ pubkey: mint, isSigner: false, isWritable: true }, { pubkey: ata, isSigner: false, isWritable: true });
48
- }
49
- const ix = new TransactionInstruction({
50
- programId,
51
- keys,
52
- data,
53
- });
65
+ const ix = tokenAccountInstruction(programId, user, chunk, tokenProgramId, data);
54
66
  const tx = new Transaction().add(ix);
55
67
  tx.feePayer = feePayer;
56
- tx.recentBlockhash = recentBlockhash;
68
+ tx.recentBlockhash = blockhash;
69
+ tx.lastValidBlockHeight = lastValidBlockHeight;
70
+ let wireSize;
71
+ try {
72
+ wireSize = tx.serialize({ requireAllSignatures: false }).length;
73
+ }
74
+ catch (error) {
75
+ if (isPacketSizeError(error)) {
76
+ throw packetSizeError(operation);
77
+ }
78
+ throw new Error(`${operation}: transaction could not be serialized. ` +
79
+ "Check the supplied transaction inputs, including recentBlockhash and account keys.", { cause: error });
80
+ }
81
+ if (wireSize > PACKET_DATA_SIZE) {
82
+ throw packetSizeError(operation);
83
+ }
57
84
  txs.push(tx);
58
85
  }
59
86
  return txs;
@@ -65,13 +92,29 @@ export async function buildBurnAndCloseTransactions(connection, programId, user,
65
92
  * Unlike `buildBurnAndCloseTransactions`, this helper does **not**
66
93
  * create or sign full transactions — it only constructs one
67
94
  * instruction for a batch of (mint, ATA) pairs.
95
+ * Instruction data is a browser-safe Uint8Array, typed as Buffer for web3.js;
96
+ * do not rely on Buffer-only methods on the returned data.
68
97
  */
69
98
  export async function buildBurnAndCloseInstruction(programId, user, pairs, tokenProgramId) {
70
99
  if (!pairs.length) {
71
100
  return null;
72
101
  }
73
- // instruction data selector: 0 = burn_and_close_token_accounts
74
- const data = new Uint8Array([0]);
102
+ return tokenAccountInstruction(programId, user, pairs, tokenProgramId, new Uint8Array([0]));
103
+ }
104
+ /**
105
+ * Build one close-empty instruction for SPL Token or Token-2022, without RPC.
106
+ * Returns null for empty input. Requires deployed selector 2 support; eligibility
107
+ * is checked by the program at execution, and ineligible targets may be skipped.
108
+ * This path never burns, including when tokens arrive after a preview.
109
+ * Data is a browser-safe Uint8Array typed as Buffer; avoid Buffer-only methods.
110
+ */
111
+ export async function buildCloseEmptyTokenAccountsInstruction(programId, user, pairs, tokenProgramId) {
112
+ if (!pairs.length) {
113
+ return null;
114
+ }
115
+ return tokenAccountInstruction(programId, user, pairs, tokenProgramId, new Uint8Array([2]));
116
+ }
117
+ function tokenAccountInstruction(programId, user, pairs, tokenProgramId, data) {
75
118
  // accounts order MUST match the on-chain program's expectation:
76
119
  // [0] fee_recipient_account (your fee recipient)
77
120
  // [1] user_wallet_account (the signer)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unclaimedsol/spl-burn-close-sdk",
3
- "version": "1.2.2",
4
- "description": "SDK to burn and close SPL token accounts via the Unclaimed SOL program with benchmarked rent cleanup fees.",
3
+ "version": "1.4.0",
4
+ "description": "Build unsigned Solana transactions to burn SPL tokens, close token accounts, and reclaim rent with the Unclaimed SOL program.",
5
5
  "license": "MIT",
6
6
  "author": "Unclaimed SOL",
7
7
  "type": "module",
@@ -15,9 +15,14 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": {
18
- "types": "./dist/index.d.ts",
19
- "import": "./dist/index.js",
20
- "require": "./dist/index.cjs"
18
+ "import": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "require": {
23
+ "types": "./dist/index.d.cts",
24
+ "default": "./dist/index.cjs"
25
+ }
21
26
  }
22
27
  },
23
28
  "main": "./dist/index.cjs",
@@ -25,11 +30,15 @@
25
30
  "types": "./dist/index.d.ts",
26
31
  "files": [
27
32
  "dist",
28
- "README.md"
33
+ "README.md",
34
+ "CHANGELOG.md"
29
35
  ],
30
36
  "scripts": {
31
37
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node ./scripts/build-cjs.js",
32
- "prepublishOnly": "npm run build"
38
+ "prepack": "npm run build",
39
+ "test": "npm run build && node --test test/*.test.js",
40
+ "test:package": "node scripts/test-package.js",
41
+ "test:runtime": "npm run build && node scripts/test-runtime.js"
33
42
  },
34
43
  "peerDependencies": {
35
44
  "@solana/web3.js": "^1.98.4"