|
1 | 1 | import { findAssociatedTokenPda } from "@solana-program/token"; |
2 | | -import type { Address, Base64EncodedBytes } from "@solana/kit"; |
| 2 | +import { isAddress, type Address, type Base64EncodedBytes } from "@solana/kit"; |
3 | 3 | import chalk from "chalk"; |
4 | 4 | import type { Command } from "commander"; |
5 | 5 |
|
@@ -91,6 +91,12 @@ export class SpotCommand { |
91 | 91 | ) |
92 | 92 | .option("--key <name>", "Key to use for signing") |
93 | 93 | .action((opts) => this.transfer(opts)); |
| 94 | + spot |
| 95 | + .command("reclaim") |
| 96 | + .description("Reclaim SOL rent from empty token accounts") |
| 97 | + .option("--key <name>", "Key to use for signing") |
| 98 | + .option("--token <token>", "Token symbol or mint address to reclaim") |
| 99 | + .action((opts) => this.reclaim(opts)); |
94 | 100 | } |
95 | 101 |
|
96 | 102 | private static async tokens(opts: { |
@@ -357,6 +363,7 @@ export class SpotCommand { |
357 | 363 | priceChange: number; |
358 | 364 | isVerified?: boolean | undefined; |
359 | 365 | scaledUiMultiplier?: number | undefined; |
| 366 | + reclaimableLamports?: string; |
360 | 367 | }[] = []; |
361 | 368 |
|
362 | 369 | // Add combined SOL/WSOL entry |
@@ -404,6 +411,7 @@ export class SpotCommand { |
404 | 411 | priceChange: info.stats24h?.priceChange ?? 0, |
405 | 412 | isVerified: info.isVerified, |
406 | 413 | scaledUiMultiplier: multiplier, |
| 414 | + reclaimableLamports: ata.reclaimableLamports, |
407 | 415 | }); |
408 | 416 | } |
409 | 417 |
|
@@ -671,6 +679,131 @@ export class SpotCommand { |
671 | 679 | } |
672 | 680 | } |
673 | 681 |
|
| 682 | + private static async reclaim(opts: { |
| 683 | + key?: string; |
| 684 | + token?: string; |
| 685 | + }): Promise<void> { |
| 686 | + const signer = await Signer.load(opts.key ?? Config.load().activeKey); |
| 687 | + const holdings = await UltraClient.getHoldings(signer.address); |
| 688 | + |
| 689 | + // Extract reclaimable mints from holdings ATAs |
| 690 | + const reclaimableMints: string[] = []; |
| 691 | + for (const [mint, accounts] of Object.entries(holdings.tokens)) { |
| 692 | + const ata = accounts.find((acc) => acc.isAssociatedTokenAccount); |
| 693 | + if (ata?.reclaimableLamports && BigInt(ata.reclaimableLamports) > 0n) { |
| 694 | + reclaimableMints.push(mint); |
| 695 | + } |
| 696 | + } |
| 697 | + |
| 698 | + // Filter to single token if --token provided |
| 699 | + let mints = reclaimableMints; |
| 700 | + if (opts.token) { |
| 701 | + const mint = isAddress(opts.token) |
| 702 | + ? opts.token |
| 703 | + : (await DatapiClient.resolveToken(opts.token)).id; |
| 704 | + mints = reclaimableMints.includes(mint) ? [mint] : []; |
| 705 | + } |
| 706 | + if (mints.length === 0) { |
| 707 | + throw new Error("No reclaimable token accounts found."); |
| 708 | + } |
| 709 | + |
| 710 | + // Chunk mints into batches of 200 and craft |
| 711 | + const MAX_MINTS_PER_REQUEST = 200; |
| 712 | + const batches: string[][] = []; |
| 713 | + for (let i = 0; i < mints.length; i += MAX_MINTS_PER_REQUEST) { |
| 714 | + batches.push(mints.slice(i, i + MAX_MINTS_PER_REQUEST)); |
| 715 | + } |
| 716 | + const craftResponses = await Promise.all( |
| 717 | + batches.map((batch) => |
| 718 | + UltraClient.postReclaimCraft({ |
| 719 | + owner: signer.address, |
| 720 | + mints: batch, |
| 721 | + }) |
| 722 | + ) |
| 723 | + ); |
| 724 | + |
| 725 | + // Aggregate across chunks |
| 726 | + const allTransactions: { requestId: string; transaction: string }[] = []; |
| 727 | + let netLamportsReclaimed = 0n; |
| 728 | + let networkFeeLamports = 0n; |
| 729 | + let totalValueReclaimed = 0; |
| 730 | + let skippedCount = 0; |
| 731 | + |
| 732 | + for (const r of craftResponses) { |
| 733 | + if (r.error) { |
| 734 | + throw new Error(r.error); |
| 735 | + } |
| 736 | + allTransactions.push(...r.transactions); |
| 737 | + netLamportsReclaimed += BigInt(r.netLamportsReclaimed); |
| 738 | + networkFeeLamports += BigInt(r.gasCostLamports); |
| 739 | + totalValueReclaimed += r.netReclaimedUsdAmount ?? 0; |
| 740 | + skippedCount += r.skippedMints?.length ?? 0; |
| 741 | + } |
| 742 | + |
| 743 | + if (allTransactions.length === 0) { |
| 744 | + throw new Error("No reclaimable token accounts found."); |
| 745 | + } |
| 746 | + |
| 747 | + // Sign and execute each transaction sequentially |
| 748 | + const signatures: string[] = []; |
| 749 | + for (const tx of allTransactions) { |
| 750 | + const signedTx = await signer.signTransaction( |
| 751 | + tx.transaction as Base64EncodedBytes |
| 752 | + ); |
| 753 | + const result = await UltraClient.postReclaimExecute({ |
| 754 | + requestId: tx.requestId, |
| 755 | + signedTransaction: signedTx, |
| 756 | + }); |
| 757 | + if (result.status === "Failed") { |
| 758 | + throw new Error(result.error ?? "Reclaim transaction failed."); |
| 759 | + } |
| 760 | + signatures.push(result.signature); |
| 761 | + } |
| 762 | + |
| 763 | + if (Output.isJson()) { |
| 764 | + Output.json({ |
| 765 | + totalLamportsReclaimed: Number(netLamportsReclaimed), |
| 766 | + totalValueReclaimed: totalValueReclaimed, |
| 767 | + networkFeeLamports: Number(networkFeeLamports), |
| 768 | + signatures, |
| 769 | + }); |
| 770 | + return; |
| 771 | + } |
| 772 | + |
| 773 | + const reclaimedSol = NumberConverter.fromChainAmount( |
| 774 | + netLamportsReclaimed, |
| 775 | + Asset.SOL.decimals |
| 776 | + ); |
| 777 | + const networkFee = NumberConverter.fromChainAmount( |
| 778 | + networkFeeLamports, |
| 779 | + Asset.SOL.decimals |
| 780 | + ); |
| 781 | + const accountCount = mints.length - skippedCount; |
| 782 | + |
| 783 | + Output.table({ |
| 784 | + type: "vertical", |
| 785 | + rows: [ |
| 786 | + { |
| 787 | + label: "SOL Reclaimed", |
| 788 | + value: `${reclaimedSol} SOL (${Output.formatDollar(totalValueReclaimed)})`, |
| 789 | + }, |
| 790 | + { |
| 791 | + label: "Accounts Reclaimed", |
| 792 | + value: `${accountCount} token account${accountCount !== 1 ? "s" : ""}`, |
| 793 | + }, |
| 794 | + { |
| 795 | + label: "Network Fee", |
| 796 | + value: `${networkFee} SOL`, |
| 797 | + }, |
| 798 | + ...signatures.map((sig, i) => ({ |
| 799 | + label: |
| 800 | + signatures.length === 1 ? "Tx Signature" : `Tx Signature ${i + 1}`, |
| 801 | + value: sig, |
| 802 | + })), |
| 803 | + ], |
| 804 | + }); |
| 805 | + } |
| 806 | + |
674 | 807 | private static parseTimestamp(value: string): string { |
675 | 808 | if (/^\d+$/.test(value)) { |
676 | 809 | return new Date(Number(value) * 1000).toISOString(); |
|
0 commit comments