
Nansen Cli
- 2 installs
- 127 repo stars
- Updated August 4, 2026
- nansen-ai/nansen-cli
Runs onchain analytics with the Nansen CLI: smart-money tracking, token and wallet profiling, perp markets, and two-step DEX trading across many chains.
About
A terminal-native CLI exposing Nansen's onchain analytics (smart money, token screeners, wallet profiler, perps) plus quote-then-execute DEX trading and wallet management. A developer or agent uses it to research onchain activity and place trades from the terminal.
- Smart-money netflow, token screener, and wallet profiler commands
- x402 pay-per-call or API-key auth; quote-then-execute DEX trades
Nansen Cli by the numbers
- 2 all-time installs (skills.sh)
- Ranked #408 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nansen-ai/nansen-cli --skill nansen-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 127 |
| Last updated | August 4, 2026 |
| Repository | nansen-ai/nansen-cli ↗ |
What it does
Runs onchain analytics with the Nansen CLI: smart-money tracking, token and wallet profiling, perp markets, and two-step DEX trading across many chains.
Files
Nansen CLI
Onchain analytics and DEX trading for AI agents.
Quick Reference
# Search for any token, wallet, or entity
nansen research search "jupiter" --type token
# Token price (OHLCV)
nansen research token ohlcv --token <addr> --chain solana --timeframe 1h --limit 24
# Smart Money — what are the pros buying?
nansen research smart-money netflow --chain solana --limit 10
# Token screener — trending tokens
nansen research token screener --chain solana --timeframe 24h --smart-money --limit 20
# Trade — quote then execute
nansen trade quote --chain solana --from <from_token_address> --to <to_token_address> --amount <base_units>
nansen trade execute --quote <quote-id>
# Create a wallet
nansen wallet create # interactive
NANSEN_WALLET_PASSWORD="pass" nansen wallet create # non-interactive
# Discover all commands, options, and return fields
nansen schemaSetup
npm install -g nansen-cliAuth (pick one)
x402 Pay-Per-Call (no API key needed):
nansen wallet create # Generates EVM + Solana keypair
# Fund the EVM address with USDC on Base (~$0.50 minimum)
export NANSEN_WALLET_PASSWORD="your-password" # Skip interactive prompt
# Done — CLI auto-pays $0.01-$0.05 per callAPI Key:
export NANSEN_API_KEY=your-api-key
# Or: nansen login --api-key YOUR_KEYGet a key at app.nansen.ai/api.
Smart Money
nansen research smart-money netflow --chain solana --limit 10
nansen research smart-money dex-trades --chain solana --labels "Smart Trader" --limit 20
nansen research smart-money holdings --chain solana --limit 10
nansen research smart-money perp-trades --limit 10 # no --chain (Hyperliquid only)
nansen research smart-money dcas --limit 10 # no --chain (Jupiter/Solana only)
nansen research smart-money historical-holdings --chain solana --token-address <addr>Labels: Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader, 180D Smart Trader, Smart HL Perps Trader
Token Analytics
--chain required. Use --token for the token address.
nansen research token screener --chain solana --timeframe 24h --smart-money --limit 20
nansen research token info --token <addr> --chain solana
nansen research token indicators --token <addr> --chain solana
nansen research token ohlcv --token <addr> --chain solana --timeframe 1h --limit 24
nansen research token holders --token <addr> --chain solana --smart-money
nansen research token flows --token <addr> --chain solana --days 7
nansen research token flow-intelligence --token <addr> --chain solana
nansen research token who-bought-sold --token <addr> --chain solana
nansen research token dex-trades --token <addr> --chain solana --limit 20
nansen research token pnl --token <addr> --chain solana --sort total_pnl_usd:desc
nansen research token transfers --token <addr> --chain solana --enrich
nansen research token jup-dca --token <addr> # no --chain
nansen research token perp-trades --symbol ETH --days 7 # no --chain, uses --symbol
nansen research token perp-positions --symbol BTC # no --chain
nansen research token perp-pnl-leaderboard --symbol SOL # no --chainNative tokens (SOL, ETH) are not supported on most token endpoints — use specific token addresses.
Wallet Profiler
--chain and --address required for most commands.
nansen research profiler balance --address <addr> --chain solana
nansen research profiler labels --address <addr> --chain ethereum
nansen research profiler pnl --address <addr> --chain ethereum --days 30
nansen research profiler pnl-summary --address <addr> --chain ethereum
nansen research profiler transactions --address <addr> --chain ethereum --limit 20
nansen research profiler historical-balances --address <addr> --chain solana --days 30
nansen research profiler related-wallets --address <addr> --chain ethereum
nansen research profiler counterparties --address <addr> --chain ethereum
nansen research profiler perp-positions --address <addr> # no --chain
nansen research profiler perp-trades --address <addr> # no --chain
nansen research profiler search --query "Vitalik" # no --chain
nansen research profiler batch --addresses "0xabc,0xdef" --chain ethereum --include labels,balance,pnl
nansen research profiler trace --address <addr> --chain ethereum --depth 2 --width 10 # ⚠️ makes N×width API calls
nansen research profiler compare --addresses "0xabc,0xdef" --chain ethereumSearch
nansen research search "jupiter" --type token
nansen research search "Vitalik" --type entity --limit 5
nansen research search "0xd8dA..." # by addressPerps (Hyperliquid)
nansen research perp screener --sort volume_usd:desc --limit 20
nansen research perp leaderboard --days 7 --limit 20Portfolio
nansen research portfolio defi --wallet <addr>
nansen research points leaderboard --tier green --limit 20Trading
Two-step: quote then execute.
# Get quotes from multiple aggregators (Jupiter, OKX, LiFi)
nansen trade quote --chain solana \
--from <from_token_address> \
--to <to_token_address> \
--amount <base_units>
# Execute the best quote
nansen trade execute --quote <quote-id>
# Trade via WalletConnect (hardware wallets, mobile wallets — EVM only)
nansen trade quote --chain base --from <addr> --to <addr> --amount <base_units> --wallet walletconnect
nansen trade execute --quote <quote-id> --wallet wc⚠️ Always inspect the quote response (price, slippage, expiry) before executing.
Quotes expire — if you wait too long, execute will fail. Get a fresh quote and retry.
Trades are irreversible once executed on-chain.
⚠️ Amounts are in base units (not human-readable):
| Token | Decimals | 1 unit = |
|---|---|---|
| SOL | 9 | 1000000000 lamports |
| ETH | 18 | 1000000000000000000 wei |
| USDC | 6 | 1000000 |
Symbol shortcuts (SOL, ETH) don't work yet — use full addresses.
Common Addresses
Solana: SOL So11111111111111111111111111111111111111112 · USDC EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v · JUP JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN
Base: ETH 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee · USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 · DEGEN 0x4ed4e862860bed51a9570b96d89af5e1b0efefed
Wallet Management
nansen wallet create # Create EVM + Solana keypair
nansen wallet list # List wallets
nansen wallet send --to <addr> --amount 1.5 --chain evm # Send native
nansen wallet send --to <addr> --chain evm --max # Send entire balance
# Send via WalletConnect (EVM only)
nansen wallet send --to <addr> --amount 1.5 --chain base --wallet walletconnectUse --wallet walletconnect (or --wallet wc) to sign trades and transfers via a WalletConnect-connected wallet (hardware wallets, mobile wallets) instead of local keys. EVM chains only.
Common Options
| Option | Description |
|---|---|
--chain | Required for most commands. See Supported Chains |
--token | Token address (aliases: --mint, --token-address) |
--address | Wallet address |
--limit | Results per page (default 10) |
--days | Lookback period in days (default 30) |
--sort | Sort field:direction (e.g. value_usd:desc) |
--smart-money | Filter to smart money wallets only |
--pretty | Formatted JSON output |
--table | ASCII table output |
--stream | NDJSON (one record per line) |
--fields a,b | Return only specific fields |
--cache | Cache responses (300s TTL). Do not use with `trade` commands — stale prices/quotes can cause bad trades |
Schema Introspection
Stuck? Runnansen schemaornansen schema <command>to discover all available commands, options, and return fields.
nansen schema # Full JSON schema — all commands, options, return fieldsSupported Chains
Research: solana, ethereum, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, mantle, ronin, sei, plasma, sonic, monad, hyperevm, iotaevm
Trading & x402: solana, base
Gotchas
- Native tokens (SOL, ETH) don't work on most token endpoints — use wrapped addresses
- Perp commands don't take
--chain(Hyperliquid only) --amountis always in base units, not human-readable- Profiler
tracemakes N×width API calls — can burn credits fast - x402 auth needs USDC on Base, not Solana
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "nansen-ai/nansen-cli" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
nansen research <unknown> and nansen trade <unknown> now exit with code 1 and return {"success":false,...} instead of silently exiting 0.
Add modular skills/ directory with 7 agent-optimised SKILL.md files (nansen-token, nansen-smart-money, nansen-profiler, nansen-trade, nansen-wallet, nansen-perp, nansen-search) following the linear-cli pattern. Each skill has scoped frontmatter, agent routing descriptions, bash examples, and exit codes. Add skills nudge to nansen --help output.
Changesets
Hello and welcome! This folder has been automatically generated by @changesets/cli, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it in the repository
We have a quick list of common questions to get you started engaging with this project in our documentation
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linter (if available)
run: npm run lint --if-present
publish:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: npm run changeset:version
publish: npm run changeset:publish
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Dependencies
node_modules/
# Config with secrets
/config.json
.env
# OS
.DS_Store
# IDE
.vscode/
.idea/
coverage/
# Tests
src/__tests__/
*.test.js
vitest.config.js
coverage/
# Dev configs
.github/
.gitignore
.eslintrc*
.prettierrc*
# Docs (keep README)
CONTRIBUTING.md
CHANGELOG.md
# Local
config.json
.env
.DS_Store
AGENTS.md — Contributor Guide
Guidance for AI coding agents (Claude Code, Codex, Copilot, etc.) working on this repository. If you're an agent using the CLI, see README.md.
Architecture
src/
├── index.js # Entry point (shebang, calls runCLI)
├── cli.js # Command router, arg parsing, schema, help text
├── api.js # NansenAPI client (REST, retry, cache, x402 auto-pay)
├── wallet.js # Wallet CRUD (create/list/show/export/delete/send)
├── trading.js # Quote + execute swaps (OKX router via API)
├── transfer.js # Token/native transfers (EVM + Solana)
├── x402.js # x402 payment orchestration (picks network, signs)
├── x402-evm.js # EVM payment signing (EIP-3009 transferWithAuthorization)
├── x402-svm.js # Solana payment signing (SPL transfer)
├── crypto.js # Key encryption/decryption (AES-256-GCM or plaintext)
└── update-check.js # Version upgrade noticeCommand routing
src/index.js → runCLI() in src/cli.js
Commands are built by three functions, merged in runCLI():
buildCommands()in cli.js — analytics commands (smart-money, profiler, token, etc.)buildWalletCommands()in wallet.js — wallet subcommandsbuildTradingCommands()in trading.js — quote/execute
Commands listed in NO_AUTH_COMMANDS skip API initialization. Everything else instantiates NansenAPI with retry, cache, and x402 config.
Data flow: trade
CLI args → api.js GET /defi/quote → quote response
→ wallet.js decrypt key → trading.js sign tx → api.js POST /defi/execute → broadcastData flow: x402 auto-pay
api.js (any call) → 402 response with payment requirements
→ x402.js rankRequirements() → picks cheapest network (EVM first)
→ x402-evm.js or x402-svm.js → sign USDC payment
→ api.js retries original request with Payment-Signature headerIf EVM payment fails (insufficient funds), the async generator yields a Solana signature as fallback.
Output convention
Core functions return data objects. The CLI layer formats via formatOutput(). Never console.log in core functions — use the log dependency injection for CLI output.
Development
npm install # Install dependencies
npm test # Run tests (vitest)
npm run test:watch # Watch mode
npm run test:coverage # With coverageRunning locally
node src/index.js <command> [options]
# Examples
node src/index.js wallet create my-wallet
node src/index.js smart-money --chain solana --limit 5Testing
- Framework: Vitest
- Test files:
src/__tests__/*.test.js - Current: 577 tests across 13 test files
- All new code must have tests
- Mock all RPC/API calls — never hit real networks in tests
Test structure
import { describe, it, expect, vi, beforeEach } from 'vitest';
global.fetch = vi.fn();
describe('featureName', () => {
beforeEach(() => {
fetch.mockReset();
});
it('should do the thing', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonrpc: '2.0', result: '0x...', id: 1 })
});
// test logic
});
});Required RPC mocks by code path
EVM transfers: eth_getBalance, eth_gasPrice, eth_maxPriorityFeePerGas, eth_getTransactionCount, eth_estimateGas, eth_getCode, eth_sendRawTransaction, eth_getTransactionReceipt
Solana transfers: getBalance, getLatestBlockhash, sendTransaction, getSignatureStatuses
SPL token transfers (additionally): getTokenAccountsByOwner, getAccountInfo
Wallet operations: No RPC mocks needed (file I/O only). Mock fs if testing file paths.
API calls: Mock fetch to return { ok: true, json: () => ({...}) } or { ok: false, status: 402, headers: new Headers({...}) } for x402 paths.
Style Guide
- ESM only (
import/export). No TypeScript, no transpilation. - No interactive prompts in core functions. Use env vars:
NANSEN_WALLET_PASSWORD,NANSEN_API_KEY. - Error handling:
throw new Error('descriptive message')in core. CLI catches and formats. - Actionable error messages — tell the user what to do:
- ❌
"Authentication failed" - ✅
"Not logged in. Run: nansen login" - BigInt for token amounts. Never use floating point. Parse to BigInt with decimals.
- Chain branching: Use
chain === 'solana'checks, not inheritance/polymorphism. - Minimal dependencies. Prefer Node.js built-in APIs (crypto, fs, path, http).
PR Checklist
- [ ]
npm testpasses (all tests) - [ ] New code paths have test coverage
- [ ] No hardcoded secrets, API keys, or private keys
- [ ] No
console.login core functions (uselogdep injection) - [ ] Error messages are actionable (tell user what to do)
- [ ] CLI help text updated if adding/changing commands
- [ ] RPC mocks cover all methods in the code path
- [ ] Wallet flows work both with and without
NANSEN_WALLET_PASSWORD - [ ] Changeset added if changing user-facing behavior (add a
.changeset/<name>.mdfile —npm testwill warn if missing)
Chains & Networks
EVM: Ethereum (chain ID 1), Base (8453). CHAIN_IDS in transfer.js only maps these two — other EVM chains will fail for transfers.
Solana: mainnet-beta. Supports native SOL, standard SPL tokens, and Token-2022 (Token Extensions).
RPC endpoints: Hardcoded in CHAIN_RPCS (transfer.js). Nansen API handles RPC for trading.
Key Constants
| Constant | Value |
|---|---|
| USDC (Base) | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| USDC (Solana) | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
| x402 payment | $0.05 USDC per API call |
| Gas buffer | API provides quote.gas with 1.5x multiplier — use directly |
Endpoint Quirks
These are internal details agents should know when writing or debugging tests:
- `token holders --smart-money` — Returns
UNSUPPORTED_FILTERfor tokens without smart money tracking. Not all tokens have this data. - `token flow-intelligence` — May return all-zero flows for illiquid tokens. Normal, not an error.
- `token screener --search` — Client-side filtering. The CLI fetches up to 500 results, then filters locally.
- `--fields` — Applies to the entire response tree, including the
success/datawrapper. - Profiler beta endpoints use
recordsPerPageinstead ofper_page. The CLI handles this automatically. - `profiler perp-positions` — No pagination support; the API ignores the pagination parameter.
Known Gotchas
1. EIP-7702 delegated accounts on Base have contract code. Always use eth_estimateGas, never hardcode 21000 gas. 2. Solana SPL account ordering: Writable accounts (destATA) must precede readonly (mint) in the transaction message. 3. `getSignatureStatuses` over confirmTransaction — the latter is deprecated and unreliable on public RPCs. 4. `--max` native SOL: Reserve 5000 lamports for fee. On EVM L2s, reserve 3x estimated gas for L1 data posting fees. 5. Token-2022: Use TOKEN_2022_PROGRAM_ID and TransferCheckedInstruction (not plain Transfer). 6. CreateATA path: When recipient doesn't have a token account, the sender creates it. This path in transfer.js has limited test coverage — add tests if modifying. 7. `CHAIN_IDS` is incomplete: Only ethereum and base are mapped. Adding new EVM chain support requires updating this map.
Changelog
1.9.3
Patch Changes
- #118 `0bd4c3c` Thanks @TimNooren! - Show warning when trade quote price impact exceeds 5%, and show pin command to avoid fallback to worse quotes
1.9.2
Patch Changes
- #116 `7a2b729` Thanks @TimNooren! - Fix usage examples for
nansen trade quoteto show correct command name instead of deprecatednansen quote
- #114 `37d8c0b` Thanks @TimNooren! - Show API key URL in non-interactive login error message
- #117 `55ad922` Thanks @TimNooren! - Add --wallet and WalletConnect documentation to
nansen trade helpoutput
1.9.1
Patch Changes
- #110 `82aa780` Thanks @TimNooren! - Fix
nansen changelogalways showing "CHANGELOG.md not found". Added afilesfield topackage.jsonto explicitly bundleCHANGELOG.mdwith the published package. Also excludessrc/__tests__/from the package, reducing package size from ~537 kB to ~269 kB.
1.9.0
Minor Changes
- #98 `2f3f556` Thanks @Codier! - Add symbol shortcuts for common tokens (SOL, ETH, USDC, USDT, etc.) that resolve to canonical addresses per chain. Users can now use
--from SOL --to USDCinstead of raw contract addresses.
- #32 `08a8d21` Thanks @arein! - Add WalletConnect support for trading, transfers, and x402 auto-payment (EVM only)
Patch Changes
- #99 `9144cba` Thanks @Codier! - Show clear error when
--amountcontains a decimal (e.g.0.005) instead of base units (lamports, wei). Detected client-side before hitting the API.
- #100 `19559bf` Thanks @Codier! - Fix
nansen trade helpreturning blank output. Now prints subcommands, usage, and examples. Also fixeserrorOutputReferenceError inbuildCommandsscope (affectedtradeandchangelogcommands).
- #93 `342c91f` Thanks @Codier! - Warn when
--fromis a wrapped native token (WETH/WBNB) or native sentinel, so AI agents can correct the token before execution fails
1.8.0
Minor Changes
- #56 `d10998a` Thanks @askeluv! - Add CHANGELOG.md,
nansen changelogcommand, and post-update "what's new" notice
- Added CHANGELOG.md following Keep a Changelog format with history back to v1.5.0
- Added
nansen changelogcommand with--since <version>filtering - Added one-time upgrade notice on first run after version update (prints to stderr)
- #75 `287937e` Thanks @TimNooren! - Restructure CLI into research/trade/wallet namespaces
- Commands reorganized:
smart-money,profiler,token,portfolionow live undernansen research - New
nansen tradenamespace forquoteandexecute - New
nansen walletnamespace for wallet management - Old top-level commands still work with deprecation warnings
- #61 `9af0192` Thanks @askeluv! - Add ENS name resolution for profiler commands. Use
.ethnames directly in--addressflags — resolved automatically via ensideas API with onchain RPC fallback. Works across all profiler subcommands, batch, and trace operations.
All notable changes to the Nansen CLI will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.7.0] - 2026-02-24
Added
- Trading commands —
quoteandexecutefor DEX swaps (EVM + Solana) - Wallet management —
wallet create,list,show,export,default,delete - Wallet send — transfer tokens on EVM and Solana (
wallet send) - x402 auto-payment — automatic payment via Base USDC or Solana SPL USDC
- Explorer links in transaction output
--dry-runflag forwallet send- x402 low balance warning
- AI Agent Access setup docs and improved onboarding flow
Fixed
- Solana execute crash with OKX quotes
- x402 auto-pay retry path (3 reference errors)
- Gas estimation — use API
quote.gasas floor - Pre-flight simulation moved after approval (industry standard)
- EVM signing edge cases with pure JS ECDSA
- Wallet send crashes on amount parsing and silent success
- Solana confirmation and SPL token transfer account ordering
- Suppress duplicate JSON output from quote/execute
- Suppress approval warning for native ETH swaps
Changed
- Pricing clarity — from $0.01/call, min $0.05 balance
- Consolidated crypto primitives into shared module
[1.6.0] - 2026-02-14
Added
token indicatorsendpointprofiler search— general entity search command--x402-payment-signatureflag for pre-signed payment headersX-Client-TypeandX-Client-Versiontracking headers on all API requests
Fixed
- Error JSON now outputs to stdout (not stderr) for consistent agent parsing
- Config loading — environment variables correctly override file config
[1.5.1] - 2026-02-07
Added
- Allow API requests without API key when using x402 payment flow
[1.5.0] - 2026-01-31
_Baseline version. Changes above are relative to this release._
CLAUDE.md
AI assistant guide for contributing to nansen-cli.
What This Is
A CLI for the Nansen API, designed specifically for AI agents. All output is structured JSON. 30 endpoints across Smart Money, Profiler, Token God Mode, and Portfolio.
Quick Start
npm install
npm test # Run mocked tests (no API key needed)
npm run test:live # Run against live API (needs NANSEN_API_KEY)Project Structure
src/
├── index.js # Thin CLI entry point (imports cli.js)
├── cli.js # CLI logic: parsing, routing, formatting, schema
├── api.js # NansenAPI class, all HTTP calls, validation
└── __tests__/
├── unit.test.js # Core logic tests (validation, parsing, formatting)
├── api.test.js # API method tests with mocked fetch
├── cli.test.js # CLI integration tests (subprocess)
├── cli.internal.test.js # CLI unit tests (direct imports for coverage)
└── coverage.test.js # Endpoint coverage verificationThree files, clear separation:
index.js= Entry point (thin wrapper)cli.js= CLI layer (parsing, routing, output formatting, schema)api.js= API layer (HTTP, validation, config)
Code Conventions
- ES modules (
import/export, notrequire) - Async/await for all API calls
- All output is JSON (for AI agent consumption)
- No external dependencies (just Node.js built-ins + vitest for tests)
Adding a New Endpoint
1. Add API method in `src/api.js`:
async newEndpoint(params = {}) {
const { chain = 'solana', filters = {}, orderBy, pagination } = params;
return this.request('/api/v1/endpoint-path', {
chain,
filters,
order_by: orderBy,
pagination
});
}2. Add CLI handler in `src/index.js`:
// In the appropriate command handler (smart-money, profiler, token, portfolio)
'new-subcommand': () => api.newEndpoint({ chains, filters, orderBy, pagination }),3. Add tests:
api.test.js— Mock the fetch, verify request bodycli.test.js— Test CLI invocationcoverage.test.js— Add toDOCUMENTED_ENDPOINTS
4. Update `README.md` with docs
Testing
npm test # All tests, mocked
npm run test:watch # Watch mode
npm run test:coverage # With coverage report
NANSEN_API_KEY=xxx npm run test:live # Live API testsTest philosophy:
- Unit tests don't need API key (use mocked fetch)
- Live tests are opt-in via
NANSEN_LIVE_TEST=1 - Coverage test ensures all documented endpoints have implementations
Common Patterns
Address Validation
// Validates EVM (0x...) or Solana (Base58) addresses
const validation = validateAddress(address, chain);
if (!validation.valid) throw new Error(validation.error);Date Ranges
// Most endpoints accept days param, converted to date range
const to = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];Retry Behavior
- Enabled by default with 3 attempts
- Retries on: 429, 500, 502, 503, 504, network errors
- Exponential backoff with jitter (1s base, 30s max)
- Respects
retry-afterheaders - Disable with
--no-retryoroptions.retry = false - Success responses include
_meta.retriedAttemptsif retried
Response Format
// Success
{ "success": true, "data": { ... } }
// Error
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }Schema Discovery
nansen schema # Full schema (commands, options, types)
nansen schema smart-money # Schema for specific commandReturns JSON with all commands, subcommands, option types/defaults, return fields, supported chains, and smart money labels. No API key required.
Field Filtering
nansen research smart-money netflow --fields token_symbol,net_flow_usd,chainReduces response size by including only specified fields. Works with nested data structures.
Error Codes
Structured error codes for programmatic handling:
| Code | Description |
|---|---|
UNAUTHORIZED | Invalid or missing API key (401) |
FORBIDDEN | Valid key but insufficient permissions (403) |
CREDITS_EXHAUSTED | Insufficient API credits (403) — do not retry |
RATE_LIMITED | Too many requests (429) |
INVALID_ADDRESS | Address format validation failed |
INVALID_TOKEN | Token address validation failed |
INVALID_CHAIN | Unsupported or invalid chain |
INVALID_PARAMS | Generic parameter validation error |
MISSING_PARAM | Required parameter not provided |
UNSUPPORTED_FILTER | Filter not supported for this token/chain (400) |
NOT_FOUND | Resource not found (404) |
TOKEN_NOT_FOUND | Token doesn't exist |
ADDRESS_NOT_FOUND | Address has no data |
SERVER_ERROR | Nansen API internal error (500+) |
SERVICE_UNAVAILABLE | API temporarily down (503) |
NETWORK_ERROR | Connection failed |
TIMEOUT | Request timed out |
UNKNOWN | Unclassified error |
API Reference
Chains
ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, zksync, mantle, ronin, sei, plasma, sonic, unichain, monad, hyperevm, iotaevm
Smart Money Labels
Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader, 180D Smart Trader, Smart HL Perps Trader
Endpoints by Category
Smart Money (6): netflow, dex-trades, perp-trades, holdings, dcas, historical-holdings
Profiler (11): balance, labels, transactions, pnl, search, historical-balances, related-wallets, counterparties, pnl-summary, perp-positions, perp-trades
Token God Mode (12): screener, holders, flows, dex-trades, pnl, who-bought-sold, flow-intelligence, transfers, jup-dca, perp-trades, perp-positions, perp-pnl-leaderboard
Portfolio (1): defi-holdings
Gotchas
- Perp endpoints work with Hyperliquid (use
--symbol BTCnot--token) - JUP DCA is Solana-only
- Beta endpoints (
/api/beta/...) may have different pagination - EVM vs Solana addresses — validation auto-detects based on chain param
Publishing (npm)
DO NOT manually run `npm version` or `npm publish`. CI handles everything.
1. Push to main — CI runs tests 2. CI creates a "Version Packages" PR — bumps version + updates CHANGELOG 3. Merge the Version PR — CI auto-publishes to npm
If you mess up: accidentally bumped version manually? git revert and add a changeset instead. CI publish failed? Check GitHub Actions logs, likely needs NPM_TOKEN secret refresh.
Changesets
Every PR that changes user-facing behavior must include a changeset file. npm test will warn if one is missing. The changeset description ends up in CHANGELOG.md (auto-generated by CI), so write it as a user-facing changelog entry.
Needs a changeset: new features, bug fixes, breaking changes, changed CLI output, new/modified commands.
Does NOT need a changeset: docs-only, test-only, refactors with no behavior change, CI/tooling.
Add a file to .changeset/ with a descriptive kebab-case name:
---
"nansen-cli": minor
---
Short description of the change (appears in CHANGELOG)Use patch for bug fixes, minor for new features, major for breaking changes.
PR Checklist
- [ ] Tests pass (
npm test) - [ ] New endpoints have tests in all 3 test files
- [ ] README.md updated if adding user-facing features
- [ ] Changeset added for user-facing changes (see above)
- [ ] No new dependencies (keep it lightweight)
Contributing to Nansen CLI
Thanks for your interest in contributing!
Getting Started
git clone https://github.com/nansen-ai/nansen-cli.git
cd nansen-cli
npm installDevelopment
# Run tests (mocked, no API key needed)
npm test
# Run tests in watch mode
npm run test:watch
# Run against live API (requires NANSEN_API_KEY)
npm run test:liveAdding New Endpoints
1. Add the API method in src/api.js 2. Add the CLI handler in src/cli.js 3. Add tests in src/__tests__/api.test.js and src/__tests__/cli.test.js 4. Update src/__tests__/coverage.test.js with the new endpoint 5. Update README.md with documentation
Code Style
- ES modules (
import/export) - Async/await for API calls
- JSDoc comments for public methods
- All output is JSON (for AI agent consumption)
Pull Requests
1. Fork the repo 2. Create a feature branch (git checkout -b feature/new-endpoint) 3. Make your changes 4. Run tests (npm test) 5. Commit with a clear message 6. Push and open a PR
Reporting Issues
Please include:
- Node.js version
- CLI command that failed
- Error message (with
--prettyflag) - Expected vs actual behavior
Questions?
Open an issue or reach out on Discord.
MIT License
Copyright (c) 2026 Nansen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "nansen-cli",
"version": "1.9.3",
"description": "Command-line interface for Nansen API - designed for AI agents",
"main": "src/index.js",
"type": "module",
"bin": {
"nansen": "./src/index.js"
},
"files": [
"src/*.js",
"CHANGELOG.md"
],
"scripts": {
"start": "node src/index.js",
"pretest": "node scripts/check-changeset.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:live": "NANSEN_LIVE_TEST=1 vitest run",
"test:trade": "vitest run --config vitest.e2e.config.js",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "changeset publish"
},
"keywords": [
"nansen",
"crypto",
"blockchain",
"api",
"cli",
"ai-agent",
"smart-money",
"onchain",
"defi",
"solana",
"ethereum"
],
"author": "Nansen <dev@nansen.ai>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/nansen-ai/nansen-cli.git"
},
"bugs": {
"url": "https://github.com/nansen-ai/nansen-cli/issues"
},
"homepage": "https://github.com/nansen-ai/nansen-cli#readme",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.29.4",
"@vitest/coverage-v8": "^4.0.18",
"vitest": "^4.0.18"
}
}
Nansen CLI
  
Built by agents, for agents. We prioritize the best possible AI agent experience.
Command-line interface for the Nansen API with structured JSON output, designed for AI agents and automation.
Installation
# Install globally via npm
npm install -g nansen-cli
# Or run directly with npx
npx nansen-cli help
# Or clone and install locally
git clone https://github.com/nansen-ai/nansen-cli.git
cd nansen-cli
npm install
npm linkAI Agent Skills
Skills provide scoped, agent-optimised documentation per command group — making it easier for AI agents to discover and use the right commands without loading the full reference.
Install Skills
# Install all skills
npx skills add nansen-ai/nansen-cli
# Install a specific skill
npx skills add nansen-ai/nansen-cli --skill nansen-searchPowered by vercel/skills.sh — the open-source CLI for installing agent skills.
This adds 7 skill files to your agent's context:
| Skill | Use when... |
|---|---|
nansen-token | Researching a token, checking holders, screening trending tokens |
nansen-smart-money | Finding what smart money is buying/selling |
nansen-profiler | Analysing a specific wallet address |
nansen-trade | Buying or selling a token via DEX swap |
nansen-wallet | Creating a wallet or sending tokens |
nansen-perp | Checking Hyperliquid perp markets or top traders |
nansen-search | Finding a token address from a name |
Skills live in skills/<name>/SKILL.md in this repo. Each file includes:
allowed-tools: Bash— agents know they only need bashUse whendescription — enables routing without reading the full doc- Bash examples with agent patterns (JSON output, field selection)
- Exit codes for retry logic
Tip: If skills aren't installed, nansen --help will remind you with the install command.Configuration
For AI Agents (Recommended)
Use the AI Agent Setup flow:
1. Your agent will ask you to visit: [app.nansen.ai/auth/agent-setup](https://app.nansen.ai/auth/agent-setup) 2. Sign in with your Nansen account 3. Copy the message shown 4. Paste it back to your agent
Your agent saves the key and handles everything else automatically.
Manual Setup
Option 1: Interactive login
nansen login
# Enter your API key when prompted
# ✓ Saved to ~/.nansen/config.jsonOption 2: Environment variable (best for agents)
export NANSEN_API_KEY=your-api-keyOption 3: Direct config file
mkdir -p ~/.nansen && echo '{"apiKey":"<key>","baseUrl":"https://api.nansen.ai"}' > ~/.nansen/config.json && chmod 600 ~/.nansen/config.jsonGet your API key at app.nansen.ai/api.
Auth Priority
1. NANSEN_API_KEY env var (highest) 2. ~/.nansen/config.json file 3. Interactive prompt
Verify It Works
# Check CLI is installed (no API key needed):
nansen schema | head -1
# Verify API access:
nansen research token screener --chain solana --limit 1Quick Start
# Get trending tokens on Solana
nansen research token screener --chain solana --timeframe 24h --pretty
# Check Smart Money activity
nansen research smart-money netflow --chain solana --pretty
# Profile a wallet
nansen research profiler balance --address 0x28c6c06298d514db089934071355e5743bf21d60 --chain ethereum --pretty
# Search for tokens/entities
nansen research search "Vitalik Buterin" --prettyCommands
All analytics live under nansen research, trading under nansen trade, and wallet management under nansen wallet.
research - Research & Analytics
nansen research <category> <subcommand> [options]Category aliases: sm (smart-money), tgm (token), prof (profiler), port (portfolio)
research smart-money - Smart Money Analytics
Track trading and holding activity of sophisticated market participants.
| Subcommand | Description |
|---|---|
netflow | Net capital flows (inflows vs outflows) |
dex-trades | Real-time DEX trading activity |
perp-trades | Perpetual trading on Hyperliquid |
holdings | Aggregated token balances |
dcas | DCA strategies on Jupiter |
historical-holdings | Historical holdings over time |
Smart Money Labels: Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader, 180D Smart Trader, Smart HL Perps Trader
research profiler - Wallet Profiling
ENS Name Resolution: You can use .eth names anywhere an --address is accepted:
nansen research profiler balance --address vitalik.eth
nansen research profiler labels --address nansen.eth --chain ethereum
nansen research profiler transactions --address vitalik.eth --tableENS names are automatically resolved to 0x addresses via public APIs (with onchain RPC fallback). Works on all EVM chains. The resolved name and address are included as _ens metadata in JSON output.
| Subcommand | Description |
|---|---|
balance | Current token holdings |
labels | Behavioral and entity labels |
transactions | Transaction history |
pnl | PnL and trade performance |
search | Search for entities by name |
historical-balances | Historical balances over time |
related-wallets | Find wallets related to an address |
counterparties | Top counterparties by volume |
pnl-summary | Summarized PnL metrics |
perp-positions | Current perpetual positions |
perp-trades | Perpetual trading history |
research token - Token God Mode
| Subcommand | Description |
|---|---|
screener | Discover and filter tokens |
holders | Token holder analysis |
flows | Token flow metrics |
dex-trades | DEX trading activity |
pnl | PnL leaderboard |
who-bought-sold | Recent buyers and sellers |
flow-intelligence | Detailed flow intelligence by label |
transfers | Token transfer history |
jup-dca | Jupiter DCA orders for token |
ohlcv | OHLCV candle data for a token |
perp-trades | Perp trades by token symbol |
perp-positions | Open perp positions by token symbol |
perp-pnl-leaderboard | Perp PnL leaderboard by token |
research search / research perp / research portfolio / research points
See nansen research help or nansen schema --pretty for full details.
trade - DEX Trading
# Get a swap quote
nansen trade quote --from USDC --to SOL --amount 10 --chain solana
# Execute the swap
nansen trade execute --from USDC --to SOL --amount 10 --chain solanawallet - Local Wallet Management
| Subcommand | Description |
|---|---|
create | Create a new wallet (EVM + Solana keypair) |
list | List all wallets |
show | Show wallet addresses |
export | Export private keys |
default | Set default wallet |
delete | Delete a wallet |
send | Send tokens (native or ERC-20/SPL) |
Wallets are passwordless by default (keys stored like SSH keys). Set NANSEN_WALLET_PASSWORD env var for encryption at rest.
schema - Schema Discovery
No API key required. Machine-readable command reference for agent introspection.
nansen schema --pretty # All commands
nansen schema research --pretty # Research commandsDeprecated Flat Commands
The old flat commands (nansen smart-money, nansen token, nansen profiler, nansen search, nansen perp, nansen portfolio, nansen points, nansen quote, nansen execute) still work but print a deprecation warning to stderr. Use the new research and trade namespaces instead.
Options
| Option | Description |
|---|---|
--pretty | Format JSON output for readability |
--table | Format output as human-readable table |
--fields <list> | Comma-separated fields to include (reduces response size) |
--stream | Output as NDJSON for incremental processing |
--cache / --no-cache | Enable/disable response caching |
--cache-ttl <s> | Cache TTL in seconds (default: 300) |
--chain <chain> | Blockchain to query |
--chains <json> | Multiple chains as JSON array |
--limit <n> | Number of results |
--days <n> | Date range in days (default: 30) |
--sort <field:dir> | Sort results (e.g., --sort value_usd:desc) |
--symbol <sym> | Token symbol for perp endpoints (e.g., BTC, ETH) |
--filters <json> | Filter criteria as JSON |
--labels <label> | Smart Money label filter |
--smart-money | Filter for Smart Money only |
--timeframe <tf> | Time window (5m, 10m, 1h, 6h, 24h, 7d, 30d) |
Supported Chains
ethereum solana base bnb arbitrum polygon optimism avalanche linea scroll zksync mantle ronin sei plasma sonic unichain monad hyperevm iotaevm
Run nansen schema to get the current chain list (source of truth).Agent-Optimized Patterns
Reduce Token Burn with --fields
# ❌ Returns everything (huge JSON, wastes agent context)
nansen research smart-money netflow --chain solana
# ✅ Only what you need
nansen research smart-money netflow --chain solana --fields token_symbol,net_flow_usd,chain --limit 10Use --stream for Large Results
# NDJSON mode — process line by line, don't buffer giant arrays
nansen research token dex-trades --chain solana --limit 100 --streamx402 Micropayments
When the API returns a 402 Payment Required, the CLI automatically handles payment if a funded wallet exists:
1. CLI detects 402 response with payment requirements 2. Signs a USDC payment ($0.05/call) using your wallet 3. Retries the request with the payment signature 4. Falls back from EVM to Solana if first network has insufficient funds
# Fund your wallet, then API calls auto-pay
nansen wallet create
# Send USDC to the displayed address
nansen research smart-money netflow --chain solana # auto-pays if no API keyPagination
Use --limit N to control result count. The CLI always fetches page 1 (there is no --page flag).
Detecting the last page: If results returned < your --limit, you've reached the end.
Output Format
Response envelope
// Success
{ "success": true, "data": <raw_api_response> }
// Error
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": {...} }Response shapes vary by endpoint
The data field structure differs across endpoints:
| Shape | Example endpoints |
|---|---|
data.data (array) | token screener |
data.results (array) | entity search |
data.data.results (array) | most profiler endpoints |
data.netflows | smart-money netflow |
data.trades | smart-money dex-trades |
data.holdings | smart-money holdings |
data.holders | token holders |
--table and --stream handle this automatically. For raw JSON parsing:
nansen research smart-money netflow --chain solana | jq 'keys, .data | keys'Error codes
| Code | Action |
|---|---|
CREDITS_EXHAUSTED | Stop all API calls immediately — do not retry. Check your plan at app.nansen.ai. |
RATE_LIMITED | Auto-retry handles this. |
UNSUPPORTED_FILTER | Remove the filter and retry. |
UNAUTHORIZED | Key is wrong or missing. Re-auth. |
INVALID_ADDRESS | Check address format for the chain. |
Troubleshooting
| Symptom | Fix |
|---|---|
command not found: nansen | npm install -g nansen-cli or npx nansen-cli |
UNAUTHORIZED after login | Check cat ~/.nansen/config.json. Write directly if needed. |
| Login hangs | Skip nansen login, write config directly. |
| Huge JSON response | Use --fields to select only needed columns. |
| Perp endpoints empty | Use --symbol BTC not --token. Perps are Hyperliquid-only. |
UNSUPPORTED_FILTER on token holders | Not all tokens have smart money data. Remove --smart-money. |
CREDITS_EXHAUSTED | Check your plan at app.nansen.ai. |
Development
npm test # Run tests (mocked, no API key needed)
npm run test:coverage # With coverage
npm run test:live # Against live API (needs NANSEN_API_KEY)See AGENTS.md for contributor guidance (architecture, testing patterns, style guide).
API Coverage
| Category | Endpoints | Coverage |
|---|---|---|
| Smart Money | 6 | 100% |
| Profiler | 11 | 100% |
| Token God Mode | 12 | 100% |
| Portfolio | 1 | 100% |
| Search | 1 | 100% |
| Total | 31 | 100% |
License
MIT © Nansen
#!/usr/bin/env node
/**
* Non-blocking check: warns if the current branch has no new changeset file
* compared to main. Runs as a pretest hook so agents and humans see a reminder.
* Always exits 0 — this is a nudge, not a gate.
*/
import { execSync } from "child_process";
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim();
if (branch === "main") process.exit(0);
const newChangesets = execSync(
"git diff main --name-only --diff-filter=A -- .changeset/*.md",
{ encoding: "utf8" }
).trim();
if (!newChangesets) {
console.error(
"\x1b[33m[changeset] No new changeset file found on this branch. " +
"If this PR changes user-facing behavior, add one: npx changeset\x1b[0m"
);
}
} catch {
// Not a git repo, main doesn't exist, etc. — skip silently.
}
/**
* CLI Smoke Tests
*
* Minimal end-to-end tests that verify the CLI binary works.
* Detailed logic tests are in cli.internal.test.js (with coverage).
*
* These tests spawn subprocesses so they're slower and don't contribute
* to coverage metrics, but they verify the real CLI works.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI_PATH = path.join(__dirname, '..', 'index.js');
// Create a mock walletconnect binary that reports "not connected"
const MOCK_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-test-'));
const MOCK_WC_PATH = path.join(MOCK_BIN_DIR, 'walletconnect');
fs.writeFileSync(MOCK_WC_PATH, '#!/bin/sh\necho \'{"connected":false}\'\n');
fs.chmodSync(MOCK_WC_PATH, 0o755);
// Helper to run CLI commands
function runCLI(args, options = {}) {
const env = {
...process.env,
PATH: `${MOCK_BIN_DIR}:${process.env.PATH}`,
NANSEN_API_KEY: 'test-key',
...options.env
};
try {
const result = execSync(`node ${CLI_PATH} ${args}`, {
encoding: 'utf8',
env,
timeout: 10000
});
return { stdout: result, exitCode: 0 };
} catch (error) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
exitCode: error.status
};
}
}
describe('CLI Smoke Tests', () => {
afterAll(() => {
fs.rmSync(MOCK_BIN_DIR, { recursive: true, force: true });
});
// =================== Help & Basic Commands ===================
it('should show help', () => {
const { stdout, exitCode } = runCLI('help');
expect(exitCode).toBe(0);
expect(stdout).toContain('Nansen CLI');
expect(stdout).toContain('research');
expect(stdout).toContain('trade');
expect(stdout).toContain('wallet');
});
it('should show schema', () => {
const { stdout, exitCode } = runCLI('schema');
expect(exitCode).toBe(0);
const schema = JSON.parse(stdout);
expect(schema.version).toBeDefined();
expect(schema.commands).toBeDefined();
});
// =================== JSON Output Format ===================
it('should output valid JSON on error', () => {
const { stdout, stderr, exitCode } = runCLI('smart-money netflow', {
env: { NANSEN_API_KEY: 'invalid-key' }
});
// Should fail with network error but still output valid JSON (error goes to stderr)
// Parse first JSON line only (stderr may contain update notifications)
const output = stdout || stderr;
const firstLine = output.split('\n').find(l => l.startsWith('{'));
const result = JSON.parse(firstLine);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBeDefined();
});
it('should support --pretty flag', () => {
const { stdout, exitCode } = runCLI('schema --pretty');
expect(exitCode).toBe(0);
expect(stdout).toContain('\n'); // Pretty JSON has newlines
expect(() => JSON.parse(stdout)).not.toThrow();
});
// =================== Command Routing ===================
it('should route research smart-money commands', () => {
const { stdout } = runCLI('research smart-money help');
expect(stdout).toContain('netflow');
});
it('should route research profiler commands', () => {
const { stdout } = runCLI('research profiler help');
expect(stdout).toContain('balance');
});
it('should route research token commands', () => {
const { stdout } = runCLI('research token help');
expect(stdout).toContain('screener');
expect(stdout).toContain('ohlcv');
});
it('should still route deprecated smart-money path', () => {
const { stdout } = runCLI('smart-money help');
expect(stdout).toContain('netflow');
});
// =================== Environment Variables ===================
it('should use NANSEN_API_KEY from environment', () => {
const { stdout, stderr } = runCLI('smart-money netflow', {
env: { NANSEN_API_KEY: 'test-env-key' }
});
// Will fail auth but proves env var is being read (error goes to stderr)
// Parse first JSON line only (stderr may contain update notifications)
const output = stdout || stderr;
const firstLine = output.split('\n').find(l => l.startsWith('{'));
const result = JSON.parse(firstLine);
expect(result.success).toBe(false);
expect(['UNAUTHORIZED', 'PAYMENT_REQUIRED', 'UNKNOWN']).toContain(result.code);
});
// =================== Error Handling ===================
it('should handle unknown command gracefully', () => {
const { stdout, exitCode } = runCLI('unknown-command');
expect(exitCode).toBe(1);
const result = JSON.parse(stdout);
expect(result.error).toContain('Unknown command');
});
it('should handle unknown subcommand gracefully', () => {
const { stdout } = runCLI('smart-money unknown-subcommand');
const result = JSON.parse(stdout);
expect(result.data.error).toContain('Unknown subcommand');
});
});
/**
* API Endpoint Coverage Test
*
* Verifies all documented Nansen API endpoints are implemented
*/
import { describe, it, expect } from 'vitest';
import { NansenAPI } from '../api.js';
import { batchProfile, traceCounterparties, compareWallets } from '../cli.js';
// All documented endpoints from Nansen API
const DOCUMENTED_ENDPOINTS = {
smartMoney: [
{ name: 'netflow', method: 'smartMoneyNetflow', endpoint: '/api/v1/smart-money/netflow' },
{ name: 'holdings', method: 'smartMoneyHoldings', endpoint: '/api/v1/smart-money/holdings' },
{ name: 'dex-trades', method: 'smartMoneyDexTrades', endpoint: '/api/v1/smart-money/dex-trades' },
{ name: 'dcas', method: 'smartMoneyDcas', endpoint: '/api/v1/smart-money/dcas' },
{ name: 'perp-trades', method: 'smartMoneyPerpTrades', endpoint: '/api/v1/smart-money/perp-trades' },
{ name: 'historical-holdings', method: 'smartMoneyHistoricalHoldings', endpoint: '/api/v1/smart-money/historical-holdings' },
],
profiler: [
{ name: 'balance', method: 'addressBalance', endpoint: '/api/v1/profiler/address/current-balance' },
{ name: 'labels', method: 'addressLabels', endpoint: '/api/beta/profiler/address/labels' },
{ name: 'transactions', method: 'addressTransactions', endpoint: '/api/v1/profiler/address/transactions' },
{ name: 'pnl', method: 'addressPnl', endpoint: '/api/v1/profiler/address/pnl-and-trade-performance' },
{ name: 'search', method: 'entitySearch', endpoint: '/api/beta/profiler/entity-name-search' },
{ name: 'historical-balances', method: 'addressHistoricalBalances', endpoint: '/api/v1/profiler/address/historical-balances' },
{ name: 'related-wallets', method: 'addressRelatedWallets', endpoint: '/api/v1/profiler/address/related-wallets' },
{ name: 'counterparties', method: 'addressCounterparties', endpoint: '/api/v1/profiler/address/counterparties' },
{ name: 'pnl-summary', method: 'addressPnlSummary', endpoint: '/api/v1/profiler/address/pnl-summary' },
{ name: 'perp-positions', method: 'addressPerpPositions', endpoint: '/api/v1/profiler/perp-positions' },
{ name: 'perp-trades', method: 'addressPerpTrades', endpoint: '/api/v1/profiler/perp-trades' },
],
tokenGodMode: [
{ name: 'indicators', method: 'tokenIndicators', endpoint: '/api/v1/tgm/indicators' },
{ name: 'token-ohlcv', method: 'tokenOhlcv', endpoint: '/api/v1/tgm/token-ohlcv' },
{ name: 'screener', method: 'tokenScreener', endpoint: '/api/v1/token-screener' },
{ name: 'holders', method: 'tokenHolders', endpoint: '/api/v1/tgm/holders' },
{ name: 'flows', method: 'tokenFlows', endpoint: '/api/v1/tgm/flows' },
{ name: 'dex-trades', method: 'tokenDexTrades', endpoint: '/api/v1/tgm/dex-trades' },
{ name: 'pnl-leaderboard', method: 'tokenPnlLeaderboard', endpoint: '/api/v1/tgm/pnl-leaderboard' },
{ name: 'who-bought-sold', method: 'tokenWhoBoughtSold', endpoint: '/api/v1/tgm/who-bought-sold' },
{ name: 'flow-intelligence', method: 'tokenFlowIntelligence', endpoint: '/api/v1/tgm/flow-intelligence' },
{ name: 'transfers', method: 'tokenTransfers', endpoint: '/api/v1/tgm/transfers' },
{ name: 'jup-dca', method: 'tokenJupDca', endpoint: '/api/v1/tgm/jup-dca' },
{ name: 'perp-trades', method: 'tokenPerpTrades', endpoint: '/api/v1/tgm/perp-trades' },
{ name: 'perp-positions', method: 'tokenPerpPositions', endpoint: '/api/v1/tgm/perp-positions' },
{ name: 'perp-pnl-leaderboard', method: 'tokenPerpPnlLeaderboard', endpoint: '/api/v1/tgm/perp-pnl-leaderboard' },
],
composite: [
{ name: 'batch-profile', fn: batchProfile, endpoint: 'composite' },
{ name: 'trace-counterparties', fn: traceCounterparties, endpoint: 'composite' },
{ name: 'compare-wallets', fn: compareWallets, endpoint: 'composite' },
],
portfolio: [
{ name: 'defi-holdings', method: 'portfolioDefiHoldings', endpoint: '/api/v1/portfolio/defi-holdings' },
],
search: [
{ name: 'general-search', method: 'generalSearch', endpoint: '/api/v1/search/general' },
],
};
// Endpoints that are documented but return 404 (confirmed non-existent)
const NOT_IMPLEMENTED = [
// These endpoints return 404 and should NOT be implemented
// 'perpLeaderboard' - Profiler perp-leaderboard returns 404
// 'tokenPerpScreener' - TGM perp-screener returns 404
];
describe('API Endpoint Coverage', () => {
const api = new NansenAPI('test-key');
describe('Smart Money Endpoints', () => {
for (const ep of DOCUMENTED_ENDPOINTS.smartMoney) {
it(`should have ${ep.name} method`, () => {
expect(typeof api[ep.method]).toBe('function');
});
}
});
describe('Profiler Endpoints', () => {
for (const ep of DOCUMENTED_ENDPOINTS.profiler) {
it(`should have ${ep.name} method`, () => {
expect(typeof api[ep.method]).toBe('function');
});
}
});
describe('Token God Mode Endpoints', () => {
for (const ep of DOCUMENTED_ENDPOINTS.tokenGodMode) {
it(`should have ${ep.name} method`, () => {
expect(typeof api[ep.method]).toBe('function');
});
}
});
describe('Composite Methods', () => {
for (const ep of DOCUMENTED_ENDPOINTS.composite) {
it(`should have ${ep.name} as exported function from cli.js`, () => {
expect(typeof ep.fn).toBe('function');
});
}
});
describe('Portfolio Endpoints', () => {
for (const ep of DOCUMENTED_ENDPOINTS.portfolio) {
it(`should have ${ep.name} method`, () => {
expect(typeof api[ep.method]).toBe('function');
});
}
});
describe('Search Endpoints', () => {
for (const ep of DOCUMENTED_ENDPOINTS.search) {
it(`should have ${ep.name} method`, () => {
expect(typeof api[ep.method]).toBe('function');
});
}
});
describe('Coverage Summary', () => {
it('should report implemented endpoints', () => {
const implemented = [
...DOCUMENTED_ENDPOINTS.smartMoney,
...DOCUMENTED_ENDPOINTS.profiler,
...DOCUMENTED_ENDPOINTS.tokenGodMode,
...DOCUMENTED_ENDPOINTS.composite,
...DOCUMENTED_ENDPOINTS.portfolio,
...DOCUMENTED_ENDPOINTS.search,
];
console.log(`\n📊 API Coverage Summary:`);
console.log(` Implemented: ${implemented.length} endpoints`);
console.log(` Not yet implemented: ${NOT_IMPLEMENTED.length} endpoints`);
console.log(` Coverage: ${((implemented.length / (implemented.length + NOT_IMPLEMENTED.length)) * 100).toFixed(1)}%`);
if (NOT_IMPLEMENTED.length > 0) {
console.log(`\n⚠️ Missing endpoints:`);
NOT_IMPLEMENTED.forEach(ep => console.log(` - ${ep}`));
}
expect(implemented.length).toBeGreaterThan(0);
});
});
});
describe('Supported Chains Coverage', () => {
const DOCUMENTED_CHAINS = [
'ethereum', 'solana', 'base', 'bnb', 'arbitrum',
'polygon', 'optimism', 'avalanche', 'linea', 'scroll',
'zksync', 'mantle', 'ronin', 'sei', 'plasma',
'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'
];
it('should document all supported chains', () => {
// Just verify the list is comprehensive
expect(DOCUMENTED_CHAINS).toContain('ethereum');
expect(DOCUMENTED_CHAINS).toContain('solana');
expect(DOCUMENTED_CHAINS).toContain('base');
expect(DOCUMENTED_CHAINS.length).toBeGreaterThanOrEqual(20);
console.log(`\n🔗 Supported Chains: ${DOCUMENTED_CHAINS.length}`);
console.log(` ${DOCUMENTED_CHAINS.join(', ')}`);
});
});
describe('Smart Money Labels Coverage', () => {
const DOCUMENTED_LABELS = [
'Fund',
'Smart Trader',
'30D Smart Trader',
'90D Smart Trader',
'180D Smart Trader',
'Smart HL Perps Trader',
];
it('should document all smart money labels', () => {
expect(DOCUMENTED_LABELS).toContain('Fund');
expect(DOCUMENTED_LABELS).toContain('Smart Trader');
expect(DOCUMENTED_LABELS.length).toBeGreaterThanOrEqual(6);
console.log(`\n🏷️ Smart Money Labels: ${DOCUMENTED_LABELS.length}`);
DOCUMENTED_LABELS.forEach(label => console.log(` - ${label}`));
});
});
/**
* Tests for shared crypto primitives (keccak256, signSecp256k1, rlpEncode).
*/
import { describe, it, expect } from "vitest";
import crypto from "crypto";
import { keccak256, signSecp256k1, rlpEncode, bigIntToMinBuf } from "../crypto.js";
describe("keccak256", () => {
it("hashes empty string to known value", () => {
const hash = keccak256(Buffer.alloc(0));
expect(hash.toString("hex")).toBe(
"c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
);
});
it('hashes "abc" to known value', () => {
const hash = keccak256(Buffer.from("abc"));
expect(hash.toString("hex")).toBe(
"4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"
);
});
it("computes ERC-20 transfer selector correctly", () => {
// keccak256("transfer(address,uint256)") first 4 bytes = 0xa9059cbb
const hash = keccak256(Buffer.from("transfer(address,uint256)"));
expect(hash.subarray(0, 4).toString("hex")).toBe("a9059cbb");
});
});
describe("signSecp256k1", () => {
const privKey = Buffer.from(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"hex"
);
it("produces 32-byte r and s with v of 0 or 1", () => {
const hash = crypto.randomBytes(32);
const sig = signSecp256k1(hash, privKey);
expect(sig.r.length).toBe(32);
expect(sig.s.length).toBe(32);
expect([0, 1]).toContain(sig.v);
});
it("is deterministic (RFC 6979)", () => {
const hash = Buffer.from(
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"hex"
);
const sig1 = signSecp256k1(hash, privKey);
const sig2 = signSecp256k1(hash, privKey);
expect(sig1.r.toString("hex")).toBe(sig2.r.toString("hex"));
expect(sig1.s.toString("hex")).toBe(sig2.s.toString("hex"));
expect(sig1.v).toBe(sig2.v);
});
it("produces correct signature for known key and hash", () => {
// Private key = 1 (well-known: derives to 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf)
const key = Buffer.from(
"0000000000000000000000000000000000000000000000000000000000000001",
"hex"
);
const hash = Buffer.from(
"0000000000000000000000000000000000000000000000000000000000000001",
"hex"
);
const sig = signSecp256k1(hash, key);
expect(sig.r.toString("hex")).toBe(
"6673ffad2147741f04772b6f921f0ba6af0c1e77fc439e65c36dedf4092e8898"
);
expect(sig.s.toString("hex")).toBe(
"4c1a971652e0ada880120ef8025e709fff2080c4a39aae068d12eed009b68c89"
);
expect(sig.v).toBe(1);
});
it("enforces low-S normalization (EIP-2)", () => {
const N =
0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const halfN = N >> 1n;
for (let i = 0; i < 10; i++) {
const hash = crypto.randomBytes(32);
const sig = signSecp256k1(hash, privKey);
const s = BigInt("0x" + sig.s.toString("hex"));
expect(s <= halfN).toBe(true);
}
});
});
describe("rlpEncode", () => {
it("encodes empty buffer as 0x80", () => {
expect(rlpEncode(Buffer.alloc(0))).toEqual(Buffer.from([0x80]));
});
it("encodes single byte < 0x80 as itself", () => {
expect(rlpEncode(Buffer.from([0x7f]))).toEqual(Buffer.from([0x7f]));
});
it("encodes empty list as 0xc0", () => {
expect(rlpEncode([])).toEqual(Buffer.from([0xc0]));
});
it("encodes hex string correctly", () => {
expect(rlpEncode("0x0400")).toEqual(Buffer.from([0x82, 0x04, 0x00]));
});
});
describe("bigIntToMinBuf", () => {
it("returns empty buffer for 0n", () => {
expect(bigIntToMinBuf(0n)).toEqual(Buffer.alloc(0));
});
it("returns minimal encoding for non-zero values", () => {
expect(bigIntToMinBuf(1n)).toEqual(Buffer.from([0x01]));
expect(bigIntToMinBuf(255n)).toEqual(Buffer.from([0xff]));
expect(bigIntToMinBuf(256n)).toEqual(Buffer.from([0x01, 0x00]));
});
});
import { describe, it, expect } from 'vitest';
import { isEnsName, resolveAddress } from '../ens.js';
describe('ENS Resolution', () => {
describe('isEnsName', () => {
it('recognizes valid ENS names', () => {
expect(isEnsName('vitalik.eth')).toBe(true);
expect(isEnsName('nansen.eth')).toBe(true);
expect(isEnsName('my-wallet.eth')).toBe(true);
});
it('rejects non-ENS strings', () => {
expect(isEnsName('0x1234567890abcdef1234567890abcdef12345678')).toBe(false);
expect(isEnsName('not-ens')).toBe(false);
expect(isEnsName('')).toBe(false);
expect(isEnsName(null)).toBe(false);
expect(isEnsName('sub.domain.eth')).toBe(false); // subdomains not matched by simple pattern
});
});
describe('resolveAddress', () => {
it('passes through raw addresses unchanged', async () => {
const result = await resolveAddress('0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE');
expect(result.address).toBe('0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE');
expect(result.ensName).toBeUndefined();
});
it('passes through null/undefined', async () => {
const result = await resolveAddress(null);
expect(result.address).toBeNull();
});
it('rejects ENS on non-EVM chains', async () => {
await expect(resolveAddress('nansen.eth', 'solana')).rejects.toThrow('EVM chains');
});
it('fails with descriptive error for unresolvable names', async () => {
await expect(resolveAddress('zzznonexistent999999.eth')).rejects.toThrow('Could not resolve ENS name');
}, 15000);
});
// Live resolution tests (require network)
describe.skipIf(!process.env.NANSEN_LIVE_TEST)('live resolution', () => {
it('resolves nansen.eth', async () => {
const result = await resolveAddress('nansen.eth');
expect(result.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(result.ensName).toBe('nansen.eth');
}, 10000);
it('resolves vitalik.eth', async () => {
const result = await resolveAddress('vitalik.eth');
expect(result.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(result.ensName).toBe('vitalik.eth');
}, 10000);
});
});
/**
* Index.js entry point tests
* Simple tests to ensure the entry point module works correctly
*/
import { describe, it, expect } from 'vitest';
describe('index.js', () => {
it('should export runCLI from cli.js', async () => {
// Dynamic import to test the module loads correctly
const indexModule = await import('../index.js');
// The index.js file imports and calls runCLI, but doesn't re-export it
// We just verify the module loads without error
expect(indexModule).toBeDefined();
});
it('should have correct shebang for CLI execution', async () => {
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.join(__dirname, '..', 'index.js');
const content = fs.readFileSync(indexPath, 'utf-8');
expect(content.startsWith('#!/usr/bin/env node')).toBe(true);
});
});
/**
* End-to-end swap tests — runs the actual CLI against mainnet.
*
* Prerequisites:
* - A wallet in ~/.nansen/wallets/ with ETH on Base and SOL on Solana
* - NANSEN_WALLET_PASSWORD env var set
*
* Run: npm run test:trade
*
* These tests execute REAL swaps with REAL funds. They are excluded
* from the default test suite and must be run explicitly.
*
* Each round-trip swaps native → USDC → native so the only prerequisite
* is having the gas token. Net cost is just gas + slippage.
*/
import { spawnSync } from 'child_process';
import { describe, it, expect } from 'vitest';
import path from 'path';
const CLI_PATH = path.resolve('src/index.js');
/**
* Run the CLI and return { stdout, stderr, exitCode }.
* Captures both stdout and stderr regardless of exit code.
*/
function runCli(...args) {
const { stdout, stderr, status } = spawnSync('node', [CLI_PATH, ...args], {
env: process.env,
encoding: 'utf8',
timeout: 120_000,
});
return { stdout: stdout || '', stderr: stderr || '', exitCode: status ?? 1 };
}
const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const BASE_ETH = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
const SWAP_AMOUNT_ETH = '270000000000000'; // ~$0.50 ETH (18 decimals)
describe.sequential('e2e: ETH ↔ USDC swap round-trip on Base', () => {
const state = {
forwardQuoteId: null,
forwardTxHash: null,
reverseQuoteId: null,
reverseTxHash: null,
receivedUsdcAmount: null,
};
it('should have NANSEN_WALLET_PASSWORD set', () => {
expect(
process.env.NANSEN_WALLET_PASSWORD,
'Set NANSEN_WALLET_PASSWORD to run e2e tests'
).toBeDefined();
});
it('should have a wallet configured', () => {
const result = runCli('wallet', 'list');
const output = result.stdout + result.stderr;
expect(output).toContain('EVM:');
});
it('quote ETH → USDC on Base', () => {
const result = runCli(
'trade', 'quote',
'--chain', 'base',
'--from', BASE_ETH,
'--to', BASE_USDC,
'--amount', SWAP_AMOUNT_ETH,
);
const output = result.stdout + result.stderr;
const quoteMatch = output.match(/Quote ID:\s+(\S+)/);
expect(quoteMatch, `Expected Quote ID in output:\n${output}`).toBeTruthy();
state.forwardQuoteId = quoteMatch[1];
});
it('execute ETH → USDC swap', () => {
expect(state.forwardQuoteId).toBeTruthy();
const result = runCli(
'trade', 'execute',
'--quote', state.forwardQuoteId,
);
const output = result.stdout + result.stderr;
expect(output).toContain('Transaction successful');
const txMatch = output.match(/Tx Hash:\s+(0x[a-fA-F0-9]+)/);
expect(txMatch, `Expected Tx Hash in output:\n${output}`).toBeTruthy();
state.forwardTxHash = txMatch[1];
console.log(`Forward swap: https://basescan.org/tx/${state.forwardTxHash}`);
// Extract received USDC amount for the reverse swap (format: "Output: 500000 → 0x8335...")
const swapMatch = output.match(/Output:\s+(\d+)\s+→/);
expect(swapMatch, `Expected Output amount in output:\n${output}`).toBeTruthy();
state.receivedUsdcAmount = swapMatch[1];
});
it('quote USDC → ETH on Base (reverse)', () => {
expect(state.receivedUsdcAmount, 'Forward swap must capture USDC amount').toBeTruthy();
const result = runCli(
'trade', 'quote',
'--chain', 'base',
'--from', BASE_USDC,
'--to', BASE_ETH,
'--amount', state.receivedUsdcAmount,
);
const output = result.stdout + result.stderr;
const quoteMatch = output.match(/Quote ID:\s+(\S+)/);
expect(quoteMatch, `Expected Quote ID in output:\n${output}`).toBeTruthy();
state.reverseQuoteId = quoteMatch[1];
});
it('execute USDC → ETH swap (reverse)', () => {
expect(state.reverseQuoteId).toBeTruthy();
const result = runCli(
'trade', 'execute',
'--quote', state.reverseQuoteId,
);
const output = result.stdout + result.stderr;
expect(output).toContain('Transaction successful');
const txMatch = output.match(/Tx Hash:\s+(0x[a-fA-F0-9]+)/);
expect(txMatch, `Expected Tx Hash in output:\n${output}`).toBeTruthy();
state.reverseTxHash = txMatch[1];
console.log(`Reverse swap: https://basescan.org/tx/${state.reverseTxHash}`);
});
});
const SOL_NATIVE = 'So11111111111111111111111111111111111111112';
const SOL_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const SWAP_AMOUNT_SOL = '2000000'; // 0.002 SOL (~$0.17, 9 decimals)
describe.sequential('e2e: SOL ↔ USDC swap round-trip on Solana', () => {
const state = {
forwardQuoteId: null,
forwardSignature: null,
reverseQuoteId: null,
reverseSignature: null,
receivedUsdcAmount: null,
};
it('should have NANSEN_WALLET_PASSWORD set', () => {
expect(
process.env.NANSEN_WALLET_PASSWORD,
'Set NANSEN_WALLET_PASSWORD to run e2e tests'
).toBeDefined();
});
it('should have a wallet with Solana address', () => {
const result = runCli('wallet', 'list');
const output = result.stdout + result.stderr;
expect(output).toContain('Solana:');
});
it('quote SOL → USDC on Solana', () => {
const result = runCli(
'trade', 'quote',
'--chain', 'solana',
'--from', SOL_NATIVE,
'--to', SOL_USDC,
'--amount', SWAP_AMOUNT_SOL,
);
const output = result.stdout + result.stderr;
const quoteMatch = output.match(/Quote ID:\s+(\S+)/);
expect(quoteMatch, `Expected Quote ID in output:\n${output}`).toBeTruthy();
state.forwardQuoteId = quoteMatch[1];
});
it('execute SOL → USDC swap', () => {
expect(state.forwardQuoteId).toBeTruthy();
const result = runCli(
'trade', 'execute',
'--quote', state.forwardQuoteId,
);
const output = result.stdout + result.stderr;
expect(output).toContain('Transaction successful');
// Solana tx signatures are base58 strings (typically 87-88 chars)
const sigMatch = output.match(/Signature:\s+([1-9A-HJ-NP-Za-km-z]{43,})/);
expect(sigMatch, `Expected Signature in output:\n${output}`).toBeTruthy();
state.forwardSignature = sigMatch[1];
console.log(`Forward swap: https://solscan.io/tx/${state.forwardSignature}`);
// Extract received USDC amount for the reverse swap
const swapMatch = output.match(/Output:\s+(\d+)\s+→/);
expect(swapMatch, `Expected Output amount in output:\n${output}`).toBeTruthy();
state.receivedUsdcAmount = swapMatch[1];
});
it('quote USDC → SOL on Solana (reverse)', () => {
expect(state.receivedUsdcAmount, 'Forward swap must capture USDC amount').toBeTruthy();
const result = runCli(
'trade', 'quote',
'--chain', 'solana',
'--from', SOL_USDC,
'--to', SOL_NATIVE,
'--amount', state.receivedUsdcAmount,
);
const output = result.stdout + result.stderr;
const quoteMatch = output.match(/Quote ID:\s+(\S+)/);
expect(quoteMatch, `Expected Quote ID in output:\n${output}`).toBeTruthy();
state.reverseQuoteId = quoteMatch[1];
});
it('execute USDC → SOL swap (reverse)', () => {
expect(state.reverseQuoteId).toBeTruthy();
const result = runCli(
'trade', 'execute',
'--quote', state.reverseQuoteId,
);
const output = result.stdout + result.stderr;
expect(output).toContain('Transaction successful');
const sigMatch = output.match(/Signature:\s+([1-9A-HJ-NP-Za-km-z]{43,})/);
expect(sigMatch, `Expected Signature in output:\n${output}`).toBeTruthy();
state.reverseSignature = sigMatch[1];
console.log(`Reverse swap: https://solscan.io/tx/${state.reverseSignature}`);
});
});
/**
* Tests for trading module
*
* Covers: chain resolution, quote storage, RLP encoding, compact-u16 parsing,
* Solana signing, EVM signing (address recovery, decimal/hex handling, EIP-155),
* ERC-20 approval building, API error handling, and CLI command validation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import {
resolveChain,
getWalletChainType,
saveQuote,
loadQuote,
cleanupQuotes,
readCompactU16,
toBuffer,
signLegacyTransaction,
signSolanaTransaction,
signEvmTransaction,
buildApprovalTransaction,
stripLeadingZeros,
buildTradingCommands,
getWrappedNativeFromWarning,
validateBaseUnitAmount,
resolveTokenAddress,
formatQuote,
} from '../trading.js';
import { keccak256, rlpEncode } from '../crypto.js';
import { base58Decode } from '../transfer.js';
import {
base58Encode,
generateEvmWallet,
generateSolanaWallet,
createWallet,
listWallets,
} from '../wallet.js';
import * as wcTrading from '../walletconnect-trading.js';
let originalHome;
let tempDir;
beforeEach(() => {
originalHome = process.env.HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-trading-test-'));
process.env.HOME = tempDir;
});
afterEach(() => {
process.env.HOME = originalHome;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ============= Chain Resolution =============
describe('resolveChain', () => {
it('should resolve all supported chains', () => {
const expected = {
solana: { index: '501', type: 'solana', chainId: 501 },
ethereum: { index: '1', type: 'evm', chainId: 1 },
base: { index: '8453', type: 'evm', chainId: 8453 },
bsc: { index: '56', type: 'evm', chainId: 56 },
};
for (const [name, exp] of Object.entries(expected)) {
const chain = resolveChain(name);
expect(chain.index).toBe(exp.index);
expect(chain.type).toBe(exp.type);
expect(chain.chainId).toBe(exp.chainId);
expect(chain.explorer).toMatch(/^https:\/\//);
}
});
it('should be case-insensitive', () => {
expect(resolveChain('SOLANA').index).toBe('501');
expect(resolveChain('Base').index).toBe('8453');
expect(resolveChain('BSC').chainId).toBe(56);
});
it('should throw for unsupported chain', () => {
expect(() => resolveChain('polygon')).toThrow('Unsupported chain');
expect(() => resolveChain('')).toThrow('Unsupported chain');
expect(() => resolveChain(null)).toThrow('Unsupported chain');
expect(() => resolveChain(undefined)).toThrow('Unsupported chain');
});
});
describe('resolveTokenAddress', () => {
it('should resolve common symbols to addresses', () => {
expect(resolveTokenAddress('SOL', 'solana')).toBe('So11111111111111111111111111111111111111112');
expect(resolveTokenAddress('USDC', 'solana')).toBe('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
expect(resolveTokenAddress('ETH', 'base')).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
expect(resolveTokenAddress('USDC', 'base')).toBe('0x833589fcd6edb6e08f4c7c32d4f71b54bda02913');
expect(resolveTokenAddress('BNB', 'bsc')).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
expect(resolveTokenAddress('ETH', 'ethereum')).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
it('should be case-insensitive for symbols', () => {
expect(resolveTokenAddress('sol', 'solana')).toBe('So11111111111111111111111111111111111111112');
expect(resolveTokenAddress('usdc', 'ethereum')).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48');
expect(resolveTokenAddress('Eth', 'base')).toBe('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
it('should pass through raw addresses unchanged', () => {
const addr = '0x1234567890abcdef1234567890abcdef12345678';
expect(resolveTokenAddress(addr, 'ethereum')).toBe(addr);
expect(resolveTokenAddress('So11111111111111111111111111111111111111112', 'solana'))
.toBe('So11111111111111111111111111111111111111112');
});
it('should pass through unknown symbols unchanged', () => {
expect(resolveTokenAddress('SHIB', 'solana')).toBe('SHIB');
});
it('should handle null/undefined gracefully', () => {
expect(resolveTokenAddress(null, 'solana')).toBe(null);
expect(resolveTokenAddress('SOL', null)).toBe('SOL');
expect(resolveTokenAddress(undefined, undefined)).toBe(undefined);
});
});
describe('getWalletChainType', () => {
it('should return solana for solana', () => {
expect(getWalletChainType('solana')).toBe('solana');
});
it('should return evm for all EVM chains', () => {
for (const chain of ['ethereum', 'base', 'bsc']) {
expect(getWalletChainType(chain)).toBe('evm');
}
});
});
// ============= Quote Storage =============
describe('quote storage', () => {
// Mock responses matching actual API shapes
const solanaQuoteResponse = {
success: true,
quotes: [{
aggregator: 'jupiter',
inputMint: 'So11111111111111111111111111111111111111112',
outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
inAmount: '10000000',
outAmount: '781370',
inUsdValue: '0.78',
outUsdValue: '0.78',
transaction: 'AQAAAA==', // base64 transaction (Solana format)
metadata: { requestId: 'test-req-id' },
}],
metadata: { chainIndex: '501', quotesCount: 1, bestQuote: 'jupiter' },
};
const evmQuoteResponse = {
success: true,
quotes: [{
aggregator: 'okx',
inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
outputMint: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
inAmount: '100000000000000',
outAmount: '186872',
inUsdValue: '0.19',
outUsdValue: '0.19',
approvalAddress: '0x57df6092665eb6058de53939612413ff4b09114e',
transaction: { // EVM format: object with fields
to: '0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc',
data: '0xf2c42696',
value: '100000000000000', // decimal string (not hex!)
gas: '558000', // decimal string
gasPrice: '13560000', // decimal string
},
}],
metadata: { chainIndex: '8453', quotesCount: 1, bestQuote: 'okx' },
};
it('should save and load a Solana quote', () => {
const quoteId = saveQuote(solanaQuoteResponse, 'solana');
expect(quoteId).toMatch(/^\d+-[a-f0-9]+$/);
const loaded = loadQuote(quoteId);
expect(loaded.chain).toBe('solana');
expect(loaded.response.quotes[0].aggregator).toBe('jupiter');
expect(loaded.response.quotes[0].transaction).toBe('AQAAAA==');
expect(loaded.response.quotes[0].metadata.requestId).toBe('test-req-id');
});
it('should save and load an EVM quote with transaction object', () => {
const quoteId = saveQuote(evmQuoteResponse, 'base');
const loaded = loadQuote(quoteId);
expect(loaded.chain).toBe('base');
expect(loaded.response.quotes[0].transaction.to).toBe('0x4409921ae43a39a11d90f7b7f96cfd0b8093d9fc');
expect(loaded.response.quotes[0].transaction.value).toBe('100000000000000');
expect(loaded.response.quotes[0].approvalAddress).toBe('0x57df6092665eb6058de53939612413ff4b09114e');
});
it('should throw for non-existent quote', () => {
expect(() => loadQuote('nonexistent-abc')).toThrow('not found');
});
it('should expire old quotes (>1 hour)', () => {
const quoteId = saveQuote(solanaQuoteResponse, 'solana');
const quotesDir = path.join(tempDir, '.nansen', 'quotes');
const filePath = path.join(quotesDir, `${quoteId}.json`);
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
data.timestamp = Date.now() - 3700000; // 1h + 100s
fs.writeFileSync(filePath, JSON.stringify(data));
expect(() => loadQuote(quoteId)).toThrow('expired');
});
it('should cleanup old quotes but keep fresh ones', () => {
const id1 = saveQuote(solanaQuoteResponse, 'solana');
const id2 = saveQuote(evmQuoteResponse, 'base');
// Backdate id1
const quotesDir = path.join(tempDir, '.nansen', 'quotes');
const data = JSON.parse(fs.readFileSync(path.join(quotesDir, `${id1}.json`), 'utf8'));
data.timestamp = Date.now() - 3700000;
fs.writeFileSync(path.join(quotesDir, `${id1}.json`), JSON.stringify(data));
cleanupQuotes();
expect(fs.existsSync(path.join(quotesDir, `${id1}.json`))).toBe(false);
expect(fs.existsSync(path.join(quotesDir, `${id2}.json`))).toBe(true);
});
});
// ============= Compact-u16 (Solana wire format) =============
describe('readCompactU16', () => {
it('should read single-byte values', () => {
expect(readCompactU16(Buffer.from([0x00]), 0)).toEqual({ value: 0, size: 1 });
expect(readCompactU16(Buffer.from([0x01]), 0)).toEqual({ value: 1, size: 1 });
expect(readCompactU16(Buffer.from([0x7f]), 0)).toEqual({ value: 127, size: 1 });
});
it('should read multi-byte values', () => {
expect(readCompactU16(Buffer.from([0x80, 0x01]), 0)).toEqual({ value: 128, size: 2 });
});
it('should read with offset', () => {
expect(readCompactU16(Buffer.from([0xff, 0x05]), 1)).toEqual({ value: 5, size: 1 });
});
});
// ============= RLP Encoding =============
describe('rlpEncode', () => {
it('should encode single byte < 0x80', () => {
expect(rlpEncode(Buffer.from([0x42]))).toEqual(Buffer.from([0x42]));
});
it('should encode empty buffer as 0x80', () => {
expect(rlpEncode(Buffer.alloc(0))).toEqual(Buffer.from([0x80]));
});
it('should encode short string', () => {
expect(rlpEncode(Buffer.from('dog'))).toEqual(Buffer.from([0x83, 0x64, 0x6f, 0x67]));
});
it('should encode empty list', () => {
expect(rlpEncode([])).toEqual(Buffer.from([0xc0]));
});
it('should encode nested list [ [], [[]], [ [], [[]] ] ]', () => {
expect(rlpEncode([[], [[]], [[], [[]]]]))
.toEqual(Buffer.from([0xc7, 0xc0, 0xc1, 0xc0, 0xc3, 0xc0, 0xc1, 0xc0]));
});
it('should encode hex strings correctly', () => {
const result = rlpEncode('0x0400');
expect(result).toEqual(Buffer.from([0x82, 0x04, 0x00]));
});
it('should encode long strings (>55 bytes)', () => {
const str = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit';
const result = rlpEncode(Buffer.from(str));
expect(result[0]).toBe(0xb8);
expect(result[1]).toBe(56);
expect(result.subarray(2).toString()).toBe(str);
});
});
// ============= toBuffer: decimal vs hex string handling =============
describe('toBuffer', () => {
it('should handle hex strings (0x prefix)', () => {
expect(toBuffer('0x5af3107a4000')).toEqual(Buffer.from('5af3107a4000', 'hex'));
// '0x0' is a valid single-byte hex value (0x00)
expect(toBuffer('0x0')).toEqual(Buffer.from([0x00]));
// '0x' is empty hex
expect(toBuffer('0x')).toEqual(Buffer.alloc(0));
});
it('should handle numbers', () => {
expect(toBuffer(0)).toEqual(Buffer.alloc(0));
expect(toBuffer(1)).toEqual(Buffer.from([0x01]));
expect(toBuffer(256)).toEqual(Buffer.from([0x01, 0x00]));
});
it('should handle bigints', () => {
expect(toBuffer(0n)).toEqual(Buffer.alloc(0));
expect(toBuffer(100000000000000n)).toEqual(Buffer.from('5af3107a4000', 'hex'));
});
});
// ============= Solana Transaction Signing =============
describe('signSolanaTransaction', () => {
it('should sign and produce a verifiable Ed25519 signature', () => {
const message = Buffer.from('test-message-to-sign-for-solana');
const txBytes = Buffer.concat([
Buffer.from([0x01]), // 1 signature slot (compact-u16)
Buffer.alloc(64), // empty signature slot
message,
]);
const wallet = generateSolanaWallet();
const signedBase64 = signSolanaTransaction(txBytes.toString('base64'), wallet.privateKey);
const signedBytes = Buffer.from(signedBase64, 'base64');
// Signature slot should be filled
const sigSlot = signedBytes.subarray(1, 65);
expect(sigSlot.every(b => b === 0)).toBe(false);
// Message should be unchanged
expect(signedBytes.subarray(65).toString()).toBe('test-message-to-sign-for-solana');
// Verify the Ed25519 signature
const seed = Buffer.from(wallet.privateKey.slice(0, 64), 'hex');
const privKey = crypto.createPrivateKey({
key: Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'),
seed,
]),
format: 'der',
type: 'pkcs8',
});
expect(crypto.verify(null, message, crypto.createPublicKey(privKey), sigSlot)).toBe(true);
});
it('should handle transactions with multiple signature slots', () => {
const message = Buffer.from('multi-sig-test');
const txBytes = Buffer.concat([
Buffer.from([0x02]), // 2 signature slots
Buffer.alloc(64), // slot 1 (ours)
Buffer.alloc(64), // slot 2 (other signer)
message,
]);
const wallet = generateSolanaWallet();
const signedBase64 = signSolanaTransaction(txBytes.toString('base64'), wallet.privateKey);
const signedBytes = Buffer.from(signedBase64, 'base64');
// First slot should be signed
expect(signedBytes.subarray(1, 65).every(b => b === 0)).toBe(false);
// Second slot should still be empty
expect(signedBytes.subarray(65, 129).every(b => b === 0)).toBe(true);
// Message unchanged
expect(signedBytes.subarray(129).toString()).toBe('multi-sig-test');
});
it('should produce identical result from base58 object (OKX format) after normalization', () => {
// OKX returns transaction as { data: "<base58-encoded tx>", ... }
// while Jupiter returns a plain base64 string. The execute handler
// normalizes by base58-decoding .data to base64 before signing.
const message = Buffer.from('okx-format-test');
const txBytes = Buffer.concat([
Buffer.from([0x01]),
Buffer.alloc(64),
message,
]);
const wallet = generateSolanaWallet();
// Jupiter path: base64 string
const base64Tx = txBytes.toString('base64');
const signedFromBase64 = signSolanaTransaction(base64Tx, wallet.privateKey);
// OKX path: base58 object -> normalize -> base64 string
const base58Tx = base58Encode(txBytes);
const okxTransaction = { data: base58Tx, from: 'addr', gas: '0', to: 'prog', value: '0' };
let normalized = okxTransaction;
if (typeof normalized === 'object' && normalized.data) {
normalized = base58Decode(normalized.data).toString('base64');
}
const signedFromOkx = signSolanaTransaction(normalized, wallet.privateKey);
expect(signedFromOkx).toBe(signedFromBase64);
});
});
// ============= EVM Transaction Signing =============
describe('signLegacyTransaction', () => {
it('should produce valid signed tx hex', () => {
const wallet = generateEvmWallet();
const tx = {
nonce: 0, gasPrice: '0x3B9ACA00', gasLimit: '0x5208',
to: '0x' + 'ab'.repeat(20), value: '0x0', data: '0x', chainId: 8453,
};
const signedHex = signLegacyTransaction(tx, wallet.privateKey);
expect(signedHex).toMatch(/^0x[0-9a-f]+$/);
// Valid RLP list prefix
expect(parseInt(signedHex.slice(2, 4), 16)).toBeGreaterThanOrEqual(0xc0);
});
it('should recover to the correct address (critical: prevents wrong-sender bugs)', () => {
// This test catches the bug where crypto.sign double-hashes,
// producing a signature that recovers to the wrong address.
const wallet = generateEvmWallet();
const expectedAddress = wallet.address.toLowerCase();
const tx = {
nonce: 0, gasPrice: '0x3B9ACA00', gasLimit: '0x5208',
to: '0x' + 'ab'.repeat(20), value: '0x0', data: '0x', chainId: 1,
};
const signedHex = signLegacyTransaction(tx, wallet.privateKey);
// Decode the signed tx to extract v, r, s and recover the address
// We'll re-hash the unsigned portion and use ecRecover
const ecdh = crypto.createECDH('secp256k1');
ecdh.setPrivateKey(Buffer.from(wallet.privateKey, 'hex'));
const pubKey = ecdh.getPublicKey();
// Derive address from public key
const pubKeyHash = keccak256(pubKey.subarray(1));
const derivedAddress = '0x' + pubKeyHash.subarray(12).toString('hex');
expect(derivedAddress.toLowerCase()).toBe(expectedAddress);
});
it('should handle EIP-155 v for different chain IDs', () => {
const wallet = generateEvmWallet();
// EIP-155: v = chainId * 2 + 35 + recoveryBit
// For chainId=8453: v is either 16941 or 16942
for (const chainId of [1, 56, 8453]) {
const tx = {
nonce: 0, gasPrice: '0x1', gasLimit: '0x5208',
to: '0x' + '00'.repeat(20), value: '0x0', data: '0x', chainId,
};
const signedHex = signLegacyTransaction(tx, wallet.privateKey);
expect(signedHex).toMatch(/^0x/);
expect(signedHex.length).toBeGreaterThan(100);
}
});
it('should handle non-zero value and complex calldata', () => {
const wallet = generateEvmWallet();
const tx = {
nonce: 5,
gasPrice: '0x4A817C800',
gasLimit: '0x30000',
to: '0x' + 'cd'.repeat(20),
value: '0xDE0B6B3A7640000', // 1 ETH
data: '0x095ea7b3' + '00'.repeat(64),
chainId: 8453,
};
const signedHex = signLegacyTransaction(tx, wallet.privateKey);
expect(signedHex).toMatch(/^0x[0-9a-f]+$/);
});
it('should produce deterministic signatures (RFC 6979)', () => {
const wallet = generateEvmWallet();
const tx = {
nonce: 0, gasPrice: '0x1', gasLimit: '0x5208',
to: '0x' + 'ab'.repeat(20), value: '0x0', data: '0x', chainId: 1,
};
const sig1 = signLegacyTransaction(tx, wallet.privateKey);
const sig2 = signLegacyTransaction(tx, wallet.privateKey);
expect(sig1).toBe(sig2);
});
});
describe('signEvmTransaction (API response format)', () => {
it('should handle decimal string values from OKX (gasPrice, value, gas)', () => {
// OKX returns decimal strings: "13560000", "100000000000000", "558000"
const wallet = generateEvmWallet();
const txData = {
to: '0x' + 'ab'.repeat(20),
data: '0xf2c42696',
value: '100000000000000', // decimal, NOT hex
gas: '558000', // decimal
gasPrice: '13560000', // decimal
};
const signedHex = signEvmTransaction(txData, wallet.privateKey, 'base', 0);
expect(signedHex).toMatch(/^0x[0-9a-f]+$/);
});
it('should handle hex string values from LiFi (0x-prefixed)', () => {
// LiFi returns hex: "0x5af3107a4000", etc.
const wallet = generateEvmWallet();
const txData = {
to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE',
data: '0x736eac0b',
value: '0x5af3107a4000',
gas: '0x88530',
gasPrice: '0xcf0e53',
};
const signedHex = signEvmTransaction(txData, wallet.privateKey, 'base', 0);
expect(signedHex).toMatch(/^0x[0-9a-f]+$/);
});
it('should reject unsupported chains', () => {
const wallet = generateEvmWallet();
expect(() => signEvmTransaction({}, wallet.privateKey, 'solana', 0))
.toThrow('Unsupported EVM chain');
expect(() => signEvmTransaction({}, wallet.privateKey, 'polygon', 0))
.toThrow('Unsupported EVM chain');
});
it('should produce different signed tx for different nonces', () => {
const wallet = generateEvmWallet();
const txData = {
to: '0x' + 'ab'.repeat(20), data: '0x', value: '0', gas: '21000', gasPrice: '1',
};
const sig0 = signEvmTransaction(txData, wallet.privateKey, 'base', 0);
const sig1 = signEvmTransaction(txData, wallet.privateKey, 'base', 1);
expect(sig0).not.toBe(sig1);
});
});
// ============= ERC-20 Approval Transaction =============
describe('buildApprovalTransaction', () => {
it('should build a valid approval tx', () => {
const wallet = generateEvmWallet();
const signedHex = buildApprovalTransaction(
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
'0x57df6092665eb6058de53939612413ff4b09114e', // spender
wallet.privateKey,
'base',
0,
);
expect(signedHex).toMatch(/^0x[0-9a-f]+$/);
});
it('should reject unsupported chains', () => {
const wallet = generateEvmWallet();
expect(() => buildApprovalTransaction('0xabc', '0xdef', wallet.privateKey, 'polygon', 0))
.toThrow('Unsupported chain');
});
});
// ============= CLI Command Validation =============
describe('buildTradingCommands', () => {
it('should show help when required params missing for quote', async () => {
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('Usage: nansen trade quote'))).toBe(true);
});
it('should show help when quote-id missing for execute', async () => {
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.execute([], null, {}, {});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('Usage: nansen trade execute'))).toBe(true);
});
it('should error when no wallet exists for quote', async () => {
const logs = [];
let exitCalled = false;
// Mock fetch for the API call
const origFetch = global.fetch;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ success: true, quotes: [{ aggregator: 'test' }] }),
});
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {
chain: 'solana', from: 'So111', to: 'EPjFW', amount: '1000',
});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('No wallet') || l.includes('No default wallet'))).toBe(true);
global.fetch = origFetch;
});
it('should reject ERC-20 swap with non-zero tx.value', async () => {
// A compromised API could attach ETH value to an ERC-20 swap to drain funds
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC (ERC-20)
outputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
inAmount: '1000000',
outAmount: '500000000000000',
transaction: { to: '0xabc', data: '0x1234', value: '5000000000000000000', gas: '200000' },
}],
}, 'ethereum');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(true);
delete process.env.NANSEN_WALLET_PASSWORD;
});
it('should reject native ETH swap with missing inAmount but non-zero tx.value', async () => {
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
outputMint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
// no inAmount or inputAmount — malformed quote
outAmount: '3000000000',
transaction: { to: '0xabc', data: '0x1234', value: '5000000000000000000', gas: '200000' },
}],
}, 'ethereum');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(logs.some(l => l.includes('value mismatch'))).toBe(true);
delete process.env.NANSEN_WALLET_PASSWORD;
});
it('should pass validation for ERC-20 swap with value 0', async () => {
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
outputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
inAmount: '1000000',
outAmount: '500000000000000',
transaction: { to: '0xabc', data: '0x1234', value: '0', gas: '200000' },
}],
}, 'ethereum');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
await cmds.execute([], null, {}, { quote: quoteId });
// Should NOT hit the value validation rejection
expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false);
expect(logs.some(l => l.includes('value mismatch'))).toBe(false);
delete process.env.NANSEN_WALLET_PASSWORD;
});
it('should pass validation for native ETH swap with matching value', async () => {
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
outputMint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
inAmount: '1000000000000000000',
outAmount: '3000000000',
transaction: { to: '0xabc', data: '0x1234', value: '1000000000000000000', gas: '200000' },
}],
}, 'ethereum');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
await cmds.execute([], null, {}, { quote: quoteId });
// Should NOT hit the value validation rejection
expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false);
expect(logs.some(l => l.includes('value mismatch'))).toBe(false);
delete process.env.NANSEN_WALLET_PASSWORD;
});
it('should reject native ETH swap with mismatched tx.value', async () => {
createWallet('default', 'testpass');
process.env.NANSEN_WALLET_PASSWORD = 'testpass';
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', // native ETH
outputMint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
inAmount: '1000000000000000000', // 1 ETH
outAmount: '3000000000',
transaction: { to: '0xabc', data: '0x1234', value: '5000000000000000000', gas: '200000' },
}],
}, 'ethereum');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(logs.some(l => l.includes('value mismatch'))).toBe(true);
delete process.env.NANSEN_WALLET_PASSWORD;
});
it('should error when execute loads a quote without transaction data', async () => {
// Save a quote without transaction field
const quoteId = saveQuote({
success: true,
quotes: [{ aggregator: 'test', inAmount: '100' }], // no .transaction
}, 'solana');
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('transaction data'))).toBe(true);
});
});
// ============= WalletConnect Integration =============
describe('WalletConnect quote support', () => {
it('should save signerType in quote when using walletconnect', () => {
const quoteId = saveQuote({
success: true,
quotes: [{ aggregator: 'test', transaction: { to: '0xabc', data: '0x1234' } }],
}, 'base', 'walletconnect');
const loaded = loadQuote(quoteId);
expect(loaded.signerType).toBe('walletconnect');
});
it('should default signerType to local', () => {
const quoteId = saveQuote({
success: true,
quotes: [{ aggregator: 'test', transaction: { to: '0xabc', data: '0x1234' } }],
}, 'base');
const loaded = loadQuote(quoteId);
expect(loaded.signerType).toBe('local');
});
it('should reject Solana + walletconnect for quote', async () => {
vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4');
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {
chain: 'solana',
from: 'So11111111111111111111111111111111111111112',
to: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
amount: '1000000000',
wallet: 'walletconnect',
});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true);
vi.restoreAllMocks();
});
it('should error when no WalletConnect session for quote', async () => {
vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null);
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {
chain: 'base',
from: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '1000000000000000000',
wallet: 'walletconnect',
});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true);
vi.restoreAllMocks();
});
it('should accept "wc" as walletconnect alias for quote', async () => {
vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null);
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {
chain: 'base',
from: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '1000000000000000000',
wallet: 'wc',
});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true);
vi.restoreAllMocks();
});
});
describe('WalletConnect execute support', () => {
it('should skip password prompt for walletconnect signerType', async () => {
vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4');
vi.spyOn(wcTrading, 'sendTransactionViaWalletConnect').mockResolvedValue({ txHash: '0xmocktx' });
// Mock global fetch for waitForReceipt RPC calls
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => ({
json: () => Promise.resolve({ result: { status: '0x1', blockNumber: '0x100' } }),
}));
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
inputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
outputMint: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
inAmount: '1000000000000000000',
outAmount: '3000000000',
transaction: { to: '0xabc', data: '0x1234', value: '1000000000000000000', gas: '200000' },
}],
}, 'base', 'walletconnect');
const logs = [];
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => {},
});
// Should not require NANSEN_WALLET_PASSWORD since it's walletconnect
delete process.env.NANSEN_WALLET_PASSWORD;
await cmds.execute([], null, {}, { quote: quoteId });
// Should have reached "Sending transaction via WalletConnect..." without password
expect(logs.some(l => l.includes('WalletConnect'))).toBe(true);
// Should not have asked for password
expect(logs.every(l => !l.includes('Enter wallet password'))).toBe(true);
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('should error when WC session expired during execute', async () => {
vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null);
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
transaction: { to: '0xabc', data: '0x1234', value: '0', gas: '200000' },
}],
}, 'base', 'walletconnect');
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true);
vi.restoreAllMocks();
});
it('should reject Solana + walletconnect for execute', async () => {
const quoteId = saveQuote({
success: true,
quotes: [{
aggregator: 'test',
transaction: 'AQAAAA==', // base64 Solana tx
}],
}, 'solana', 'walletconnect');
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.execute([], null, {}, { quote: quoteId });
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true);
});
});
// ============= stripLeadingZeros =============
describe('stripLeadingZeros', () => {
it('should strip multiple leading zero bytes', () => {
expect(stripLeadingZeros(Buffer.from([0, 0, 0, 1, 2]))).toEqual(Buffer.from([1, 2]));
});
it('should strip a single leading zero byte', () => {
expect(stripLeadingZeros(Buffer.from([0, 0xff]))).toEqual(Buffer.from([0xff]));
});
it('should return empty buffer for all zeros', () => {
expect(stripLeadingZeros(Buffer.from([0, 0, 0]))).toEqual(Buffer.alloc(0));
});
it('should not strip from non-zero-leading buffer', () => {
expect(stripLeadingZeros(Buffer.from([1, 2, 3]))).toEqual(Buffer.from([1, 2, 3]));
});
it('should handle empty buffer', () => {
expect(stripLeadingZeros(Buffer.alloc(0))).toEqual(Buffer.alloc(0));
});
});
// ============= Wrapped Native Token Warning =============
describe('getWrappedNativeFromWarning', () => {
it('should warn when --from is WETH on Base', () => {
const warning = getWrappedNativeFromWarning('0x4200000000000000000000000000000000000006', 'base');
expect(warning).toContain('WETH');
expect(warning).toContain('wrapped ETH');
expect(warning).toContain('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
it('should warn when --from is WETH on Ethereum', () => {
const warning = getWrappedNativeFromWarning('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 'ethereum');
expect(warning).toContain('WETH');
expect(warning).toContain('wrapped ETH');
expect(warning).toContain('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
it('should warn when --from is WBNB on BSC', () => {
const warning = getWrappedNativeFromWarning('0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', 'bsc');
expect(warning).toContain('WBNB');
expect(warning).toContain('wrapped BNB');
expect(warning).toContain('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
});
it('should warn when --from is native sentinel on Base', () => {
const warning = getWrappedNativeFromWarning('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 'base');
expect(warning).toContain('native ETH');
expect(warning).toContain('WETH');
expect(warning).toContain('0x4200000000000000000000000000000000000006');
});
it('should warn when --from is native sentinel on BSC', () => {
const warning = getWrappedNativeFromWarning('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', 'bsc');
expect(warning).toContain('native BNB');
expect(warning).toContain('WBNB');
expect(warning).toContain('0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c');
});
it('should match addresses case-insensitively', () => {
const warning = getWrappedNativeFromWarning('0x4200000000000000000000000000000000000006', 'Base');
expect(warning).toContain('WETH');
});
it('should return null for non-wrapped, non-native tokens', () => {
expect(getWrappedNativeFromWarning('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 'base')).toBeNull();
});
it('should return null for unsupported chains (e.g. solana)', () => {
expect(getWrappedNativeFromWarning('So11111111111111111111111111111111111111112', 'solana')).toBeNull();
});
it('should return null for null/undefined inputs', () => {
expect(getWrappedNativeFromWarning(null, 'base')).toBeNull();
expect(getWrappedNativeFromWarning(undefined, 'base')).toBeNull();
expect(getWrappedNativeFromWarning('0x4200000000000000000000000000000000000006', null)).toBeNull();
expect(getWrappedNativeFromWarning(null, null)).toBeNull();
});
});
// ============= Base Unit Amount Validation =============
describe('validateBaseUnitAmount', () => {
it('should return error for decimal amounts', () => {
for (const val of ['0.005', '1.5', '0.000001']) {
const result = validateBaseUnitAmount(val);
expect(result).toContain('base units');
expect(result).toContain(val);
}
});
it('should return null for valid integer amounts', () => {
expect(validateBaseUnitAmount('1000000000')).toBeNull();
expect(validateBaseUnitAmount('1000000000000000000')).toBeNull();
});
it('should return null for null/undefined', () => {
expect(validateBaseUnitAmount(null)).toBeNull();
expect(validateBaseUnitAmount(undefined)).toBeNull();
});
it('should return null for zero (used for max sends)', () => {
expect(validateBaseUnitAmount('0')).toBeNull();
});
it('should return null for non-numeric strings (let API handle)', () => {
expect(validateBaseUnitAmount('abc')).toBeNull();
});
});
describe('quote handler rejects decimal amounts before API call', () => {
it('should error on decimal amount and not call fetch', async () => {
const origFetch = global.fetch;
global.fetch = vi.fn();
const logs = [];
let exitCalled = false;
const cmds = buildTradingCommands({
errorOutput: (msg) => logs.push(msg),
exit: () => { exitCalled = true; },
});
await cmds.quote([], null, {}, {
chain: 'solana', from: 'So111', to: 'EPjFW', amount: '0.005',
});
expect(exitCalled).toBe(true);
expect(logs.some(l => l.includes('base units'))).toBe(true);
expect(global.fetch).not.toHaveBeenCalled();
global.fetch = origFetch;
});
});
// ============= API Error Handling =============
describe('API error handling', () => {
it('should surface INVALID_AMOUNT errors from quote API', async () => {
const origFetch = global.fetch;
const errorBody = JSON.stringify({
code: 'INVALID_AMOUNT',
message: 'Amount must be a valid numeric string',
details: { provided: 'abc' },
});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => errorBody,
});
const { getQuote } = await import('../trading.js');
await expect(getQuote({
chainIndex: '501',
fromTokenAddress: 'So111',
toTokenAddress: 'EPjFW',
amount: 'abc',
userWalletAddress: 'test',
})).rejects.toThrow('Amount must be a valid numeric string');
global.fetch = origFetch;
});
it('should surface UPSTREAM_BROADCAST_ERROR from execute API', async () => {
const origFetch = global.fetch;
const errorBody = JSON.stringify({
code: 'UPSTREAM_BROADCAST_ERROR',
message: 'Jupiter Ultra execute failed: transaction simulation failed',
});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 502,
text: async () => errorBody,
});
const { executeTransaction } = await import('../trading.js');
await expect(executeTransaction({
signedTransaction: 'test',
chain: 'solana',
})).rejects.toThrow('simulation failed');
global.fetch = origFetch;
});
it('should surface NO_QUOTES_AVAILABLE errors', async () => {
const origFetch = global.fetch;
const errorBody = JSON.stringify({
code: 'NO_QUOTES_AVAILABLE',
message: 'No quotes available from any aggregator',
details: ['Jupiter: insufficient liquidity', 'OKX: pair not supported'],
});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => errorBody,
});
const { getQuote } = await import('../trading.js');
await expect(getQuote({
chainIndex: '501',
fromTokenAddress: 'x',
toTokenAddress: 'y',
amount: '1',
userWalletAddress: 'z',
})).rejects.toThrow('No quotes available');
global.fetch = origFetch;
});
it('should handle non-JSON error responses gracefully (e.g. Cloudflare)', async () => {
const origFetch = global.fetch;
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: async () => '<!DOCTYPE html><html><body>Cloudflare challenge</body></html>',
});
const { getQuote } = await import('../trading.js');
await expect(getQuote({
chainIndex: '501',
fromTokenAddress: 'x',
toTokenAddress: 'y',
amount: '1',
userWalletAddress: 'z',
})).rejects.toThrow(); // Should throw, not hang
global.fetch = origFetch;
});
});
describe('formatQuote price impact warning', () => {
it('should show warning when priceImpactPct exceeds 5%', () => {
const output = formatQuote({ aggregator: 'jupiter', inputMint: 'So11111111111111111111111111111111111111112', outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', inAmount: '1000', outAmount: '500', priceImpactPct: '22.59' });
expect(output).toContain('⚠ Price impact is 22.59%!');
expect(output).not.toContain('Price Impact: 22.59%');
});
it('should show warning when priceImpactPct is negative and exceeds -5%', () => {
const output = formatQuote({ aggregator: 'okx', inputMint: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', outputMint: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', inAmount: '100000000000000000000000000', outAmount: '31932262114904620119', priceImpactPct: '-10.03' });
expect(output).toContain('⚠ Price impact is 10.03%!');
expect(output).not.toContain('-10.03');
});
it('should show normal line when priceImpactPct is low', () => {
const output = formatQuote({ aggregator: 'jupiter', inputMint: 'So11111111111111111111111111111111111111112', outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', inAmount: '1000', outAmount: '500', priceImpactPct: '0.05' });
expect(output).toContain('Price Impact: 0.05%');
expect(output).not.toContain('WARNING');
});
it('should not show price impact line when priceImpactPct is absent', () => {
const output = formatQuote({ aggregator: 'jupiter', inputMint: 'So11111111111111111111111111111111111111112', outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', inAmount: '1000', outAmount: '500' });
expect(output).not.toContain('Price Impact');
expect(output).not.toContain('WARNING');
});
});
/**
* Tests for x402 EVM payment module
*/
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import { hashTypedData, createEvmPaymentPayload, isEvmNetwork } from '../x402-evm.js';
import { generateEvmWallet } from '../wallet.js';
describe('EIP-712 hashTypedData', () => {
it('should produce a 32-byte hash', () => {
const domain = {
name: 'USD Coin',
version: '2',
chainId: 8453,
verifyingContract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
};
const fields = [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
];
const message = {
from: '0x1234567890abcdef1234567890abcdef12345678',
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
value: 50000n,
validAfter: 0n,
validBefore: BigInt(Math.floor(Date.now() / 1000) + 3600),
nonce: '0x' + crypto.randomBytes(32).toString('hex'),
};
const hash = hashTypedData(domain, 'TransferWithAuthorization', fields, message);
expect(hash).toBeInstanceOf(Buffer);
expect(hash.length).toBe(32);
});
it('should produce different hashes for different messages', () => {
const domain = {
name: 'Test Token',
version: '1',
chainId: 1,
verifyingContract: '0x0000000000000000000000000000000000000001',
};
const fields = [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
];
const msg1 = {
from: '0x1111111111111111111111111111111111111111',
to: '0x2222222222222222222222222222222222222222',
value: 100n,
validAfter: 0n,
validBefore: 9999999999n,
nonce: '0x' + '00'.repeat(32),
};
const msg2 = { ...msg1, value: 200n };
const hash1 = hashTypedData(domain, 'TransferWithAuthorization', fields, msg1);
const hash2 = hashTypedData(domain, 'TransferWithAuthorization', fields, msg2);
expect(hash1.toString('hex')).not.toBe(hash2.toString('hex'));
});
});
describe('createEvmPaymentPayload', () => {
it('should create a valid base64-encoded payload', () => {
const wallet = generateEvmWallet();
const requirements = {
scheme: 'exact',
network: 'eip155:8453',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '50000',
pay_to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
extra: { name: 'USD Coin', version: '2' },
};
const result = createEvmPaymentPayload(
requirements,
wallet.privateKey,
wallet.address,
'https://api.nansen.ai/v1/test',
);
// Should be valid base64
const decoded = JSON.parse(Buffer.from(result, 'base64').toString('utf8'));
expect(decoded.x402Version).toBe(2);
expect(decoded.accepted.scheme).toBe('exact');
expect(decoded.accepted.network).toBe('eip155:8453');
expect(decoded.payload.authorization).toBeDefined();
expect(decoded.payload.authorization.from).toBe(wallet.address);
expect(decoded.payload.signature).toMatch(/^0x/);
expect(decoded.resource.url).toBe('https://api.nansen.ai/v1/test');
});
it('should throw if extra.name is missing', () => {
const wallet = generateEvmWallet();
const requirements = {
network: 'eip155:8453',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '50000',
pay_to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
extra: {},
};
expect(() => createEvmPaymentPayload(
requirements, wallet.privateKey, wallet.address, 'https://test.com',
)).toThrow('name missing');
});
});
describe('isEvmNetwork', () => {
it('should return true for EVM networks', () => {
expect(isEvmNetwork('eip155:8453')).toBe(true);
expect(isEvmNetwork('eip155:1')).toBe(true);
});
it('should return false for non-EVM networks', () => {
expect(isEvmNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')).toBe(false);
expect(isEvmNetwork('')).toBe(false);
expect(isEvmNetwork(null)).toBe(false);
});
});
/**
* Canonical EVM chain name → numeric chain ID mapping.
*
* Single source of truth — import from here instead of defining inline.
*/
export const EVM_CHAIN_IDS = {
ethereum: 1,
base: 8453,
optimism: 10,
arbitrum: 42161,
polygon: 137,
avalanche: 43114,
bnb: 56,
linea: 59144,
scroll: 534352,
zksync: 324,
mantle: 5000,
};
#!/usr/bin/env node
/**
* Nansen CLI - Command-line interface for Nansen API
* Designed for AI agents with structured JSON output
*
* Usage: nansen <command> [options]
*
* All output is JSON for easy parsing by AI agents.
* Use --pretty for human-readable formatting.
*
* Core logic lives in cli.js for testability.
*/
import { runCLI } from './cli.js';
// Main entry point
runCLI(process.argv.slice(2));
/**
* Shared subprocess helper for WalletConnect CLI calls.
*
* Used by walletconnect-x402.js and walletconnect-trading.js.
*/
import { execFile } from 'child_process';
/**
* Execute a walletconnect CLI command and return stdout.
*/
export function wcExec(cmd, args, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
if (err) {
reject(new Error(err.message));
return;
}
resolve(stdout.trim());
});
});
}
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.js'],
exclude: ['src/**/*.e2e.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.js'],
exclude: ['src/**/*.test.js']
},
testTimeout: 30000
}
});
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.e2e.test.js'],
testTimeout: 120000,
},
});