Skip to content

Commit 45ce716

Browse files
authored
feat: resolve tokens args from user holdings (#8)
## Summary - Wallet-bound options (`swap --from`, `transfer --token`, `reclaim --token`) now resolve tokens against the user's actual holdings instead of Jupiter's global search - Mint address inputs skip the holdings fetch entirely (no performance cost) - Symbol inputs that match multiple tokens in the wallet error with disambiguation options - Extracted `DatapiClient.getTokensByMints` from duplicated batch-resolve logic in portfolio/history
1 parent f651e11 commit 45ce716

3 files changed

Lines changed: 108 additions & 38 deletions

File tree

docs/spot.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ Anywhere a token is specified (`--from`, `--to`, `--token`, `--search`), you can
99
- **Symbol** (e.g. `SOL`, `USDC`, `JUP`) — the CLI auto-resolves to the best-matching token
1010
- **Mint address** (e.g. `So11111111111111111111111111111111111111112`) — exact match
1111

12-
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).
12+
Token resolution depends on the context:
13+
14+
- **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.
15+
- **All other options** (`swap --to`, `quote --from`, `quote --to`, `tokens --search`, `history --token`) resolve via Jupiter's global token search, picking the top result.
1316

1417
## Commands
1518

@@ -111,7 +114,6 @@ jup spot reclaim --token USDC
111114
```
112115

113116
- 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
115117

116118
```js
117119
// Example JSON response:

src/clients/DatapiClient.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ export class DatapiClient {
155155
return this.#ky.get("_datapi/v1/txs/users", { searchParams }).json();
156156
}
157157

