Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ bun run ci
- `KeysCommand` — `keys list/add/delete/edit/use/solana-import`
- `LendCommand` — `lend earn tokens/positions/deposit/withdraw`
- `PerpsCommand` — `perps positions/markets/open/set/close`
- `SpotCommand` — `spot tokens/quote/swap/portfolio/transfer`
- `SpotCommand` — `spot tokens/quote/swap/portfolio/transfer/reclaim`

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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jup keys add key1 --private-key <key>
jup spot portfolio
# Swap 1 SOL to USDC
jup spot swap --from SOL --to USDC --amount 1
# Reclaim rent from empty token accounts
jup spot reclaim

# Open a 3x long SOL position with $10 USDC
jup perps open --asset SOL --side long --amount 10 --input USDC --leverage 3
Expand Down
23 changes: 23 additions & 0 deletions docs/spot.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ jup spot portfolio --address <wallet-address>
}
```

### Reclaim rent from ATA

```bash
jup spot reclaim
jup spot reclaim --key mykey
jup spot reclaim --token USDC
```

- With no options, reclaims rent from all empty Associated Token Accounts (ATA) owned by the active key's wallet
- `--token` accepts a token symbol or mint address to filter which account to reclaim from; ATA balance must be zero to reclaim

```js
// Example JSON response:
{
"totalLamportsReclaimed": 5000, // divide by 10^9 for total SOL reclaimed
"totalValueReclaimed": 0.005, // USD value of reclaimed SOL
"networkFeeLamports": 5000, // divide by 10^9 for SOL fee
"signatures": [ // array of tx signatures for each batch of reclaim tx
"3dV98zG...",
]
}
```

### View trade history

```bash
Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ On failure, commands exit with non-zero code with an error message. In JSON mode
- [Setup](docs/setup.md): Installation of the CLI
- [Config](docs/config.md): CLI settings and configurations
- [Keys](docs/keys.md): Private key management
- [Spot](docs/spot.md): Spot trading, transfers, token and portfolio data
- [Spot](docs/spot.md): Spot trading, transfers, reclaim rent, token and portfolio data
- [Perps](docs/perps.md): Perps trading (leveraged longs/shorts)
- Lend: Lending and borrowing (coming soon)
- [Lend](docs/lend.md): Lending and yield farming
- Predictions: Create and trade prediction markets (coming soon)
63 changes: 63 additions & 0 deletions src/clients/UltraClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export type HoldingsTokenAccount = {
isAssociatedTokenAccount: boolean;
decimals: number;
programId: string;
lamports: string;
reclaimableLamports?: string;
excludeFromNetWorth?: boolean;
};

export type GetHoldingsResponse = {
Expand Down Expand Up @@ -105,6 +108,50 @@ type PostExecuteTransferResponse = {
signature: string;
};

export type PostReclaimCraftRequest = {
owner: string;
mints: string[];
};

export type PostReclaimCraftResponse = {
transactions: ReclaimTransaction[];
code: number;
error?: string;
totalLamportsReclaimed: string;
netLamportsReclaimed: string;
netReclaimedUsdAmount?: number;
serviceFeeLamports: string;
serviceFeeUsdAmount?: number;
gasCostLamports: string;
gasCostUsdAmount?: number;
skippedMints?: SkippedMint[];
totalTime: number;
expireAt: string;
};

type ReclaimTransaction = {
requestId: string;
transaction: string;
};

type SkippedMint = {
mint: string;
reason: "verified" | "frozen";
};

export type PostReclaimExecuteRequest = {
requestId: string;
signedTransaction: string;
};

export type PostReclaimExecuteResponse = {
status: "Success" | "Failed";
signature: string;
code: number;
error?: string;
totalTime: number;
};

export class UltraClient {
static readonly #ky = ky.create({
prefixUrl: `${ClientConfig.host}/ultra/v1`,
Expand Down Expand Up @@ -156,4 +203,20 @@ export class UltraClient {
): Promise<PostExecuteTransferResponse> {
return this.#ky.post("transfer/execute", { json: req }).json();
}

public static async postReclaimCraft(req: PostReclaimCraftRequest) {
return this.#ky
.post<PostReclaimCraftResponse>("reclaim/craft", {
json: req,
})
.json();
}

public static async postReclaimExecute(req: PostReclaimExecuteRequest) {
return this.#ky
.post<PostReclaimExecuteResponse>("reclaim/execute", {
json: req,
})
.json();
}
}
135 changes: 134 additions & 1 deletion src/commands/SpotCommand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { findAssociatedTokenPda } from "@solana-program/token";
import type { Address, Base64EncodedBytes } from "@solana/kit";
import { isAddress, type Address, type Base64EncodedBytes } from "@solana/kit";
import chalk from "chalk";
import type { Command } from "commander";

Expand Down Expand Up @@ -91,6 +91,12 @@ export class SpotCommand {
)
.option("--key <name>", "Key to use for signing")
.action((opts) => this.transfer(opts));
spot
.command("reclaim")
.description("Reclaim SOL rent from empty token accounts")
.option("--key <name>", "Key to use for signing")
.option("--token <token>", "Token symbol or mint address to reclaim")
.action((opts) => this.reclaim(opts));
}

