Skip to content

Commit 27a79a0

Browse files
committed
feat: add spot history command
Adds swap trade history to the spot module using the Datapi `_datapi/v1/txs/users` endpoint. Groups double-bookkeeping API entries by txHash into input/output pairs, resolves token metadata via batch search, and supports filtering by token, date range, and pagination.
1 parent 8f66a47 commit 27a79a0

4 files changed

Lines changed: 250 additions & 10 deletions

File tree

docs/spot.md

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

105+
### View trade history
106+
107+
```bash
108+
jup spot history --address <wallet-address>
109+
jup spot history --key mykey
110+
jup spot history --address <wallet-address> --token SOL
111+
jup spot history --address <wallet-address> --after 2025-01-01 --before 2025-02-01
112+
jup spot history --address <wallet-address> --limit 10 --offset 123
113+
```
114+
115+
- With no `--address` or `--key`, uses the active key's wallet
116+
- `--token` filters by token (symbol or mint address)
117+
- `--after` / `--before` accept ISO 8601 dates or UNIX timestamps
118+
- `--limit` defaults to 10, max 15
119+
- `--offset` is used for pagination; use the `next` value from the previous response to fetch the next page of results
120+
121+
```js
122+
// Example JSON response:
123+
{
124+
"trades": [
125+
{
126+
"time": "2025-01-15T10:30:00.000Z", // ISO 8601 timestamp
127+
"inputToken": { "id": "So11...1112", "symbol": "SOL", "decimals": 9 },
128+
"outputToken": { "id": "EPjF...USDC", "symbol": "USDC", "decimals": 6 },
129+
"inAmount": "1", // human-readable decimal amount
130+
"outAmount": "84.994059", // human-readable decimal amount
131+
"inUsdValue": 84.98, // USD value
132+
"outUsdValue": 84.99,
133+
"signature": "3dV98zG..." // tx signature
134+
}
135+
],
136+
"next": "123" // pagination offset for next page of results; use with --offset to fetch next page
137+
}
138+
```
139+
105140
### Transfer tokens
106141

