11import { findAssociatedTokenPda } from "@solana-program/token" ;
22import type { Address , Base64EncodedBytes } from "@solana/kit" ;
3+ import chalk from "chalk" ;
34import 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" ;
611import {
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