private static async tokens(opts: {
Expand Down Expand Up @@ -357,6 +363,7 @@ export class SpotCommand {
priceChange: number;
isVerified?: boolean | undefined;
scaledUiMultiplier?: number | undefined;
reclaimableLamports?: string;
}[] = [];

// Add combined SOL/WSOL entry
Expand Down Expand Up @@ -404,6 +411,7 @@ export class SpotCommand {
priceChange: info.stats24h?.priceChange ?? 0,
isVerified: info.isVerified,
scaledUiMultiplier: multiplier,
reclaimableLamports: ata.reclaimableLamports,
});
}

Expand Down Expand Up @@ -671,6 +679,131 @@ export class SpotCommand {
}
}

private static async reclaim(opts: {
key?: string;
token?: string;
}): Promise<void> {
const signer = await Signer.load(opts.key ?? Config.load().activeKey);
const holdings = await UltraClient.getHoldings(signer.address);

// Extract reclaimable mints from holdings ATAs
const reclaimableMints: string[] = [];
for (const [mint, accounts] of Object.entries(holdings.tokens)) {
const ata = accounts.find((acc) => acc.isAssociatedTokenAccount);
if (ata?.reclaimableLamports && BigInt(ata.reclaimableLamports) > 0n) {
reclaimableMints.push(mint);
}
}

// Filter to single token if --token provided
let mints = reclaimableMints;
if (opts.token) {
const mint = isAddress(opts.token)
? opts.token
: (await DatapiClient.resolveToken(opts.token)).id;
mints = reclaimableMints.includes(mint) ? [mint] : [];
}
if (mints.length === 0) {
throw new Error("No reclaimable token accounts found.");
}

// Chunk mints into batches of 200 and craft
const MAX_MINTS_PER_REQUEST = 200;
const batches: string[][] = [];
for (let i = 0; i < mints.length; i += MAX_MINTS_PER_REQUEST) {
batches.push(mints.slice(i, i + MAX_MINTS_PER_REQUEST));
}
const craftResponses = await Promise.all(
batches.map((batch) =>
UltraClient.postReclaimCraft({
owner: signer.address,
mints: batch,
})
)
);

// Aggregate across chunks
const allTransactions: { requestId: string; transaction: string }[] = [];
let netLamportsReclaimed = 0n;
let networkFeeLamports = 0n;
let totalValueReclaimed = 0;
let skippedCount = 0;

for (const r of craftResponses) {
if (r.error) {
throw new Error(r.error);
}
allTransactions.push(...r.transactions);
netLamportsReclaimed += BigInt(r.netLamportsReclaimed);
networkFeeLamports += BigInt(r.gasCostLamports);
totalValueReclaimed += r.netReclaimedUsdAmount ?? 0;
skippedCount += r.skippedMints?.length ?? 0;
}

if (allTransactions.length === 0) {
throw new Error("No reclaimable token accounts found.");
}

// Sign and execute each transaction sequentially
const signatures: string[] = [];
for (const tx of allTransactions) {
const signedTx = await signer.signTransaction(
tx.transaction as Base64EncodedBytes
);
const result = await UltraClient.postReclaimExecute({
requestId: tx.requestId,
signedTransaction: signedTx,
});
if (result.status === "Failed") {
throw new Error(result.error ?? "Reclaim transaction failed.");
}
signatures.push(result.signature);
}

if (Output.isJson()) {
Output.json({
totalLamportsReclaimed: Number(netLamportsReclaimed),
totalValueReclaimed: totalValueReclaimed,
networkFeeLamports: Number(networkFeeLamports),
signatures,
});
return;
}

const reclaimedSol = NumberConverter.fromChainAmount(
netLamportsReclaimed,
Asset.SOL.decimals
);
const networkFee = NumberConverter.fromChainAmount(
networkFeeLamports,
Asset.SOL.decimals
);
const accountCount = mints.length - skippedCount;

Output.table({
type: "vertical",
rows: [
{
label: "SOL Reclaimed",
value: `${reclaimedSol} SOL (${Output.formatDollar(totalValueReclaimed)})`,
},
{
label: "Accounts Reclaimed",
value: `${accountCount} token account${accountCount !== 1 ? "s" : ""}`,
},
{
label: "Network Fee",
value: `${networkFee} SOL`,
},
...signatures.map((sig, i) => ({
label:
signatures.length === 1 ? "Tx Signature" : `Tx Signature ${i + 1}`,
value: sig,
})),
],
});
}

private static parseTimestamp(value: string): string {
if (/^\d+$/.test(value)) {
return new Date(Number(value) * 1000).toISOString();
Expand Down
Loading