diff --git a/.gitignore b/.gitignore index 46041ee..bfd9d74 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,5 @@ dist # Vite logs files vite.config.js.timestamp-* -vite.config.ts.timestamp-* \ No newline at end of file +vite.config.ts.timestamp-* +docs \ No newline at end of file diff --git a/README.md b/README.md index da27028..6c58c91 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - [Liquidity](#liquidity) - [Protocol & History](#protocol--history) - [Generic Contract](#generic-contract) + - [SunPump](#sunpump) - [Global Flags](#global-flags) - [Output Formats](#output-formats) - [Built-In Token Symbols](#built-in-token-symbols) @@ -148,13 +149,13 @@ Swap executed successfully Every command level supports `--help` (or `-h`). Use it to discover options, subcommands, and flag aliases without leaving the terminal. -| Command | What it shows | -| --- | --- | -| `sun --help` | Top-level overview, global flags, full command list | -| `sun --version` | Installed CLI version | -| `sun --help` | Subcommand group help (e.g. `sun pool --help`, `sun liquidity --help`) | -| `sun --help` | Leaf command help with all options (e.g. `sun pool top-apy --help`) | -| `sun help ` | Equivalent to ` --help` | +| Command | What it shows | +| -------------------------- | ---------------------------------------------------------------------- | +| `sun --help` | Top-level overview, global flags, full command list | +| `sun --version` | Installed CLI version | +| `sun --help` | Subcommand group help (e.g. `sun pool --help`, `sun liquidity --help`) | +| `sun --help` | Leaf command help with all options (e.g. `sun pool top-apy --help`) | +| `sun help ` | Equivalent to ` --help` | ```bash sun --help # global flags + command list @@ -202,23 +203,23 @@ Wallets are managed by [`agent-wallet`](https://github.com/BofAI/agent-wallet?ta You can override wallet settings per-invocation with these root flags: -| Flag | Purpose | -| --- | --- | -| `-k, --private-key ` | One-shot private key | -| `-m, --mnemonic ` | One-shot mnemonic | -| `-i, --mnemonic-account-index ` | Mnemonic account index | +| Flag | Purpose | +| ---------------------------------- | -------------------------------- | +| `-k, --private-key ` | One-shot private key | +| `-m, --mnemonic ` | One-shot mnemonic | +| `-i, --mnemonic-account-index ` | Mnemonic account index | | `-p, --agent-wallet-password ` | Override `AGENT_WALLET_PASSWORD` | -| `-d, --agent-wallet-dir ` | Override `AGENT_WALLET_DIR` | +| `-d, --agent-wallet-dir ` | Override `AGENT_WALLET_DIR` | See [`agent-wallet`](https://github.com/BofAI/agent-wallet?tab=readme-ov-file#quick-start) for file formats and the full set of `AGENT_WALLET_*` options. ### Network -| Variable | Purpose | Default | -| --- | --- | --- | -| `TRON_NETWORK` | Target network (`mainnet`, `nile`, …) | `mainnet` | -| `TRONGRID_API_KEY` | TronGrid API key for higher rate limits | — | -| `TRON_RPC_URL` | Custom RPC endpoint | — | +| Variable | Purpose | Default | +| ------------------ | --------------------------------------- | --------- | +| `TRON_NETWORK` | Target network (`mainnet`, `nile`, …) | `mainnet` | +| `TRONGRID_API_KEY` | TronGrid API key for higher rate limits | — | +| `TRON_RPC_URL` | Custom RPC endpoint | — | ```bash export TRON_NETWORK=mainnet @@ -353,25 +354,75 @@ sun contract send transfer --args '["TRecipient","1000000"]' `contract send` returns a `tronscanUrl` on successful broadcast. +### SunPump + +Access to SunPump — read-only API for discovery (token launches, trending lists, +holder portfolios) plus on-chain trade commands +(`buy`/`sell`/`quote-buy`/`quote-sell`/`state`) that talk to the bonding-curve +contract through `sun-kit`. Read-only API calls need no wallet; trade commands do. + +SunPump is **mainnet only** — both the API host (`https://api-v2.sunpump.meme/pump-api`) +and the on-chain bonding-curve contract. Passing `--network nile` (or any non-mainnet +value) to a `sunpump` subcommand will fail fast. + +```bash +sun sunpump token king-of-hill # current king-of-the-hill token +sun sunpump token list --size 20 --sort marketCap,desc +sun sunpump token search --size 10 +sun sunpump token get # token detail +sun sunpump token holders --size 20 +sun sunpump token ranking --type MARKET_CAP --size 10 # also: VOLUME_24H, PRICE_CHANGE_24H + +sun sunpump tx token --size 20 # swap history for a token +sun sunpump tx user --size 20 # swap history for a wallet + +sun sunpump portfolio --include-zero +``` + +Trade on the bonding curve (requires a wallet; pre-launch tokens only — once a token +migrates to SunSwap, use `sun swap` instead): + +```bash +sun sunpump state # 0 NOT_EXIST · 1 TRADING · 2 READY_TO_LAUNCH · 3 LAUNCHED +sun sunpump quote-buy --trx 10 # preview, no tx +sun sunpump quote-sell --amount 1000 + +sun sunpump buy --trx 10 # spend 10 TRX +sun sunpump buy --trx 10 --slippage 0.1 # 10% slippage +sun sunpump sell --amount 1000 # sell 1000 tokens (assumes 18 decimals) +sun sunpump sell --amount 1000 --decimals 6 # override token decimals +sun --dry-run sunpump buy --trx 10 # show params without sending +``` + +`--trx` and `--amount` accept decimal values; CLI scales by TRX-Sun (1e6) and token +decimals (default 18) before calling the contract. Default slippage is 5% (meme tokens +move fast); pass `--slippage 0.005` for 0.5% or `--min-out ` for an exact floor in +base units. + +Endpoints requiring a signed message (`favors`) accept `--user-address`, +`--signature`, `--signed-message` flags. Override the base URL with +`SUNPUMP_API_BASE_URL` only when you have a custom mainnet-compatible host. + + --- ## Global Flags Inherited by every subcommand: -| Flag | Description | -| --- | --- | -| `--output ` | Output format: `table`, `json`, `tsv` | -| `--json` | Shortcut for `--output json` | -| `--fields ` | Comma-separated field filter | -| `--network ` | Override `TRON_NETWORK` | -| `-k, --private-key ` | One-shot private key | -| `-m, --mnemonic ` | One-shot mnemonic | -| `-i, --mnemonic-account-index ` | Mnemonic account index | -| `-p, --agent-wallet-password ` | Override `AGENT_WALLET_PASSWORD` | -| `-d, --agent-wallet-dir ` | Override `AGENT_WALLET_DIR` | -| `-y, --yes` | Skip confirmation prompts | -| `--dry-run` | Print intent without sending the write | +| Flag | Description | +| ---------------------------------- | -------------------------------------- | +| `--output ` | Output format: `table`, `json`, `tsv` | +| `--json` | Shortcut for `--output json` | +| `--fields ` | Comma-separated field filter | +| `--network ` | Override `TRON_NETWORK` | +| `-k, --private-key ` | One-shot private key | +| `-m, --mnemonic ` | One-shot mnemonic | +| `-i, --mnemonic-account-index ` | Mnemonic account index | +| `-p, --agent-wallet-password ` | Override `AGENT_WALLET_PASSWORD` | +| `-d, --agent-wallet-dir ` | Override `AGENT_WALLET_DIR` | +| `-y, --yes` | Skip confirmation prompts | +| `--dry-run` | Print intent without sending the write | **Examples:** @@ -387,11 +438,11 @@ sun --dry-run contract send TContract transfer --args '["TRecipient","1000000"]' ## Output Formats -| Mode | When to use | -| --- | --- | -| `table` *(default)* | Human-friendly terminal output | -| `json` | Machine-readable JSON for scripts and agents | -| `tsv` | Tab-separated values for shell pipelines | +| Mode | When to use | +| ------------------- | -------------------------------------------- | +| `table` _(default)_ | Human-friendly terminal output | +| `json` | Machine-readable JSON for scripts and agents | +| `tsv` | Tab-separated values for shell pipelines | ```bash sun pool top-apy --page-size 5 @@ -405,17 +456,17 @@ sun --output tsv token list --protocol V3 Most commands accept these symbols anywhere a token is expected. -| Symbol | Address | Decimals | -| --- | --- | --- | -| `TRX` | `T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb` | 6 | -| `WTRX` | `TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR` | 6 | -| `USDT` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 6 | -| `USDC` | `TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8` | 6 | -| `USDD` | `TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn` | 18 | -| `SUN` | `TSSMHYeV2uE9qYH95DqyoCuNCzEL1NvU3S` | 18 | -| `JST` | `TCFLL5dx5ZJdKnWuesXxi1VPwjLVmWZZy9` | 18 | -| `BTT` | `TAFjULxiVgT4qWk6UZwjqwZXTSaGaqnVp4` | 18 | -| `WIN` | `TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7` | 6 | +| Symbol | Address | Decimals | +| ------ | ------------------------------------ | -------- | +| `TRX` | `T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb` | 6 | +| `WTRX` | `TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR` | 6 | +| `USDT` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 6 | +| `USDC` | `TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8` | 6 | +| `USDD` | `TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn` | 18 | +| `SUN` | `TSSMHYeV2uE9qYH95DqyoCuNCzEL1NvU3S` | 18 | +| `JST` | `TCFLL5dx5ZJdKnWuesXxi1VPwjLVmWZZy9` | 18 | +| `BTT` | `TAFjULxiVgT4qWk6UZwjqwZXTSaGaqnVp4` | 18 | +| `WIN` | `TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7` | 6 | Symbols and raw addresses are interchangeable: @@ -430,11 +481,11 @@ sun swap T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t 1 Quick lookup for the most common errors: -| Error | Category | Jump to | -| --- | --- | --- | -| `unknown command 'nile'` | CLI parsing | [▸ Flag placement](#error-unknown-command-nile) | -| `No wallet configured` | Wallet setup | [▸ Wallet sources](#error-no-wallet-configured) | -| `Swap failed` | Execution | [▸ Swap diagnostics](#error-swap-failed) | +| Error | Category | Jump to | +| ------------------------ | ------------ | ----------------------------------------------- | +| `unknown command 'nile'` | CLI parsing | [▸ Flag placement](#error-unknown-command-nile) | +| `No wallet configured` | Wallet setup | [▸ Wallet sources](#error-no-wallet-configured) | +| `Swap failed` | Execution | [▸ Swap diagnostics](#error-swap-failed) | ### Error: `unknown command 'nile'` diff --git a/package-lock.json b/package-lock.json index f2d6104..6b052d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bankofai/sun-cli", - "version": "1.0.3", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/sun-cli", - "version": "1.0.3", + "version": "1.2.0", "license": "MIT", "dependencies": { "@bankofai/agent-wallet": "^2.3.0", diff --git a/package.json b/package.json index e97cdd8..ba25f32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/sun-cli", - "version": "1.1.0", + "version": "1.2.0", "description": "CLI tool for SUN.IO / SUNSWAP on TRON — for humans and AI agents", "main": "dist/bin.js", "type": "commonjs", diff --git a/src/bin.ts b/src/bin.ts index 0cd753c..dab54b9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -17,6 +17,7 @@ import { registerPairCommands } from './commands/pair' import { registerFarmCommands } from './commands/farm' import { registerLiquidityCommands } from './commands/liquidity' import { registerContractCommands } from './commands/contract' +import { registerSunpumpCommands } from './commands/sunpump' const { version } = require('../package.json') as { version: string } @@ -79,6 +80,7 @@ registerPairCommands(program) registerFarmCommands(program) registerLiquidityCommands(program) registerContractCommands(program) +registerSunpumpCommands(program) program.parseAsync(process.argv).catch((err) => { process.stderr.write(`${err.message}\n`) diff --git a/src/commands/sunpump.ts b/src/commands/sunpump.ts new file mode 100644 index 0000000..6b42a62 --- /dev/null +++ b/src/commands/sunpump.ts @@ -0,0 +1,845 @@ +import { Command } from 'commander' +import { parseApiResponse, writeAction } from '../lib/command' +import { getNetwork, getKit } from '../lib/context' +import { + output, + outputError, + withSpinner, + printPaginationFooter, + printKeyValue, + isJsonMode, + formatUsd, + formatTime, + formatAmount, + formatPct, +} from '../lib/output' +import { getSunPump, SunPump } from '../lib/sunpump' + +// --------------------------------------------------------------------------- +// pumpAction — local mirror of readApiAction but for the SunPump client. +// SunPump responses follow the same {code,msg,data} envelope so we reuse +// parseApiResponse from lib/command.ts. +// --------------------------------------------------------------------------- + +interface PumpActionOpts { + spinnerLabel: string + errorLabel: string + execute: (client: SunPump) => Promise + tableConfig?: { headers: string[]; toRow: (item: any) => string[] } + transform?: (data: any) => unknown + parseEnvelope?: boolean + detailFor?: (data: any) => Record | null +} + +function assertMainnet(): void { + const n = getNetwork() + if (n && n !== 'mainnet') { + throw new Error( + `SunPump is only available on mainnet (got "${n}"). Drop --network or pass --network mainnet.`, + ) + } +} + +async function pumpAction(opts: PumpActionOpts): Promise { + try { + assertMainnet() + const client = getSunPump() + const raw = await withSpinner(opts.spinnerLabel, () => opts.execute(client)) + + let data: unknown = raw + let pagination + if (opts.parseEnvelope !== false) { + const parsed = parseApiResponse(raw) + data = parsed.data + pagination = parsed.pagination + } + const final = opts.transform ? opts.transform(data) : data + if (opts.detailFor && !isJsonMode()) { + const pairs = opts.detailFor(final) + if (pairs) { + printKeyValue(pairs) + if (pagination) printPaginationFooter(pagination) + return + } + } + output(final, opts.tableConfig) + if (pagination) printPaginationFooter(pagination) + } catch (err: any) { + outputError(opts.errorLabel, err) + } +} + +function toIntOrUndef(v: unknown): number | undefined { + if (v === undefined || v === null || v === '') return undefined + const n = Number(v) + return Number.isFinite(n) ? Math.trunc(n) : undefined +} + +// --------------------------------------------------------------------------- +// Shared table configs +// --------------------------------------------------------------------------- + +function tokenPriceUsd(t: any): unknown { + const usd = t.priceInUsd ?? t.tokenPriceUsd ?? t.price + if (usd !== undefined && usd !== null && usd !== '') return usd + const trxPrice = Number(t.priceInTrx) + const trxUsdRaw = t.trxPriceInUsd + if ( + Number.isFinite(trxPrice) && + trxUsdRaw !== null && + trxUsdRaw !== undefined && + trxUsdRaw !== '' + ) { + const trxUsd = Number(trxUsdRaw) + if (Number.isFinite(trxUsd) && trxUsd > 0) return trxPrice * trxUsd + } + const mcap = Number(t.marketCap ?? t.marketCapUsd) + const supply = Number(t.totalSupply) + if (Number.isFinite(mcap) && mcap > 0 && Number.isFinite(supply) && supply > 0) { + return mcap / supply + } + return undefined +} + +const tokenTable = { + headers: ['Symbol', 'Name', 'Address', 'Price', 'MCap', 'Volume24h'], + toRow: (t: any) => [ + t.symbol ?? '-', + t.name ?? '-', + t.contractAddress ?? t.tokenAddress ?? '-', + formatUsd(tokenPriceUsd(t)), + formatUsd(t.marketCap ?? t.mcap ?? t.marketCapUsd), + formatUsd(t.volume24Hr ?? t.volume24h ?? t.volumeUsd1d), + ], +} + +const holderTable = { + headers: ['Holder', 'Type', 'Balance', 'Percent'], + toRow: (h: any) => { + const pctRaw = h.percent ?? h.percentage + let pct: string = '-' + if (pctRaw !== undefined && pctRaw !== null && pctRaw !== '') { + const n = Number(pctRaw) + if (Number.isFinite(n)) { + // API returns percentage values both as fractions (token list endpoint, e.g. 1.95e-6 = 0.000195%) + // and as percent units (holders endpoint, e.g. 38.51 = 38.51%). Use magnitude heuristic. + const asPct = Math.abs(n) <= 1 ? n * 100 : n + pct = `${asPct.toFixed(4)}%` + } + } + return [ + h.holderAddress ?? h.address ?? h.userAddress ?? '-', + h.holderType ?? '-', + h.decimals ? formatAmount(h.amount ?? h.balance, h.decimals) : fmtNum(h.balance ?? h.amount, 2), + pct, + ] + }, +} + +const txTable = { + headers: ['Time', 'Type', 'From → To', 'Volume', 'TxHash'], + toRow: (tx: any) => { + const fromSym = tx.fromTokenSymbol ?? tx.fromSymbol ?? tx.fromTokenAddress ?? '?' + const toSym = tx.toTokenSymbol ?? tx.toSymbol ?? tx.toTokenAddress ?? '?' + return [ + formatTime(tx.txDateTime ?? tx.swapTime ?? tx.timestamp ?? tx.createdAt), + tx.swapTranType ?? tx.txnOrderType ?? tx.type ?? '-', + `${fromSym} → ${toSym}`, + formatUsd(tx.volumeInUsd ?? tx.amountUsd), + tx.txHash ?? tx.transactionHash ?? tx.txnHash ?? '-', + ] + }, +} + +function tokenDetail(t: any): Record | null { + if (!t || typeof t !== 'object') return null + const pairs: Record = {} + const set = (k: string, v: unknown) => { + if (v !== undefined && v !== null && v !== '') pairs[k] = v + } + set('Symbol', t.symbol) + set('Name', t.name) + set('Description', t.description) + const statusBits: string[] = [] + if (t.status) statusBits.push(String(t.status)) + if (t.active === true) statusBits.push('active') + if (t.pumpPercentage !== undefined && t.pumpPercentage !== null) { + statusBits.push(`pump ${Number(t.pumpPercentage).toFixed(2)}%`) + } + if (statusBits.length) set('Status', statusBits.join(' · ')) + set('Contract', t.contractAddress) + set('Owner', t.ownerAddress) + set('Swap Pool', t.swapPoolAddress) + set('Price (USD)', formatUsd(tokenPriceUsd(t))) + set('Price (TRX)', fmtNum(t.priceInTrx, 8)) + if (t.priceChange24Hr !== undefined && t.priceChange24Hr !== null) { + const n = Number(t.priceChange24Hr) + const sign = n > 0 ? '+' : '' + set('24h Change', `${sign}${formatPct(n)}`) + } + set('Market Cap', formatUsd(t.marketCap ?? t.marketCapUsd)) + set('24h Volume', formatUsd(t.volume24Hr ?? t.volume24h ?? t.volumeUsd1d)) + if (t.holders !== undefined && t.holders !== null) set('Holders', t.holders) + if (t.totalSupply !== undefined && t.totalSupply !== null) { + set('Total Supply', Number(t.totalSupply).toLocaleString('en-US')) + } + if (t.trxPriceInUsd !== undefined && t.trxPriceInUsd !== null) { + set('TRX/USD', formatUsd(t.trxPriceInUsd)) + } + set('Created', formatTime(t.tokenCreatedInstant)) + set('Launched', formatTime(t.tokenLaunchedInstant)) + set('Website', t.websiteUrl) + set('Twitter', t.twitterUrl) + set('Telegram', t.telegramUrl) + if (t.listOn && typeof t.listOn === 'object') { + const cex = Object.entries(t.listOn) + .filter(([, url]) => typeof url === 'string' && url !== '') + .map(([name]) => name) + if (cex.length) set('Listed On', cex.join(', ')) + } + return pairs +} + +// Decimal string (e.g. "10.5") → integer string in base units, scaled by 10^decimals. +function decimalToBaseUnits(value: string, decimals: number): string { + const trimmed = value.trim() + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) { + throw new Error(`Invalid decimal amount: "${value}"`) + } + const negative = trimmed.startsWith('-') + const unsigned = negative ? trimmed.slice(1) : trimmed + const [whole, frac = ''] = unsigned.split('.') + if (frac.length > decimals) { + throw new Error( + `Amount "${value}" has more than ${decimals} decimal places (token precision limit).`, + ) + } + const paddedFrac = (frac + '0'.repeat(decimals)).slice(0, decimals) + const scaled = BigInt(whole || '0') * 10n ** BigInt(decimals) + BigInt(paddedFrac || '0') + return (negative ? -scaled : scaled).toString() +} + +const trxToSun = (trx: string) => decimalToBaseUnits(trx, 6) + +// Inverse: base-unit string → human-readable decimal. +function baseUnitsToDecimal(raw: string | bigint, decimals: number): string { + const n = typeof raw === 'bigint' ? raw : BigInt(raw) + const negative = n < 0n + const abs = negative ? -n : n + const base = 10n ** BigInt(decimals) + const whole = (abs / base).toString() + const fracDigits = (abs % base).toString().padStart(decimals, '0').replace(/0+$/, '') + const out = fracDigits ? `${whole}.${fracDigits}` : whole + return negative ? `-${out}` : out +} + +// SunKit's enum lists 0/1/2 but the contract uses extra states (e.g. 3 = launched-on-DEX). +// Map what we know; surface raw values verbatim otherwise. +const SUNPUMP_STATE_NAME: Record = { + 0: 'NOT_EXIST', + 1: 'TRADING', + 2: 'READY_TO_LAUNCH', + 3: 'LAUNCHED', +} + +function sunpumpStateLabel(state: number | undefined | null): string { + if (state === undefined || state === null) return '-' + const name = SUNPUMP_STATE_NAME[state] + return name ? `${name} (${state})` : `UNKNOWN (${state})` +} + +function fmtNum(v: unknown, digits = 6): string { + if (v === undefined || v === null || v === '') return '-' + const n = Number(v) + if (!Number.isFinite(n)) return '-' + if (n === 0) return '0' + if (Math.abs(n) >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: digits }) + return n.toPrecision(4) +} + +const portfolioTable = { + headers: ['Symbol', 'Address', 'Balance', 'Price (TRX)', 'Value (TRX)', 'Percent'], + toRow: (t: any) => [ + t.symbol ?? '-', + t.address ?? t.contractAddress ?? '-', + fmtNum(t.balance, 4), + fmtNum(t.priceInTrx, 8), + fmtNum(t.valueInTrx, 6), + t.percentage !== undefined && t.percentage !== null + ? `${(Number(t.percentage) * 100).toPrecision(4)}%` + : '-', + ], +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + +export function registerSunpumpCommands(program: Command) { + const sp = program + .command('sunpump') + .description('SunPump endpoints (mainnet only: api-v2.sunpump.meme).') + .hook('preAction', () => { + assertMainnet() + }) + + // -------------------------- token group ---------------------------------- + const token = sp.command('token').description('Token info, search, holders, ranking') + + token + .command('list') + .description('List tokens with optional filters and pagination') + .option('--contract
', 'Filter by contract address') + .option('--owner
', 'Filter by owner address') + .option('--symbol ', 'Filter by symbol') + .option('--name ', 'Filter by name') + .option('--description ', 'Filter by description') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (opts) => { + await pumpAction({ + spinnerLabel: 'Fetching tokens...', + errorLabel: 'Failed to fetch tokens', + execute: (c) => + c.listTokens({ + contractAddress: opts.contract, + ownerAddress: opts.owner, + symbol: opts.symbol, + name: opts.name, + description: opts.description, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: tokenTable, + }) + }) + + token + .command('get ') + .description('Get token detail by contract address') + .action(async (contractAddress: string) => { + await pumpAction({ + spinnerLabel: 'Fetching token...', + errorLabel: 'Failed to fetch token', + execute: (c) => c.getToken(contractAddress), + detailFor: tokenDetail, + }) + }) + + token + .command('search ') + .description('Fuzzy search tokens') + .option('--on-sunswap', 'Only tokens listed on SunSwap', false) + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (query: string, opts) => { + await pumpAction({ + spinnerLabel: `Searching tokens for "${query}"...`, + errorLabel: 'Failed to search tokens', + execute: (c) => + c.searchTokens({ + query, + onSunSwap: opts.onSunswap || undefined, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: tokenTable, + }) + }) + + token + .command('search-v2 ') + .description('Fuzzy search tokens (v2 with extra filters)') + .option('--on-sunswap', 'Only tokens listed on SunSwap') + .option('--dlive', 'Filter: DLive showing') + .option('--ai-helper', 'Filter: AI helper') + .option('--twitter-launch', 'Filter: Twitter launch') + .option('--sun-agent-launch', 'Filter: Sun agent launch') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (query: string, opts) => { + await pumpAction({ + spinnerLabel: `Searching tokens (v2) for "${query}"...`, + errorLabel: 'Failed to search tokens', + execute: (c) => + c.searchTokensV2({ + query, + onSunSwap: opts.onSunswap || undefined, + filterDliveShowing: opts.dlive || undefined, + filterAiHelper: opts.aiHelper || undefined, + filterTwitterLaunch: opts.twitterLaunch || undefined, + filterSunAgentLaunch: opts.sunAgentLaunch || undefined, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: tokenTable, + }) + }) + + token + .command('by-owner ') + .description('List tokens created by a wallet') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (ownerAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching tokens by owner...', + errorLabel: 'Failed to fetch tokens', + execute: (c) => + c.tokensByOwner({ + address: ownerAddress, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: tokenTable, + }) + }) + + token + .command('holders ') + .description('List holders of a token') + .option('--include-zero', 'Include zero-balance holders') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (tokenAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching token holders...', + errorLabel: 'Failed to fetch holders', + execute: (c) => + c.tokenHolders({ + address: tokenAddress, + includeZeroBalance: opts.includeZero || undefined, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: holderTable, + }) + }) + + token + .command('holders-v2 ') + .description('List holders of a token (v2)') + .option('--include-zero', 'Include zero-balance holders') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (tokenAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching token holders (v2)...', + errorLabel: 'Failed to fetch holders', + execute: (c) => + c.tokenHoldersV2({ + address: tokenAddress, + includeZeroBalance: opts.includeZero || undefined, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: holderTable, + }) + }) + + token + .command('favors') + .description("List a user's favorite tokens (requires signed message)") + .requiredOption('--user-address
', 'User wallet address') + .requiredOption('--signature ', 'Signature of signed-message') + .requiredOption('--signed-message ', 'Signed message') + .option('--token-address
', 'Filter by token address') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .action(async (opts) => { + await pumpAction({ + spinnerLabel: 'Fetching user favors...', + errorLabel: 'Failed to fetch favors', + execute: (c) => + c.userFavors({ + userAddress: opts.userAddress, + signature: opts.signature, + signedMessage: opts.signedMessage, + tokenAddress: opts.tokenAddress, + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + }), + tableConfig: tokenTable, + }) + }) + + token + .command('ranking') + .description('Token ranking by type') + .requiredOption( + '--type ', + 'Ranking type: MARKET_CAP | VOLUME_24H | PRICE_CHANGE_24H', + ) + .option('--size ', 'Number of entries') + .action(async (opts) => { + await pumpAction({ + spinnerLabel: 'Fetching ranking...', + errorLabel: 'Failed to fetch ranking', + execute: (c) => c.ranking({ rankingType: opts.type, size: toIntOrUndef(opts.size) }), + tableConfig: tokenTable, + }) + }) + + token + .command('king-of-hill') + .description('Get the current king-of-the-hill token') + .action(async () => { + await pumpAction({ + spinnerLabel: 'Fetching king of hill...', + errorLabel: 'Failed to fetch king of hill', + execute: (c) => c.kingOfHill(), + tableConfig: tokenTable, + }) + }) + + token + .command('pump-list') + .description('Get the SunPump token list (raw, no envelope)') + .action(async () => { + await pumpAction({ + spinnerLabel: 'Fetching pump token list...', + errorLabel: 'Failed to fetch pump list', + execute: (c) => c.pumpTokenList(), + parseEnvelope: false, + }) + }) + + // -------------------------- tx group ------------------------------------- + const tx = sp.command('tx').description('Swap transactions') + + const addTxFilterOptions = (cmd: Command) => + cmd + .option('--swap-type ', 'Swap type filter (BUY/SELL)') + .option('--pool
', 'Swap pool address') + .option('--tx-hash ', 'Specific tx hash') + .option('--block ', 'Block number') + .option('--start-time ', 'Start time (epoch seconds)') + .option('--end-time ', 'End time (epoch seconds)') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + + addTxFilterOptions( + tx.command('token ').description('Transactions for a token'), + ).action(async (contractAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching token transactions...', + errorLabel: 'Failed to fetch transactions', + execute: (c) => + c.tokenTransactions(contractAddress, { + swapTranType: opts.swapType, + swapPoolAddress: opts.pool, + txHash: opts.txHash, + blockNum: toIntOrUndef(opts.block), + startTime: toIntOrUndef(opts.startTime), + endTime: toIntOrUndef(opts.endTime), + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: txTable, + }) + }) + + addTxFilterOptions( + tx.command('user ').description('Transactions for a wallet'), + ).action(async (ownerAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching user transactions...', + errorLabel: 'Failed to fetch transactions', + execute: (c) => + c.userTransactions(ownerAddress, { + swapTranType: opts.swapType, + swapPoolAddress: opts.pool, + txHash: opts.txHash, + blockNum: toIntOrUndef(opts.block), + startTime: toIntOrUndef(opts.startTime), + endTime: toIntOrUndef(opts.endTime), + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: txTable, + }) + }) + + // -------------------------- portfolio ------------------------------------ + sp.command('portfolio ') + .description('Get tokens held by a wallet (with TRX value filter)') + .option('--include-zero', 'Include zero-balance tokens') + .option('--min-trx ', 'Minimum TRX value') + .option('--page ', 'Page number') + .option('--size ', 'Page size') + .option('--sort ', 'Sort field') + .action(async (walletAddress: string, opts) => { + await pumpAction({ + spinnerLabel: 'Fetching portfolio...', + errorLabel: 'Failed to fetch portfolio', + execute: (c) => + c.portfolio(walletAddress, { + includeZeroBalance: opts.includeZero || undefined, + trxAmountMin: toIntOrUndef(opts.minTrx), + page: toIntOrUndef(opts.page), + size: toIntOrUndef(opts.size), + sort: opts.sort, + }), + tableConfig: portfolioTable, + }) + }) + + + // -------------------------- trade (buy/sell/quote/state) ----------------- + sp.command('state ') + .description( + 'Show SunPump token state (0 NOT_EXIST, 1 TRADING, 2 READY_TO_LAUNCH, 3 LAUNCHED)', + ) + .action(async (tokenAddress: string) => { + try { + const network = getNetwork() + const result = await withSpinner('Fetching token state...', async () => { + const kit = await getKit() + const state = await kit.getSunPumpTokenState(tokenAddress, network) + const info = await kit.getSunPumpTokenInfo(tokenAddress, network).catch(() => null) + return { state, info } + }) + if (isJsonMode()) { + output({ ...result, stateName: sunpumpStateLabel(result.state) }) + return + } + const pairs: Record = { + Token: result.info?.tokenAddress ?? tokenAddress, + State: sunpumpStateLabel(result.state), + Launched: result.info?.launched === true ? 'yes' : 'no', + } + if (result.info?.price !== undefined) pairs['Price (raw)'] = String(result.info.price) + if (result.info?.trxReserve !== undefined) { + pairs['TRX Reserve'] = `${baseUnitsToDecimal(result.info.trxReserve, 6)} TRX` + } + if (result.info?.tokenReserve !== undefined) { + pairs['Token Reserve'] = baseUnitsToDecimal(result.info.tokenReserve, 18) + } + printKeyValue(pairs) + } catch (err: any) { + outputError('Failed to fetch state', err) + } + }) + + sp.command('quote-buy ') + .description('Preview a SunPump buy without sending a transaction') + .requiredOption('--trx ', 'TRX to spend (decimal, e.g. 10 or 1.5)') + .action(async (tokenAddress: string, opts) => { + let trxSun: string + try { + trxSun = trxToSun(opts.trx) + } catch (err: any) { + outputError('Invalid --trx', err) + return + } + try { + const network = getNetwork() + const quote: any = await withSpinner('Fetching buy quote...', async () => { + const kit = await getKit() + return kit.sunpumpQuoteBuy(tokenAddress, trxSun, network) + }) + if (isJsonMode()) { + output({ ...quote, tokenAddress, trxSun, trxIn: opts.trx }) + return + } + printKeyValue({ + Token: tokenAddress, + 'TRX In': `${opts.trx} TRX`, + 'Tokens Out (expected)': baseUnitsToDecimal(quote.tokenAmount, 18), + Fee: `${baseUnitsToDecimal(quote.fee, 6)} TRX`, + }) + } catch (err: any) { + outputError('Failed to fetch quote', err) + } + }) + + sp.command('quote-sell ') + .description('Preview a SunPump sell without sending a transaction') + .requiredOption('--amount ', 'Token amount to sell (decimal)') + .option('--decimals ', 'Token decimals (default 18)', '18') + .action(async (tokenAddress: string, opts) => { + const decimals = Number(opts.decimals) || 18 + let tokenRaw: string + try { + tokenRaw = decimalToBaseUnits(opts.amount, decimals) + } catch (err: any) { + outputError('Invalid --amount', err) + return + } + try { + const network = getNetwork() + const quote: any = await withSpinner('Fetching sell quote...', async () => { + const kit = await getKit() + return kit.sunpumpQuoteSell(tokenAddress, tokenRaw, network) + }) + if (isJsonMode()) { + output({ ...quote, tokenAddress, tokenRaw, amountIn: opts.amount }) + return + } + printKeyValue({ + Token: tokenAddress, + 'Tokens In': `${opts.amount}`, + 'TRX Out (expected)': `${baseUnitsToDecimal(quote.trxAmount, 6)} TRX`, + Fee: `${baseUnitsToDecimal(quote.fee, 6)} TRX`, + }) + } catch (err: any) { + outputError('Failed to fetch quote', err) + } + }) + + sp.command('buy ') + .description('Buy a SunPump token with TRX (bonding-curve, pre-launch only)') + .requiredOption('--trx ', 'TRX to spend (decimal, e.g. 10 or 1.5)') + .option('--slippage ', 'Slippage tolerance (decimal, e.g. 0.05 = 5%)', '0.05') + .option('--min-out ', 'Minimum tokens out in raw base units (overrides slippage)') + .action(async (tokenAddress: string, opts) => { + let trxSun: string + try { + trxSun = trxToSun(opts.trx) + } catch (err: any) { + outputError('Invalid --trx', err) + return + } + const slippage = parseFloat(opts.slippage) + const network = getNetwork() + + const summary: Record = { + Token: tokenAddress, + 'TRX In': `${opts.trx} TRX (${trxSun} Sun)`, + Slippage: `${(slippage * 100).toFixed(2)}%`, + Network: network, + } + try { + const quote = await withSpinner('Fetching buy quote...', async () => { + const kit = await getKit() + return kit.sunpumpQuoteBuy(tokenAddress, trxSun, network) + }) + summary['Tokens Out (expected)'] = baseUnitsToDecimal((quote as any).tokenAmount, 18) + summary['Fee'] = `${baseUnitsToDecimal((quote as any).fee, 6)} TRX` + } catch { + // Quote is best-effort; the real call below will surface the error. + } + if (opts.minOut) summary['Min Tokens Out (raw)'] = opts.minOut + + await writeAction({ + title: 'SunPump Buy Preview', + summary, + confirmMsg: 'Execute this buy?', + spinnerLabel: 'Submitting buy...', + errorLabel: 'Buy failed', + execute: (kit) => + kit.sunpumpBuy({ + tokenAddress, + trxAmount: trxSun, + minTokenOut: opts.minOut, + slippage, + network, + }), + onSuccess: async (result: any) => { + if (isJsonMode()) return + const chalk = (await import('chalk')).default + console.log() + console.log(chalk.green('Buy executed')) + if (result.expectedTokens) { + console.log(` Expected: ${baseUnitsToDecimal(result.expectedTokens, 18)} tokens`) + } + if (result.minTokenOut) { + console.log(` Min Out: ${baseUnitsToDecimal(result.minTokenOut, 18)} tokens`) + } + if (result.txResult?.txid) { + console.log(` TxID: ${chalk.bold(result.txResult.txid)}`) + } + if (result.tronscanUrl) { + console.log(` Tronscan: ${chalk.underline(result.tronscanUrl)}`) + } + }, + }) + }) + + sp.command('sell ') + .description('Sell a SunPump token for TRX (bonding-curve, pre-launch only)') + .requiredOption('--amount ', 'Token amount to sell (decimal)') + .option('--decimals ', 'Token decimals (default 18)', '18') + .option('--slippage ', 'Slippage tolerance (decimal, e.g. 0.05 = 5%)', '0.05') + .option('--min-out ', 'Minimum TRX out in Sun (overrides slippage)') + .action(async (tokenAddress: string, opts) => { + const decimals = Number(opts.decimals) || 18 + let tokenRaw: string + try { + tokenRaw = decimalToBaseUnits(opts.amount, decimals) + } catch (err: any) { + outputError('Invalid --amount', err) + return + } + const slippage = parseFloat(opts.slippage) + const network = getNetwork() + + const summary: Record = { + Token: tokenAddress, + 'Tokens In': `${opts.amount} (${tokenRaw} raw)`, + Slippage: `${(slippage * 100).toFixed(2)}%`, + Network: network, + } + try { + const quote = await withSpinner('Fetching sell quote...', async () => { + const kit = await getKit() + return kit.sunpumpQuoteSell(tokenAddress, tokenRaw, network) + }) + summary['TRX Out (expected)'] = `${baseUnitsToDecimal((quote as any).trxAmount, 6)} TRX` + summary['Fee'] = `${baseUnitsToDecimal((quote as any).fee, 6)} TRX` + } catch { + // Best-effort. + } + if (opts.minOut) summary['Min TRX Out (Sun)'] = opts.minOut + + await writeAction({ + title: 'SunPump Sell Preview', + summary, + confirmMsg: 'Execute this sell?', + spinnerLabel: 'Submitting sell...', + errorLabel: 'Sell failed', + execute: (kit) => + kit.sunpumpSell({ + tokenAddress, + tokenAmount: tokenRaw, + minTrxOut: opts.minOut, + slippage, + network, + }), + onSuccess: async (result: any) => { + if (isJsonMode()) return + const chalk = (await import('chalk')).default + console.log() + console.log(chalk.green('Sell executed')) + if (result.expectedTrx) { + console.log(` Expected: ${baseUnitsToDecimal(result.expectedTrx, 6)} TRX`) + } + if (result.minTrxOut) { + console.log(` Min Out: ${baseUnitsToDecimal(result.minTrxOut, 6)} TRX`) + } + if (result.txResult?.txid) { + console.log(` TxID: ${chalk.bold(result.txResult.txid)}`) + } + if (result.tronscanUrl) { + console.log(` Tronscan: ${chalk.underline(result.tronscanUrl)}`) + } + }, + }) + }) + +} diff --git a/src/lib/command.ts b/src/lib/command.ts index a6a99e3..aadb985 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -121,9 +121,24 @@ function unwrapApiData(raw: unknown): unknown { function readPagination(src: unknown): ApiPagination | undefined { if (!isPlainObject(src)) return undefined + const direct = readPaginationFlat(src) + if (direct) return direct + // Some SunPump endpoints nest pagination under `pageData` or `metadata`. + if (isPlainObject(src.pageData)) { + const nested = readPaginationFlat(src.pageData) + if (nested) return nested + } + if (isPlainObject(src.metadata)) { + const nested = readPaginationFlat(src.metadata) + if (nested) return nested + } + return undefined +} + +function readPaginationFlat(src: Record): ApiPagination | undefined { const total = (src.total ?? src.totalCount) as number | undefined const pageNo = (src.pageNo ?? src.page) as number | undefined - const pageSize = src.pageSize as number | undefined + const pageSize = (src.pageSize ?? src.size) as number | undefined const offset = (src.offset ?? src.next) as string | undefined if ( total === undefined && diff --git a/src/lib/output.ts b/src/lib/output.ts index a0551a0..626f24b 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -175,6 +175,9 @@ export function extractList(data: any): any[] | null { if (Array.isArray(data.data)) return data.data if (Array.isArray(data.tokens)) return data.tokens if (Array.isArray(data.pools)) return data.pools + if (Array.isArray(data.swaps)) return data.swaps + if (Array.isArray(data.holders)) return data.holders + if (Array.isArray(data.items)) return data.items } return null } diff --git a/src/lib/sunpump.ts b/src/lib/sunpump.ts new file mode 100644 index 0000000..a063c5c --- /dev/null +++ b/src/lib/sunpump.ts @@ -0,0 +1,243 @@ +/** + * SunPump API client — read-only GET endpoints. + * + * Mainnet only: https://api-v2.sunpump.meme/pump-api + * + * Standalone from sun-kit's SunAPI: SunPump is a separate service with its own + * base URL and schema. Uses Node's global fetch (>=18). + * + * Methods mirror the OpenAPI operationIds documented in docs/sunpump-api.md. + */ + +export const SUNPUMP_DEFAULT_BASE_URL = 'https://api-v2.sunpump.meme/pump-api' + +export interface SunPumpClientOptions { + baseUrl?: string + fetchImpl?: typeof fetch +} + +type QueryValue = string | number | boolean | null | undefined +export type Query = Record + +export class SunPumpHttpError extends Error { + readonly code = 'SUNPUMP_HTTP_ERROR' + constructor( + message: string, + readonly status: number, + readonly body?: string, + ) { + super(message) + this.name = 'SunPumpHttpError' + } +} + +function buildQueryString(query?: Query): string { + if (!query) return '' + const usp = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + usp.append(key, String(value)) + } + const s = usp.toString() + return s ? `?${s}` : '' +} + +export class SunPump { + private readonly baseUrl: string + private readonly fetchImpl: typeof fetch + + constructor(opts: SunPumpClientOptions = {}) { + const base = opts.baseUrl ?? process.env.SUNPUMP_API_BASE_URL ?? SUNPUMP_DEFAULT_BASE_URL + this.baseUrl = base.replace(/\/+$/, '') + this.fetchImpl = opts.fetchImpl ?? fetch + } + + async request(path: string, query?: Query): Promise { + const url = `${this.baseUrl}${path}${buildQueryString(query)}` + const res = await this.fetchImpl(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + const text = await res.text() + if (!res.ok) { + const excerpt = text.length > 500 ? text.slice(0, 500) + '…' : text + let apiMsg: string | undefined + try { + const parsed = JSON.parse(text) + if (parsed && typeof parsed === 'object' && typeof parsed.msg === 'string') { + apiMsg = parsed.msg + } + } catch { + // body not JSON; fall through + } + const suffix = apiMsg ? ` — ${apiMsg}` : '' + throw new SunPumpHttpError( + `SunPump request failed: ${res.status} ${res.statusText} (${path})${suffix}`, + res.status, + excerpt, + ) + } + if (!text) return undefined as unknown as T + try { + return JSON.parse(text) as T + } catch { + throw new SunPumpHttpError(`SunPump returned non-JSON body (${path})`, res.status, text) + } + } + + // --------------------------------------------------------------------------- + // Token Info + // --------------------------------------------------------------------------- + + listTokens( + query: { + contractAddress?: string + ownerAddress?: string + symbol?: string + name?: string + description?: string + page?: number + size?: number + sort?: string + } = {}, + ) { + return this.request('/token', query) + } + + getToken(contractAddress: string) { + return this.request(`/token/${encodeURIComponent(contractAddress)}`) + } + + searchTokens(query: { + query?: string + onSunSwap?: boolean + page?: number + size?: number + sort?: string + }) { + return this.request('/token/search', query) + } + + searchTokensV2(query: { + query?: string + onSunSwap?: boolean + filterDliveShowing?: boolean + filterAiHelper?: boolean + filterTwitterLaunch?: boolean + filterSunAgentLaunch?: boolean + page?: number + size?: number + sort?: string + }) { + return this.request('/token/searchV2', query) + } + + tokensByOwner(query: { address: string; page?: number; size?: number; sort?: string }) { + return this.request('/token/search/by_owner', query) + } + + tokenHolders(query: { + address: string + includeZeroBalance?: boolean + page?: number + size?: number + sort?: string + }) { + return this.request('/token/holders', query) + } + + tokenHoldersV2(query: { + address: string + includeZeroBalance?: boolean + page?: number + size?: number + sort?: string + }) { + return this.request('/token/holdersV2', query) + } + + userFavors(query: { + signature: string + signedMessage: string + userAddress: string + tokenAddress?: string + page?: number + size?: number + }) { + return this.request('/token/getUserFavors', query) + } + + ranking(query: { rankingType: string; size?: number }) { + return this.request('/token/getRanking', query) + } + + kingOfHill() { + return this.request('/token/getKingOfHill') + } + + pumpTokenList() { + return this.request('/token/SunPumpTokenList.json') + } + + // --------------------------------------------------------------------------- + // Transactions + // --------------------------------------------------------------------------- + + tokenTransactions( + contractAddress: string, + query: { + swapTranType?: string + swapPoolAddress?: string + txHash?: string + blockNum?: number + startTime?: number + endTime?: number + page?: number + size?: number + sort?: string + } = {}, + ) { + return this.request(`/transactions/token/${encodeURIComponent(contractAddress)}`, query) + } + + userTransactions( + ownerAddress: string, + query: { + swapTranType?: string + swapPoolAddress?: string + txHash?: string + blockNum?: number + startTime?: number + endTime?: number + page?: number + size?: number + sort?: string + } = {}, + ) { + return this.request(`/transactions/holder/${encodeURIComponent(ownerAddress)}`, query) + } + + // --------------------------------------------------------------------------- + // Holder portfolio + // --------------------------------------------------------------------------- + + portfolio( + address: string, + query: { + includeZeroBalance?: boolean + trxAmountMin?: number + page?: number + size?: number + sort?: string + } = {}, + ) { + return this.request(`/holders/${encodeURIComponent(address)}/tokens`, query) + } +} + +let _client: SunPump | null = null + +export function getSunPump(): SunPump { + if (!_client) _client = new SunPump() + return _client +}