107142
```bash

src/clients/DatapiClient.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,63 @@ type GetSearchTokensRequest = {
9595

9696
type GetSearchTokensResponse = Token[];
9797

98+
export type SpotTrade = {
99+
type: "buy" | "sell";
100+
usdVolume: number;
101+
profit: number;
102+
cost: number;
103+
txHash: string;
104+
assetId: string;
105+
blockTime: string;
106+
amount: number;
107+
price: number;
108+
};
109+
110+
type GetSpotHistoryResponse = {
111+
userTrades: SpotTrade[];
112+
next: string | null;
113+
};
114+
98115
export class DatapiClient {
99116
static readonly #ky = ky.create({
100-
prefixUrl: `${ClientConfig.host}/tokens/v2`,
117+
prefixUrl: ClientConfig.host,
101118
headers: ClientConfig.headers,
102119
throwHttpErrors: false,
103120
});
104121

105-
public static async search(
122+
public static async getTokensSearch(
106123
req: GetSearchTokensRequest
107124
): Promise<GetSearchTokensResponse> {
108-
return this.#ky.get("search", { searchParams: req }).json();
125+
return this.#ky.get("tokens/v2/search", { searchParams: req }).json();
126+
}
127+
128+
public static async getSwapsByAddress(params: {
129+
address: string;
130+
assetId?: string;
131+
after?: string;
132+
before?: string;
133+
limit?: number;
134+
offset?: string;
135+
}): Promise<GetSpotHistoryResponse> {
136+
const searchParams: Record<string, string | number> = {
137+
addresses: params.address,
138+
includeCapitalSide: "true",
139+
};
140+
if (params.assetId) {
141+
searchParams.assetId = params.assetId;
142+
}
143+
if (params.after) {
144+
searchParams.fromTs = params.after;
145+
}
146+
if (params.before) {
147+
searchParams.toTs = params.before;
148+
}
149+
if (params.limit) {
150+
searchParams.limit = Math.min(params.limit, 30);
151+
}
152+
if (params.offset) {
153+
searchParams.offset = params.offset;
154+
}
155+
return this.#ky.get("_datapi/v1/txs/users", { searchParams }).json();
109156
}
110157
}

src/clients/UltraClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ type GetTransferTxResponse =
8888
requestId: string;
8989
transaction: string; // base64 encoded wire tx
9090
expireAt: string;
91-
feeAmount: number;
92-
feeUsdAmount: number;
91+
feeAmount?: number | undefined;
92+
feeUsdAmount?: number | undefined;
9393
}
9494
| {
9595
error: string;

src/commands/SpotCommand.ts

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { findAssociatedTokenPda } from "@solana-program/token";
22
import type { Address, Base64EncodedBytes } from "@solana/kit";
3+
import chalk from "chalk";
34
import type { Command } from "commander";
45

5-
import { DatapiClient, type Token } from "../clients/DatapiClient.ts";
6+
import {
7+
DatapiClient,
8+
type SpotTrade,
9+
type Token,
10+
} from "../clients/DatapiClient.ts";
611
import {
712
UltraClient,
813
type HoldingsTokenAccount,
@@ -56,6 +61,20 @@ export class SpotCommand {
5661
.option("--address <address>", "Wallet address to look up")
5762
.option("--key <name>", "Key to use (overrides active key)")
5863
.action((opts) => this.portfolio(opts));
64+
spot
65+
.command("history")
66+
.description("View swap trade history for a wallet")
67+
.option("--key <name>", "Key to use (overrides active key)")
68+
.option("--address <address>", "Wallet address to look up")
69+
.option("--token <token>", "Filter by token (symbol or mint address)")
70+
.option("--after <date>", "Show trades after this date or UNIX timestamp")
71+
.option(
72+
"--before <date>",
73+
"Show trades before this date or UNIX timestamp"
74+
)
75+
.option("--limit <n>", "Max number of results (max: 15)", "10")
76+
.option("--offset <offset>", "Pagination offset for next page of results")
77+
.action((opts) => this.history(opts));
5978
spot
6079
.command("transfer")
6180
.description("Transfer tokens to another wallet")
@@ -81,7 +100,7 @@ export class SpotCommand {
81100
throw new Error("--limit must be a number");
82101
}
83102

84-
const tokens = await DatapiClient.search({
103+
const tokens = await DatapiClient.getTokensSearch({
85104
query: opts.search,
86105
limit: opts.limit,
87106
});
@@ -361,7 +380,7 @@ export class SpotCommand {
361380
}
362381
const resolved = await Promise.all(
363382
batches.map((batch) =>
364-
DatapiClient.search({
383+
DatapiClient.getTokensSearch({
365384
query: batch.join(","),
366385
limit: BATCH_SIZE.toString(),
367386
})
@@ -551,7 +570,7 @@ export class SpotCommand {
551570
}
552571

553572
const networkFee = NumberConverter.fromChainAmount(
554-
txResponse.feeAmount.toString(),
573+
txResponse.feeAmount?.toString() ?? 0n,
555574
Asset.SOL.decimals
556575
);
557576

@@ -573,6 +592,142 @@ export class SpotCommand {
573592
});
574593
}
575594

595+
private static async history(opts: {
596+
key?: string;
597+
address?: string;
598+
token?: string;
599+
after?: string;
600+
before?: string;
601+
limit: string;
602+
offset?: string;
603+
}): Promise<void> {
604+
if (opts.address && opts.key) {
605+
throw new Error("Only one of --address or --key can be provided.");
606+
}
607+
608+
const limit = Number(opts.limit);
609+
if (isNaN(limit) || limit <= 0) {
610+
throw new Error("--limit must be a positive number.");
611+
}
612+
613+
const address =
614+
opts.address ??
615+
(await Signer.load(opts.key ?? Config.load().activeKey)).address;
616+
const targetAsset = opts.token
617+
? await this.resolveToken(opts.token)
618+
: undefined;
619+
const { userTrades, next } = await DatapiClient.getSwapsByAddress({
620+
address,
621+
assetId: targetAsset?.id,
622+
after: opts.after ? this.parseTimestamp(opts.after) : undefined,
623+
before: opts.before ? this.parseTimestamp(opts.before) : undefined,
624+
limit: opts.limit ? limit * 2 : undefined, // double bookkeeping
625+
offset: opts.offset,
626+
});
627+
628+
// Group double-bookkeeping entries by txHash
629+
const grouped = new Map<string, SpotTrade[]>();
630+
for (const t of userTrades) {
631+
const existing = grouped.get(t.txHash);
632+
if (existing) {
633+
existing.push(t);
634+
} else {
635+
grouped.set(t.txHash, [t]);
636+
}
637+
}
638+
639+
// Resolve token metadata for all unique mints
640+
const mints = [...new Set(userTrades.map((t) => t.assetId))];
641+
const tokenMap = new Map<string, Token>();
642+
if (mints.length > 0) {
643+
const tokens = await DatapiClient.getTokensSearch({
644+
query: mints.join(","),
645+
limit: mints.length.toString(),
646+
});
647+
for (const token of tokens) {
648+
tokenMap.set(token.id, token);
649+
}
650+
}
651+
652+
const trades = [...grouped.values()]
653+
.map((entries) => {
654+
const sell = entries.find((e) => e.type === "sell");
655+
const buy = entries.find((e) => e.type === "buy");
656+
const inputInfo = sell ? tokenMap.get(sell.assetId) : undefined;
657+
const outputInfo = buy ? tokenMap.get(buy.assetId) : undefined;
658+
return {
659+
time: (sell ?? buy)!.blockTime,
660+
inputToken: inputInfo
661+
? {
662+
id: inputInfo.id,
663+
symbol: inputInfo.symbol,
664+
decimals: inputInfo.decimals,
665+
}
666+
: null,
667+
outputToken: outputInfo
668+
? {
669+
id: outputInfo.id,
670+
symbol: outputInfo.symbol,
671+
decimals: outputInfo.decimals,
672+
}
673+
: null,
674+
inAmount: sell ? String(sell.amount) : null,
675+
outAmount: buy ? String(buy.amount) : null,
676+
inUsdValue: sell ? sell.usdVolume : null,
677+
outUsdValue: buy ? buy.usdVolume : null,
678+
signature: (sell ?? buy)!.txHash,
679+
};
680+
})
681+
.slice(0, limit);
682+
683+
if (Output.isJson()) {
684+
Output.json({
685+
trades,
686+
next,
687+
});
688+
return;
689+
}
690+
691+
if (trades.length === 0) {
692+
throw new Error("No trades found.");
693+
}
694+
695+
Output.table({
696+
type: "horizontal",
697+
headers: {
698+
time: "Time",
699+
input: "Input",
700+
output: "Output",
701+
signature: "Tx Signature",
702+
},
703+
rows: trades.map((t) => ({
704+
time: new Date(t.time).toLocaleString(),
705+
input: t.inAmount
706+
? `${t.inAmount} ${t.inputToken?.symbol ?? "?"} (${Output.formatDollar(t.inUsdValue ?? undefined)})`
707+
: chalk.gray("\u2014"),
708+
output: t.outAmount
709+
? `${t.outAmount} ${t.outputToken?.symbol ?? "?"} (${Output.formatDollar(t.outUsdValue ?? undefined)})`
710+
: chalk.gray("\u2014"),
711+
signature: t.signature,
712+
})),
713+
});
714+
715+
if (next) {
716+
console.log("\nNext offset:", next);
717+
}
718+
}
719+
720+
private static parseTimestamp(value: string): string {
721+
if (/^\d+$/.test(value)) {
722+
return new Date(Number(value) * 1000).toISOString();
723+
}
724+
const ms = new Date(value).getTime();
725+
if (isNaN(ms)) {
726+
throw new Error(`Invalid date: ${value}`);
727+
}
728+
return new Date(ms).toISOString();
729+
}
730+
576731
private static validateAmountOpts(opts: {
577732
amount?: string;
578733
rawAmount?: string;
@@ -597,7 +752,10 @@ export class SpotCommand {
597752
}
598753

599754
private static async resolveToken(input: string): Promise<Token> {
600-
const [token] = await DatapiClient.search({ query: input, limit: "1" });
755+
const [token] = await DatapiClient.getTokensSearch({
756+
query: input,
757+
limit: "1",
758+
});
601759
if (!token) {
602760
throw new Error(`Token not found: ${input}`);
603761
}

0 commit comments

Comments
 (0)