158+
public static async getTokensByMints(
159+
mints: string[]
160+
): Promise<Token[]> {
161+
if (mints.length === 0) {
162+
return [];
163+
}
164+
const BATCH_SIZE = 100;
165+
const batches: string[][] = [];
166+
for (let i = 0; i < mints.length; i += BATCH_SIZE) {
167+
batches.push(mints.slice(i, i + BATCH_SIZE));
168+
}
169+
const resolved = await Promise.all(
170+
batches.map((batch) =>
171+
this.getTokensSearch({
172+
query: batch.join(","),
173+
limit: BATCH_SIZE.toString(),
174+
})
175+
)
176+
);
177+
return resolved.flat();
178+
}
179+
158180
public static async resolveToken(input: string): Promise<Token> {
159181
const [token] = await this.getTokensSearch({
160182
query: input,

src/commands/SpotCommand.ts

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "../clients/DatapiClient.ts";
1111
import {
1212
UltraClient,
13+
type GetHoldingsResponse,
1314
type HoldingsTokenAccount,
1415
} from "../clients/UltraClient.ts";
1516
import { Asset } from "../lib/Asset.ts";
@@ -231,9 +232,9 @@ export class SpotCommand {
231232
Swap.validateAmountOpts(opts);
232233

233234
const settings = Config.load();
234-
const [signer, inputToken, outputToken] = await Promise.all([
235-
Signer.load(opts.key ?? settings.activeKey),
236-
DatapiClient.resolveToken(opts.from),
235+
const signer = await Signer.load(opts.key ?? settings.activeKey);
236+
const [inputToken, outputToken] = await Promise.all([
237+
this.resolveWalletToken(opts.from, signer.address),
237238
DatapiClient.resolveToken(opts.to),
238239
]);
239240

@@ -332,24 +333,10 @@ export class SpotCommand {
332333
allMints.push(Asset.SOL.id);
333334
}
334335

335-
const BATCH_SIZE = 100;
336336
const tokenMap = new Map<string, Token>();
337-
const batches: string[][] = [];
338-
for (let i = 0; i < allMints.length; i += BATCH_SIZE) {
339-
batches.push(allMints.slice(i, i + BATCH_SIZE));
340-
}
341-
const resolved = await Promise.all(
342-
batches.map((batch) =>
343-
DatapiClient.getTokensSearch({
344-
query: batch.join(","),
345-
limit: BATCH_SIZE.toString(),
346-
})
347-
)
348-
);
349-
for (const tokens of resolved) {
350-
for (const token of tokens) {
351-
tokenMap.set(token.id, token);
352-
}
337+
const tokens = await DatapiClient.getTokensByMints(allMints);
338+
for (const token of tokens) {
339+
tokenMap.set(token.id, token);
353340
}
354341

355342
const outputTokens: {
@@ -462,10 +449,8 @@ export class SpotCommand {
462449
Swap.validateAmountOpts(opts);
463450

464451
const settings = Config.load();
465-
const [signer, token] = await Promise.all([
466-
Signer.load(opts.key ?? settings.activeKey),
467-
DatapiClient.resolveToken(opts.token),
468-
]);
452+
const signer = await Signer.load(opts.key ?? settings.activeKey);
453+
const token = await this.resolveWalletToken(opts.token, signer.address);
469454
const multiplier = Swap.getScaledUiMultiplier(token);
470455
const chainAmount =
471456
opts.rawAmount ??
@@ -601,14 +586,9 @@ export class SpotCommand {
601586
// Resolve token metadata for all unique mints
602587
const mints = [...new Set(userTrades.map((t) => t.assetId))];
603588
const tokenMap = new Map<string, Token>();
604-
if (mints.length > 0) {
605-
const tokens = await DatapiClient.getTokensSearch({
606-
query: mints.join(","),
607-
limit: mints.length.toString(),
608-
});
609-
for (const token of tokens) {
610-
tokenMap.set(token.id, token);
611-
}
589+
const tokens = await DatapiClient.getTokensByMints(mints);
590+
for (const token of tokens) {
591+
tokenMap.set(token.id, token);
612592
}
613593

614594
const trades = [...grouped.values()]
@@ -698,10 +678,27 @@ export class SpotCommand {
698678
// Filter to single token if --token provided
699679
let mints = reclaimableMints;
700680
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] : [];
681+
if (isAddress(opts.token)) {
682+
mints = reclaimableMints.includes(opts.token) ? [opts.token] : [];
683+
} else {
684+
try {
685+
const token = await this.resolveTokenFromHoldings(
686+
opts.token,
687+
reclaimableMints
688+
);
689+
mints = [token.id];
690+
} catch (err) {
691+
if (
692+
err instanceof Error &&
693+
err.message === `Token "${opts.token}" not found in wallet.`
694+
) {
695+
throw new Error(
696+
`No reclaimable token account found for "${opts.token}".`
697+
);
698+
}
699+
throw err;
700+
}
701+
}
705702
}
706703
if (mints.length === 0) {
707704
throw new Error("No reclaimable token accounts found.");
@@ -804,6 +801,55 @@ export class SpotCommand {
804801
});
805802
}
806803

804+
private static async resolveWalletToken(
805+
input: string,
806+
walletAddress: string
807+
): Promise<Token> {
808+
if (isAddress(input)) {
809+
return DatapiClient.resolveToken(input);
810+
}
811+
const holdings = await UltraClient.getHoldings(walletAddress);
812+
return this.resolveTokenFromHoldings(
813+
input,
814+
this.getHoldingsMints(holdings)
815+
);
816+
}
817+
818+
private static async resolveTokenFromHoldings(
819+
input: string,
820+
holdingsMints: string[]
821+
): Promise<Token> {
822+
if (isAddress(input)) {
823+
if (!holdingsMints.includes(input)) {
824+
throw new Error(`Token ${input} not found in wallet.`);
825+
}
826+
return DatapiClient.resolveToken(input);
827+
}
828+
829+
const tokens = await DatapiClient.getTokensByMints(holdingsMints);
830+
const query = input.toLowerCase();
831+
const matches = tokens.filter((t) => t.symbol.toLowerCase() === query);
832+
833+
if (matches.length === 0) {
834+
throw new Error(`Token "${input}" not found in wallet.`);
835+
}
836+
if (matches.length === 1) {
837+
return matches[0]!;
838+
}
839+
const options = matches.map((t) => ` - ${t.symbol} (${t.id})`).join("\n");
840+
throw new Error(
841+
`Multiple tokens matching "${input}" found in wallet. Use the mint address instead:\n${options}`
842+
);
843+
}
844+
845+
private static getHoldingsMints(holdings: GetHoldingsResponse): string[] {
846+
const mints = Object.keys(holdings.tokens);
847+
if (BigInt(holdings.amount) > 0n && !mints.includes(Asset.SOL.id)) {
848+
mints.push(Asset.SOL.id);
849+
}
850+
return mints;
851+
}
852+
807853
private static parseTimestamp(value: string): string {
808854
if (/^\d+$/.test(value)) {
809855
return new Date(Number(value) * 1000).toISOString();

0 commit comments

Comments
 (0)