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
6 changes: 4 additions & 2 deletions docs/spot.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ Anywhere a token is specified (`--from`, `--to`, `--token`, `--search`), you can
- **Symbol** (e.g. `SOL`, `USDC`, `JUP`) — the CLI auto-resolves to the best-matching token
- **Mint address** (e.g. `So11111111111111111111111111111111111111112`) — exact match

When using a symbol, the CLI picks the top result from Jupiter's token search. Use a mint address when you need to target a specific token (e.g. to disambiguate tokens with the same symbol).
Token resolution depends on the context:

- **Wallet-bound options** (`swap --from`, `transfer --token`, `reclaim --token`) resolve against the tokens in your wallet. This ensures the CLI matches the token you actually hold, not a different token with the same symbol. If the token is not found in your wallet, the command errors. If multiple tokens share the same symbol, the CLI asks you to use the mint address instead.
- **All other options** (`swap --to`, `quote --from`, `quote --to`, `tokens --search`, `history --token`) resolve via Jupiter's global token search, picking the top result.

## Commands

Expand Down Expand Up @@ -111,7 +114,6 @@ 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:
Expand Down
22 changes: 22 additions & 0 deletions src/clients/DatapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,28 @@ export class DatapiClient {
return this.#ky.get("_datapi/v1/txs/users", { searchParams }).json();
}

public static async getTokensByMints(
mints: string[]
): Promise<Token[]> {
if (mints.length === 0) {
return [];
}
const BATCH_SIZE = 100;
const batches: string[][] = [];
for (let i = 0; i < mints.length; i += BATCH_SIZE) {
batches.push(mints.slice(i, i + BATCH_SIZE));
}
const resolved = await Promise.all(
batches.map((batch) =>
this.getTokensSearch({
query: batch.join(","),
limit: BATCH_SIZE.toString(),
})
)
);
return resolved.flat();
}

public static async resolveToken(input: string): Promise<Token> {
const [token] = await this.getTokensSearch({
query: input,
Expand Down
118 changes: 82 additions & 36 deletions src/commands/SpotCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../clients/DatapiClient.ts";
import {
UltraClient,
type GetHoldingsResponse,
type HoldingsTokenAccount,
} from "../clients/UltraClient.ts";
import { Asset } from "../lib/Asset.ts";
Expand Down Expand Up @@ -231,9 +232,9 @@ export class SpotCommand {
Swap.validateAmountOpts(opts);

const settings = Config.load();
const [signer, inputToken, outputToken] = await Promise.all([
Signer.load(opts.key ?? settings.activeKey),
DatapiClient.resolveToken(opts.from),
const signer = await Signer.load(opts.key ?? settings.activeKey);
const [inputToken, outputToken] = await Promise.all([
this.resolveWalletToken(opts.from, signer.address),
DatapiClient.resolveToken(opts.to),
]);

Expand Down Expand Up @@ -332,24 +333,10 @@ export class SpotCommand {
allMints.push(Asset.SOL.id);
}

const BATCH_SIZE = 100;
const tokenMap = new Map<string, Token>();
const batches: string[][] = [];
for (let i = 0; i < allMints.length; i += BATCH_SIZE) {
batches.push(allMints.slice(i, i + BATCH_SIZE));
}
const resolved = await Promise.all(
batches.map((batch) =>
DatapiClient.getTokensSearch({
query: batch.join(","),
limit: BATCH_SIZE.toString(),
})
)
);
for (const tokens of resolved) {
for (const token of tokens) {
tokenMap.set(token.id, token);
}
const tokens = await DatapiClient.getTokensByMints(allMints);
for (const token of tokens) {
tokenMap.set(token.id, token);
}

const outputTokens: {
Expand Down Expand Up @@ -462,10 +449,8 @@ export class SpotCommand {
Swap.validateAmountOpts(opts);

const settings = Config.load();
const [signer, token] = await Promise.all([
Signer.load(opts.key ?? settings.activeKey),
DatapiClient.resolveToken(opts.token),
]);
const signer = await Signer.load(opts.key ?? settings.activeKey);
const token = await this.resolveWalletToken(opts.token, signer.address);
const multiplier = Swap.getScaledUiMultiplier(token);
const chainAmount =
opts.rawAmount ??
Expand Down Expand Up @@ -601,14 +586,9 @@ export class SpotCommand {
// Resolve token metadata for all unique mints
const mints = [...new Set(userTrades.map((t) => t.assetId))];
const tokenMap = new Map<string, Token>();
if (mints.length > 0) {
const tokens = await DatapiClient.getTokensSearch({
query: mints.join(","),
limit: mints.length.toString(),
});
for (const token of tokens) {
tokenMap.set(token.id, token);
}
const tokens = await DatapiClient.getTokensByMints(mints);
for (const token of tokens) {
tokenMap.set(token.id, token);
}

const trades = [...grouped.values()]
Expand Down Expand Up @@ -698,10 +678,27 @@ export class SpotCommand {
// 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 (isAddress(opts.token)) {
mints = reclaimableMints.includes(opts.token) ? [opts.token] : [];
} else {
try {
const token = await this.resolveTokenFromHoldings(
opts.token,
reclaimableMints
);
mints = [token.id];
} catch (err) {
if (
err instanceof Error &&
err.message === `Token "${opts.token}" not found in wallet.`
) {
throw new Error(
`No reclaimable token account found for "${opts.token}".`
);
}
throw err;
}
}
}
if (mints.length === 0) {
throw new Error("No reclaimable token accounts found.");
Expand Down Expand Up @@ -804,6 +801,55 @@ export class SpotCommand {
});
}

private static async resolveWalletToken(
input: string,
walletAddress: string
): Promise<Token> {
if (isAddress(input)) {
return DatapiClient.resolveToken(input);
}
const holdings = await UltraClient.getHoldings(walletAddress);
return this.resolveTokenFromHoldings(
input,
this.getHoldingsMints(holdings)
);
}

private static async resolveTokenFromHoldings(
input: string,
holdingsMints: string[]
): Promise<Token> {
if (isAddress(input)) {
if (!holdingsMints.includes(input)) {
throw new Error(`Token ${input} not found in wallet.`);
}
return DatapiClient.resolveToken(input);
}

const tokens = await DatapiClient.getTokensByMints(holdingsMints);
const query = input.toLowerCase();
const matches = tokens.filter((t) => t.symbol.toLowerCase() === query);

if (matches.length === 0) {
throw new Error(`Token "${input}" not found in wallet.`);
}
if (matches.length === 1) {
return matches[0]!;
}
const options = matches.map((t) => ` - ${t.symbol} (${t.id})`).join("\n");
throw new Error(
`Multiple tokens matching "${input}" found in wallet. Use the mint address instead:\n${options}`
);
}

private static getHoldingsMints(holdings: GetHoldingsResponse): string[] {
const mints = Object.keys(holdings.tokens);
if (BigInt(holdings.amount) > 0n && !mints.includes(Asset.SOL.id)) {
mints.push(Asset.SOL.id);
}
return mints;
}

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