Skip to content

Commit f651e11

Browse files
authored
feat: add jup spot reclaim command (#7)
Close empty ATAs and reclaim locked SOL rent via the Ultra reclaim API.
1 parent 4d438df commit f651e11

6 files changed

Lines changed: 225 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ bun run ci
3232
- `KeysCommand``keys list/add/delete/edit/use/solana-import`
3333
- `LendCommand``lend earn tokens/positions/deposit/withdraw`
3434
- `PerpsCommand``perps positions/markets/open/set/close`
35-
- `SpotCommand``spot tokens/quote/swap/portfolio/transfer`
35+
- `SpotCommand``spot tokens/quote/swap/portfolio/transfer/reclaim`
3636

3737
**Libraries** (`src/lib/`):
3838

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ jup keys add key1 --private-key <key>
3535
jup spot portfolio
3636
# Swap 1 SOL to USDC
3737
jup spot swap --from SOL --to USDC --amount 1
38+
# Reclaim rent from empty token accounts
39+
jup spot reclaim
3840

3941
# Open a 3x long SOL position with $10 USDC
4042
jup perps open --asset SOL --side long --amount 10 --input USDC --leverage 3

docs/spot.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ jup spot portfolio --address <wallet-address>
102102
}
103103
```
104104

105+
### Reclaim rent from ATA
106+
107+
```bash
108+
jup spot reclaim
109+
jup spot reclaim --key mykey
110+
jup spot reclaim --token USDC
111+
```
112+
113+
- With no options, reclaims rent from all empty Associated Token Accounts (ATA) owned by the active key's wallet
114+
- `--token` accepts a token symbol or mint address to filter which account to reclaim from; ATA balance must be zero to reclaim
115+
116+
```js
117+
// Example JSON response:
118+
{
119+
"totalLamportsReclaimed": 5000, // divide by 10^9 for total SOL reclaimed
120+
"totalValueReclaimed": 0.005, // USD value of reclaimed SOL
121+
"networkFeeLamports": 5000, // divide by 10^9 for SOL fee
122+
"signatures": [ // array of tx signatures for each batch of reclaim tx
123+
"3dV98zG...",
124+
]
125+
}
126+
```
127+
105128
### View trade history
106129

107130
```bash

llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ On failure, commands exit with non-zero code with an error message. In JSON mode
1111
- [Setup](docs/setup.md): Installation of the CLI
1212
- [Config](docs/config.md): CLI settings and configurations
1313
- [Keys](docs/keys.md): Private key management
14-
- [Spot](docs/spot.md): Spot trading, transfers, token and portfolio data
14+
- [Spot](docs/spot.md): Spot trading, transfers, reclaim rent, token and portfolio data
1515
- [Perps](docs/perps.md): Perps trading (leveraged longs/shorts)
16-
- Lend: Lending and borrowing (coming soon)
16+
- [Lend](docs/lend.md): Lending and yield farming
1717
- Predictions: Create and trade prediction markets (coming soon)

src/clients/UltraClient.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export type HoldingsTokenAccount = {
5757
isAssociatedTokenAccount: boolean;
5858
decimals: number;
5959
programId: string;
60+
lamports: string;
61+
reclaimableLamports?: string;
62+
excludeFromNetWorth?: boolean;
6063
};
6164

6265
export type GetHoldingsResponse = {
@@ -105,6 +108,50 @@ type PostExecuteTransferResponse = {
105108
signature: string;
106109
};
107110

111+
export type PostReclaimCraftRequest = {
112+
owner: string;
113+
mints: string[];
114+
};
115+
116+
export type PostReclaimCraftResponse = {
117+
transactions: ReclaimTransaction[];
118+
code: number;
119+
error?: string;
120+
totalLamportsReclaimed: string;
121+
netLamportsReclaimed: string;
122+
netReclaimedUsdAmount?: number;
123+
serviceFeeLamports: string;
124+
serviceFeeUsdAmount?: number;
125+
gasCostLamports: string;
126+
gasCostUsdAmount?: number;
127+
skippedMints?: SkippedMint[];
128+
totalTime: number;
129+
expireAt: string;
130+
};
131+
132+
type ReclaimTransaction = {
133+
requestId: string;
134+
transaction: string;
135+
};
136+
137+
type SkippedMint = {
138+
mint: string;
139+
reason: "verified" | "frozen";
140+
};
141+
142+
export type PostReclaimExecuteRequest = {
143+
requestId: string;
144+
signedTransaction: string;
145+
};
146+
147+
export type PostReclaimExecuteResponse = {
148+
status: "Success" | "Failed";
149+
signature: string;
150+
code: number;
151+
error?: string;
152+
totalTime: number;
153+
};
154+
108155
export class UltraClient {
109156
static readonly #ky = ky.create({
110157
prefixUrl: `${ClientConfig.host}/ultra/v1`,
@@ -156,4 +203,20 @@ export class UltraClient {
156203
): Promise<PostExecuteTransferResponse> {
157204
return this.#ky.post("transfer/execute", { json: req }).json();
158205
}
206+
207+
public static async postReclaimCraft(req: PostReclaimCraftRequest) {
208+
return this.#ky
209+
.post<PostReclaimCraftResponse>("reclaim/craft", {
210+
json: req,
211+
})
212+
.json();
213+
}
214+
215+
public static async postReclaimExecute(req: PostReclaimExecuteRequest) {
216+
return this.#ky
217+
.post<PostReclaimExecuteResponse>("reclaim/execute", {
218+
json: req,
219+
})
220+
.json();
221+
}
159222
}

src/commands/SpotCommand.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { findAssociatedTokenPda } from "@solana-program/token";
2-
import type { Address, Base64EncodedBytes } from "@solana/kit";
2+
import { isAddress, type Address, type Base64EncodedBytes } from "@solana/kit";
33
import chalk from "chalk";
44
import type { Command } from "commander";
55

@@ -91,6 +91,12 @@ export class SpotCommand {
9191
)
9292
.option("--key <name>", "Key to use for signing")
9393
.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));
94100
}
95101

96102
private static async tokens(opts: {
@@ -357,6 +363,7 @@ export class SpotCommand {
357363
priceChange: number;
358364
isVerified?: boolean | undefined;
359365
scaledUiMultiplier?: number | undefined;
366+
reclaimableLamports?: string;
360367
}[] = [];
361368

362369
// Add combined SOL/WSOL entry
@@ -404,6 +411,7 @@ export class SpotCommand {
404411
priceChange: info.stats24h?.priceChange ?? 0,
405412
isVerified: info.isVerified,
406413
scaledUiMultiplier: multiplier,
414+
reclaimableLamports: ata.reclaimableLamports,
407415
});
408416
}
409417

@@ -671,6 +679,131 @@ export class SpotCommand {
671679
}
672680
}
673681

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+
674807
private static parseTimestamp(value: string): string {
675808
if (/^\d+$/.test(value)) {
676809
return new Date(Number(value) * 1000).toISOString();

0 commit comments

Comments
 (0)