
Onchain
- 28 installs
- Updated April 19, 2026
- skillhq/onchain
Helps with ai & agent building tasks.
About
onchain is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- onchain
- AI & Agent Building
- AI-coding skill
Onchain by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,505 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/skillhq/onchain --skill onchainAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| Last updated | April 19, 2026 |
| Repository | skillhq/onchain ↗ |
What it does
Helps with ai & agent building tasks.
Files
Onchain CLI
CLI for crypto portfolio tracking, market data, and CEX history.
First-Time Setup (Required)
Before using most features, users must configure their API keys:
onchain setupThis interactive wizard helps configure:
- Zerion - Unified EVM + Solana portfolio, balances, and history (recommended — replaces DeBank + Helius for most features)
- Coinbase/Binance - For CEX balances and trade history
- DeBank - For EVM wallet data (fallback if no Zerion key)
- Helius - For Solana wallet data (fallback if no Zerion key)
Without setup: Only onchain price and onchain markets work (using free CoinGecko tier).
Verify setup: Run onchain test to check which providers are configured and working.
Agent note: If a command fails with "not configured" or "API key required", guide the user to run onchain setup first, then onchain test to verify.
Invocation
onchain <command>Commands
Market Data
onchain price <token> # Token price (btc, eth, sol, etc.)
onchain markets # Market overview with trending
onchain search <query> # Search tokens by name or symbol
onchain gas # Current gas prices (Ethereum default)
onchain gas --chain polygon # Gas prices for other EVM chainsWallet Data
onchain balance <address> # Token balances (auto-detects EVM/Solana)
onchain balance <address> --chain polygon # Filter by chain
onchain history <address> # Transaction history
onchain portfolio <address> # Full portfolio with DeFi positionsAddress is required — pass an EVM address (0x...) or a Solana address. There is no default wallet; you must always provide the address explicitly.
Transaction Lookup
onchain tx <hash> # Lookup transaction details (auto-detects chain)
onchain tx <hash> --chain base # Specify chain explicitly
onchain tx <explorer-url> # Paste block explorer URL directlySupports EVM chains (Ethereum, Polygon, Base, Arbitrum, Optimism, BSC, Avalanche, Fantom) and Solana. Accepts raw hashes or explorer URLs (etherscan.io, basescan.org, solscan.io, etc.).
Example Output
Transaction Details
✓ Status: SUCCESS
Hash: 0xd757...5f31
Chain: Base
Block: 41,310,593
Time: Jan 26, 2026, 01:55 PM (4h ago)
Addresses
From: 0xc4e7263dd870a29f1cfe438d1a7db48547b16888
To: 0xab98b760e5ad88521a97c0f87a3f6eef8c42641d
Value & Fee
Value: 0 ETH
Fee: 3.62e-7 ETH
Gas: 96,893 / 249,604 (39%)
Method
ID: 0x6a761202
🔗 https://basescan.org/tx/0xd757...This output contains all available transaction data. The CLI queries Etherscan/Solscan APIs directly - there is no additional data available from other sources.
CEX Data
onchain coinbase balance # Coinbase balances
onchain coinbase history # Coinbase trade history
onchain binance balance # Binance balances
onchain binance history # Binance trade historyPrediction Markets
onchain polymarket tags # List all available tags/categories
onchain polymarket tags --popular # Show popular tags by market count
onchain polymarket trending # Trending markets (respects config filters)
onchain polymarket trending --all # Show all markets (ignore config filters)
onchain polymarket trending --exclude sports,nfl # Exclude specific tags
onchain polymarket trending --include crypto,ai # Only show specific tags
onchain polymarket search <query> # Search markets (respects config filters)
onchain polymarket view <slug> # View market details
onchain polymarket sentiment <topic> # Analyze market sentiment for a topicSentiment analysis: Analyzes prediction markets to determine bullish/bearish expectations:
onchain polymarket sentiment fed # Fed rate expectations
onchain polymarket sentiment bitcoin # Bitcoin market sentiment
onchain polymarket sentiment ai # AI-related predictions
onchain polymarket sentiment trump # Political sentiment
onchain polymarket sentiment fed --json # JSON output for agentsTag filtering: Configure default excludes in ~/.config/onchain/config.json5:
{
"polymarket": {
"excludeTags": ["sports", "nfl", "nba", "mlb"],
"includeTags": [] // empty = all non-excluded
}
}Configuration
onchain setup # Interactive setup wizard
onchain config # View current config
onchain config set <key> <value> # Set config value (e.g., timeoutMs)Wallet Connectivity (WalletConnect)
Connect mobile wallets (MetaMask, Rainbow, etc.) to send transactions:
onchain wallet connect # Show QR code, connect wallet
onchain wallet connect --chain base # Connect on specific EVM chain
onchain wallet status # Check connection status
onchain wallet send <to> <amount> # Send ETH/native token
onchain wallet send <to> <amount> --chain polygon # Send on Polygon
onchain wallet send <to> <amount> --token 0xUSDC --decimals 6 # Send ERC-20
onchain wallet switch base # Switch EVM chain
onchain wallet disconnect # Disconnect walletGlobal Options
--json- Output as JSON (agent-friendly)--plain- Disable colors and emoji--timeout <ms>- Request timeout
Configuration
Config file: ~/.config/onchain/config.json5
Required API Keys
| Feature | API Key | Get Key |
|---|---|---|
| Unified wallets (EVM + Solana) | ZERION_API_KEY | Zerion |
| Coinbase CEX | COINBASE_API_KEY + COINBASE_API_SECRET | Coinbase |
| Binance CEX | BINANCE_API_KEY + BINANCE_API_SECRET | Binance |
Fallback API Keys (if no Zerion key)
| Feature | API Key | Get Key |
|---|---|---|
| EVM wallets | DEBANK_API_KEY | DeBank |
| Solana wallets | HELIUS_API_KEY | Helius |
Optional API Keys
| Feature | API Key | Notes |
|---|---|---|
| Market data | COINGECKO_API_KEY | Free tier works, Pro for higher limits |
| Market fallback | COINMARKETCAP_API_KEY | Alternative market data source |
| EVM tx lookup | ETHERSCAN_API_KEY | For onchain tx on EVM chains |
| Solana tx lookup | SOLSCAN_API_KEY | For onchain tx on Solana |
| Wallet connect | WALLETCONNECT_PROJECT_ID | For onchain wallet commands (Get ID) |
Examples
Get Bitcoin price
onchain price btcCheck wallet balance
onchain balance 0x1234...5678View portfolio with DeFi positions
onchain portfolio 0x1234...5678Get trending prediction markets
onchain polymarket trending -n 5 # Top 5 (respects config filters)
onchain polymarket trending --all # All markets, ignore config
onchain polymarket trending --exclude sports # Filter out sports on-the-flyLookup a transaction
onchain tx 0xd757e7e4cdb424e22319cbf63bbcfcd4b26c93ebef31d1458ab7d5e986375f31
onchain tx https://basescan.org/tx/0x... # Or paste explorer URLSearch for tokens
onchain search pepe # Find tokens matching "pepe"
onchain search "shiba inu" -l 5 # Limit to 5 resultsCheck gas prices
onchain gas # Ethereum gas prices
onchain gas --chain polygon # Polygon gas prices
onchain gas --json # JSON outputJSON output for scripts
onchain --json price eth | jq '.priceUsd'Supported Chains
EVM + Solana (via Zerion — preferred)
Ethereum, BNB Chain, Polygon, Arbitrum, Optimism, Avalanche, Base, zkSync Era, Linea, Scroll, Blast, Mantle, Gnosis, Fantom, Celo, Solana, and more. Single API key covers all chains.
EVM (via DeBank — fallback)
Same EVM chains as above, requires separate DeBank API key.
Solana (via Helius — fallback)
Full Solana mainnet support including SPL tokens and NFTs, requires separate Helius API key.
Agent Integration
This CLI is designed for agent use. Key patterns:
1. Always use `--json` for programmatic access 2. Check exit codes - 0 for success, 1 for error 3. Always pass an address - Wallet commands require an explicit address (no default wallet) 4. Rate limiting - APIs have rate limits, add delays between rapid calls
Example Agent Usage
# Get portfolio value (address is required)
VALUE=$(onchain --json portfolio 0x1234...5678 | jq -r '.totalValueUsd')
# Get price with change
onchain --json price btc | jq '{price: .priceUsd, change24h: .priceChange24h}'
# Check if market is bullish
CHANGE=$(onchain --json markets | jq '.marketCapChange24h')
# Get transaction details as JSON
TX=$(onchain --json tx 0x... --chain base)
echo $TX | jq '{status: .status, from: .from, to: .to, method: .methodId}'Transaction Lookup Guidance
IMPORTANT: Trust the CLI output. The onchain tx command queries Etherscan (EVM) or Solscan (Solana) APIs directly and returns all available data.
DO NOT:
- Use curl to hit Etherscan/Basescan APIs directly
- Use
castor other CLI tools as "fallbacks" - Use WebFetch to scrape block explorer websites
- Assume the CLI is missing data - it returns everything available
DO:
- Use
onchain tx <hash>oronchain tx <explorer-url> - Use
--jsonfor structured data parsing - Interpret the output directly to answer user questions
Example interpretation:
onchain tx 0x... --chain baseIf output shows Status: SUCCESS, From: 0x..., To: 0x..., Method ID: 0x6a761202 - that's a successful contract interaction. The method ID 0x6a761202 is execTransaction (Gnosis Safe). No additional lookups needed.
Commit and Push Work (onchain)
This repo-specific command handles version bumping, tagging, and pushing for the onchain CLI.
Steps to perform:
1. Pre-flight checks
- Run
pnpm run lintto ensure code passes linting - Run
pnpm run testto ensure tests pass - Check
git statusto see what files have changed
2. Determine version bump type
Ask the user which version bump to use:
- patch (0.5.1 → 0.5.2): Bug fixes, minor changes
- minor (0.5.1 → 0.6.0): New features, backwards compatible
- major (0.5.1 → 1.0.0): Breaking changes
3. Bump version in package.json
Use pnpm version <patch|minor|major> --no-git-tag-version to bump the version without creating a tag yet (we'll create it after committing).
4. Stage and commit all changes
- Stage all modified files (be specific, avoid
git add -A) - Create a commit with a clear message describing the changes
- Include
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>in the commit
5. Create git tag
After the commit succeeds, create a git tag:
git tag v$(node -p "require('./package.json').version")6. Push to remote with tags
Push the commit and the tag to origin:
git push origin main && git push origin v$(node -p "require('./package.json').version")Important notes:
- The user has a YubiKey for git signing - inform them to watch for the touch prompt
- Use 60000ms timeout for git commit and push operations
- This triggers the npm release via GitHub Actions
- Never push force or amend commits without explicit user request
# Only publish SKILL.md and README.md for the skill
# Exclude everything else
# Source code
src/
dist/
tests/
scripts/
# Config files
*.json
*.json5
*.yaml
*.yml
*.toml
biome.json
tsconfig*.json
pnpm-lock.yaml
# Docs (except README and SKILL)
CLAUDE.md
CONTRIBUTING.md
LICENSE
# GitHub
.github/
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:dist
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run test
- run: pnpm run build:dist
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Compiled binary
onchain
# Environment and secrets
.env
.env.*
!.env.example
# Config files with credentials
.onchainrc.json5
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Lock files (using pnpm)
package-lock.json
yarn.lock
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
},
"style": {
"noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
},
"files": {
"includes": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"ignoreUnknown": true
}
}
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & Development Commands
pnpm run build # Build TypeScript and compile binary
pnpm run dev # Run CLI directly with tsx (no build needed)
pnpm run lint # Run biome + oxlint
pnpm run lint:fix # Auto-fix lint issues
pnpm run test # Run vitest tests
pnpm run test:watch # Run tests in watch modeTo test the CLI during development:
pnpm run dev price btc # Run any command
./onchain test # After building, test the binaryArchitecture
Mixin-Based Client Composition
The core OnchainClient is composed using TypeScript mixins, each adding methods for a specific API provider:
OnchainClientBase (base class with shared utilities)
↓
withCoinGecko → withCoinMarketCap → withDeBank → withHelius → withCoinbase → withBinance → withPolymarket → withEtherscan → withSolscan → withWalletConnect → withZerion → withNansen
↓
OnchainClient (final composed class)Note: Nansen is a CLI wrapper (not an API client). When the nansen CLI is installed, it becomes the preferred provider for balance lookups. Zerion is a unified API covering both EVM and Solana chains — when configured, it takes priority over DeBank/Helius.
Provider priority: Zerion API → Nansen CLI → DeBank/Helius API → Browser scraping
Each mixin in src/lib/mixins/ exports:
- A methods interface (e.g.,
CoinGeckoMethods) - A
withX(Base)function that returns a class extending Base with those methods
CLI Context Pattern
Commands receive a CliContext (from src/cli/shared.ts) which provides:
colors- Terminal color helpers (respect--plain/--no-color)p(kind)- Status prefixes (ok/warn/err/info/hint)getOutput()- Current output config (json, plain, color, emoji)resolveCredentials()- Merged config + env credentialsgetClientOptions()- Ready-to-use options forOnchainClientoutputJson(data)- JSON output helper
Command Registration
Each command file in src/commands/ exports a registerXCommand(program, ctx) function. Commands are registered in src/cli/program.ts.
Result Types
All client methods return tagged union results for error handling:
type BalanceResult =
| { success: true; balances: TokenBalance[]; totalValueUsd: number }
| { success: false; error: string };Code Style Rules
- Import extensions: Always use
.jsextensions for local imports (enforced by biome) - No forEach: Use
for...ofloops instead (biome:noForEach) - Top-level regex: Define regex patterns at module level for performance (biome:
useTopLevelRegex) - No explicit any: Use proper types or
unknown(biome:noExplicitAny) - Block statements: Always use braces for if/else/for (biome:
useBlockStatements) - Line width: 120 characters max
- Quotes: Single quotes, semicolons required
Browser Fallback
When DeBank/Helius API keys aren't configured, the CLI can fall back to browser scraping via agent-browser:
- Uses unique session names (
--session onchain-<random>) to avoid session conflicts - Extraction scripts return objects directly (not JSON.stringify) for proper parsing
- Located in
src/lib/browser-scraper.ts
Configuration
Config files: ~/.config/onchain/config.json5 (global) or ./.onchainrc.json5 (local)
Environment variables (override config): DEBANK_API_KEY, HELIUS_API_KEY, ZERION_API_KEY, COINBASE_API_KEY_ID, COINBASE_API_KEY_SECRET, BINANCE_API_KEY, BINANCE_API_SECRET, COINGECKO_API_KEY, COINMARKETCAP_API_KEY, ETHERSCAN_API_KEY, SOLSCAN_API_KEY, WALLETCONNECT_PROJECT_ID
Releasing
IMPORTANT: When any code changes are made (not just *.md files), you MUST release a new version:
1. Bump version in package.json (use semver: patch for fixes, minor for features, major for breaking changes) 2. Commit the version bump 3. Create git tag: git tag v<version> (e.g., git tag v0.2.0) 4. Push the tag: git push origin v<version>
This triggers the npm release via GitHub Actions. Always do this after pushing code changes.
# Example release flow after code changes
pnpm version patch # or minor/major - bumps package.json and creates tag
git push && git push --tagsChecklist for New Features
When adding new commands or capabilities:
1. Update SKILL.md - Add the new command to the appropriate section with usage examples 2. Update setup wizard - If the feature requires API keys or preferences, add prompts to src/cli/setup-wizard.ts 3. Update credentials - Add new API keys to src/lib/credentials.ts and src/lib/config.ts 4. Update this file - Update the mixin chain diagram and environment variables list 5. Release - Bump version, tag, and push (see Releasing section above)
Contributing to @cyberdrk/onchain
Development Setup
git clone https://github.com/cyberdrk305/onchain.git
cd onchain
pnpm installDevelopment Workflow
# Run CLI without building
pnpm run dev <command>
# Build TypeScript
pnpm run build:dist
# Build standalone binary
pnpm run build:binary
# Run tests
pnpm run test
pnpm run test:watch
# Lint
pnpm run lint
pnpm run lint:fixProject Structure
src/
├── cli.ts # Entry point
├── cli/
│ ├── program.ts # Command registration
│ ├── shared.ts # CliContext and utilities
│ └── setup-wizard.ts # Interactive setup
├── commands/ # CLI commands
│ ├── balance.ts
│ ├── price.ts
│ └── ...
└── lib/
├── onchain-client.ts # Composed client
├── onchain-client-base.ts
├── mixins/ # API provider mixins
│ ├── coingecko.ts
│ ├── debank.ts
│ └── ...
├── config.ts
├── credentials.ts
└── output.tsArchitecture
Mixin Pattern
API providers are implemented as mixins that compose onto OnchainClientBase:
// Each mixin adds methods to the client
export function withCoinGecko<TBase extends AbstractConstructor<OnchainClientBase>>(
Base: TBase
): Mixin<TBase, CoinGeckoMethods> {
abstract class CoinGeckoMixin extends Base implements CoinGeckoMethods {
async getTokenPrice(tokenId: string): Promise<PriceResult> {
// Implementation
}
}
return CoinGeckoMixin;
}Result Types
All client methods return tagged unions for explicit error handling:
type PriceResult =
| { success: true; token: TokenPrice }
| { success: false; error: string };Adding a New Command
1. Create src/commands/mycommand.ts:
export function registerMyCommand(program: Command, ctx: CliContext): void {
program
.command('mycommand')
.description('Does something')
.action(async () => {
const client = new OnchainClient(ctx.getClientOptions());
// Implementation
});
}2. Register in src/cli/program.ts:
import { registerMyCommand } from '../commands/mycommand.js';
// ...
registerMyCommand(program, ctx);Adding a New API Provider
1. Create src/lib/mixins/myprovider.ts following the mixin pattern 2. Add to composition in src/lib/onchain-client.ts 3. Add credentials to OnchainClientBase and config types
Code Style
- Use
.jsextensions for local imports - Use
for...ofinstead of.forEach() - Define regex patterns at module level
- Use proper types (no
any) - Always use block statements for if/else/for
- Single quotes, semicolons required
- 120 character line width
Run pnpm run lint to check and pnpm run lint:fix to auto-fix.
Pull Requests
1. Fork the repository 2. Create a feature branch (git checkout -b feature/my-feature) 3. Make your changes 4. Run pnpm run lint && pnpm run test 5. Commit with a descriptive message 6. Push and open a PR
Releases
Releases are automated via GitHub Actions when a version tag is pushed:
# Update version in package.json, then:
git tag v0.2.0
git push origin v0.2.0This triggers npm publish automatically.
MIT License
Copyright (c) 2025 cyberdrk305
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": "@skillhq/onchain",
"version": "0.8.0",
"description": "CLI tool for crypto portfolio tracking, market data, and CEX history",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"onchain": "dist/cli.js"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE",
"SKILL.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/skillhq/onchain.git"
},
"homepage": "https://github.com/skillhq/onchain#readme",
"bugs": {
"url": "https://github.com/skillhq/onchain/issues"
},
"author": "skillhq",
"license": "MIT",
"keywords": [
"crypto",
"portfolio",
"cli",
"defi",
"ethereum",
"solana",
"coinbase",
"binance",
"polymarket",
"wallet"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc",
"build:binary": "ONCHAIN_VERSION=$(node -p \"require('./package.json').version\") ONCHAIN_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=ONCHAIN_* src/cli.ts --outfile onchain",
"dev": "tsx src/cli.ts",
"onchain": "pnpm run build:dist && node dist/cli.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests",
"format": "biome format --write .",
"binary": "pnpm run build:binary"
},
"dependencies": {
"@solana/web3.js": "^1.98.4",
"@walletconnect/keyvaluestorage": "^1.1.1",
"@walletconnect/sign-client": "^2.23.4",
"commander": "^14.0.0",
"json5": "^2.2.3",
"kleur": "^4.1.5",
"qrcode-terminal": "^0.12.0",
"viem": "^2.45.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.0",
"@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.11.0",
"tsx": "^4.21.0",
"typescript": "^5.9.0",
"vitest": "^4.0.0"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}
@skillhq/onchain
CLI for crypto portfolio tracking, market data, and CEX history. Designed for both human use and AI agent integration.
Installation
Install the skill for your AI coding agent:
npx skills add https://github.com/skillhq/onchain --skill onchainInstall the CLI globally:
npm install -g @skillhq/onchainThen configure your API keys:
onchain setupThis interactive wizard walks you through configuring Zerion (recommended), Coinbase, Binance, and other providers.
Quick Start
# Check token prices
onchain price btc
onchain price eth sol matic
# View wallet balances (auto-detects EVM/Solana)
onchain balance 0x1234...5678
# CEX balances
onchain coinbase balance
onchain binance balance
# Market overview
onchain markets
# Prediction markets
onchain polymarket trendingCommands
Market Data
onchain price <token> # Token price with 24h change
onchain markets # Market overview with trending coinsWallet Data
onchain balance <address> # Token balances (EVM or Solana)
onchain history <address> # Transaction history
onchain portfolio <address> # Full portfolio with DeFi positionsCEX Data
onchain coinbase balance # Coinbase account balances
onchain coinbase history # Trade history
onchain binance balance # Binance account balances
onchain binance history # Trade historyPrediction Markets
onchain polymarket trending # Trending markets
onchain polymarket search <q> # Search markets
onchain polymarket view <slug> # Market detailsConfiguration
onchain setup # Interactive API key setup
onchain config # View current configuration
onchain test # Test all configured providersConfiguration
Run onchain setup for interactive configuration, or set environment variables:
| Feature | Environment Variable | Provider |
|---|---|---|
| EVM + Solana wallets | ZERION_API_KEY | Zerion (recommended) |
| Coinbase | COINBASE_API_KEY_ID + COINBASE_API_KEY_SECRET | Coinbase CDP |
| Binance | BINANCE_API_KEY + BINANCE_API_SECRET | Binance |
| EVM wallets (fallback) | DEBANK_API_KEY | DeBank Cloud |
| Solana wallets (fallback) | HELIUS_API_KEY | Helius |
| Market data | COINGECKO_API_KEY | CoinGecko (optional) |
| Market fallback | COINMARKETCAP_API_KEY | CoinMarketCap (optional) |
Config file locations:
- Global:
~/.config/onchain/config.json5 - Local:
./.onchainrc.json5
Global Options
--json # Output as JSON (for scripting/agents)
--plain # Disable colors and emoji
--no-color # Disable colors only
--timeout <ms> # Request timeout in millisecondsAgent Integration
This CLI is designed for AI agent use with --json output:
# Get portfolio value
onchain --json portfolio 0x123... | jq '.totalValueUsd'
# Check if market is up
onchain --json markets | jq '.marketCapChange24h > 0'
# Get specific token price
onchain --json price eth | jq '{price: .priceUsd, change: .priceChange24h}'Exit codes: 0 for success, 1 for errors.
Supported Chains
EVM + Solana (via Zerion): Ethereum, BNB Chain, Polygon, Arbitrum, Optimism, Avalanche, Base, zkSync Era, Linea, Scroll, Blast, Mantle, Solana, and more. Single API key covers all chains.
EVM (via DeBank, fallback): Same EVM chains, requires separate DeBank API key.
Solana (via Helius, fallback): Full mainnet support including SPL tokens and NFTs.
Development
pnpm install
pnpm run dev price btc # Run without building
pnpm run build # Build TypeScript
pnpm run test # Run tests
pnpm run lint # Lint codeLicense
MIT
#!/usr/bin/env bash
set -euo pipefail
# Publish onchain skill to ClawdHub
# Usage:
# ./scripts/publish-skill.sh # Use version from package.json
# ./scripts/publish-skill.sh 0.2.0 # Specify version
# ./scripts/publish-skill.sh patch # Bump patch (0.1.0 -> 0.1.1)
# ./scripts/publish-skill.sh minor # Bump minor (0.1.0 -> 0.2.0)
# ./scripts/publish-skill.sh major # Bump major (0.1.0 -> 1.0.0)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# Read current version from package.json
CURRENT_VERSION=$(node -p "require('./package.json').version")
# Determine target version
if [[ $# -eq 0 ]]; then
VERSION="$CURRENT_VERSION"
elif [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION="$1"
elif [[ "$1" == "patch" || "$1" == "minor" || "$1" == "major" ]]; then
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
case "$1" in
patch) VERSION="$major.$minor.$((patch + 1))" ;;
minor) VERSION="$major.$((minor + 1)).0" ;;
major) VERSION="$((major + 1)).0.0" ;;
esac
else
echo "Usage: $0 [version|patch|minor|major]"
echo " version: semver like 0.2.0"
echo " patch: bump patch version"
echo " minor: bump minor version"
echo " major: bump major version"
exit 1
fi
# Get changelog from git (commits since last tag, or last 5 commits)
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG=$(git log "$LAST_TAG"..HEAD --oneline --no-decorate | head -10 | sed 's/^/- /')
else
CHANGELOG=$(git log -5 --oneline --no-decorate | sed 's/^/- /')
fi
if [[ -z "$CHANGELOG" ]]; then
CHANGELOG="Version $VERSION"
fi
echo "Publishing onchain skill v$VERSION"
echo "Changelog:"
echo "$CHANGELOG"
echo ""
# Update package.json version if different
if [[ "$VERSION" != "$CURRENT_VERSION" ]]; then
echo "Updating package.json version: $CURRENT_VERSION -> $VERSION"
npm pkg set version="$VERSION"
fi
# Publish to ClawdHub
# Note: --workdir is required to override clawdbot default workspace
TAGS="latest,crypto,blockchain,portfolio,defi,wallet,prices,bitcoin,ethereum,solana,polymarket,coinbase,binance,trading"
clawdhub --workdir "$PROJECT_DIR" publish . \
--slug onchain \
--name "Onchain CLI" \
--version "$VERSION" \
--tags "$TAGS" \
--changelog "$CHANGELOG"
echo ""
echo "Published onchain@$VERSION to ClawdHub"
#!/usr/bin/env node
import { createProgram } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
const ctx = createCliContext(process.argv);
const program = createProgram(ctx);
program.parseAsync(process.argv).catch((error) => {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { registerBalanceCommand } from '../commands/balance.js';
import { registerBinanceCommands } from '../commands/binance.js';
import { registerCoinbaseCommands } from '../commands/coinbase.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerGasCommand } from '../commands/gas.js';
import { registerHistoryCommand } from '../commands/history.js';
import { registerMarketsCommand } from '../commands/markets.js';
import { registerNansenCommands } from '../commands/nansen.js';
import { registerPolymarketCommands } from '../commands/polymarket.js';
import { registerPortfolioCommand } from '../commands/portfolio.js';
import { registerPriceCommand } from '../commands/price.js';
import { registerSearchCommand } from '../commands/search.js';
import { registerSetupCommand } from '../commands/setup.js';
import { registerTestCommand } from '../commands/test.js';
import { registerTxCommand } from '../commands/tx.js';
import { registerWalletCommand } from '../commands/wallet.js';
import type { CliContext } from './shared.js';
// Read version from package.json (works in both dev and built modes)
function getPackageVersion(): string {
// Check env var first (set during binary build)
if (process.env.ONCHAIN_VERSION) {
return process.env.ONCHAIN_VERSION;
}
// Fall back to reading package.json
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
// In dev: src/cli/program.ts -> ../../package.json
// In dist: dist/cli/program.js -> ../../package.json
const pkgPath = join(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
return pkg.version;
} catch {
return '0.0.0';
}
}
const version = getPackageVersion();
const gitSha = process.env.ONCHAIN_GIT_SHA ?? '';
export function createProgram(ctx: CliContext): Command {
const program = new Command();
program
.name('onchain')
.description('CLI for crypto portfolio tracking, market data, and CEX history')
.version(gitSha ? `${version} (${gitSha})` : version, '-V, --version')
.option('--plain', 'Disable colors and emoji')
.option('--no-emoji', 'Disable emoji')
.option('--no-color', 'Disable colors')
.option('--json', 'Output as JSON')
.option('--timeout <ms>', 'Request timeout in milliseconds')
.hook('preAction', (thisCommand) => {
ctx.applyOutputFromCommand(thisCommand);
});
// Register all commands
registerBalanceCommand(program, ctx);
registerHistoryCommand(program, ctx);
registerPortfolioCommand(program, ctx);
registerPriceCommand(program, ctx);
registerMarketsCommand(program, ctx);
registerGasCommand(program, ctx);
registerSearchCommand(program, ctx);
registerTxCommand(program, ctx);
registerCoinbaseCommands(program, ctx);
registerBinanceCommands(program, ctx);
registerPolymarketCommands(program, ctx);
registerNansenCommands(program, ctx);
registerWalletCommand(program, ctx);
registerSetupCommand(program, ctx);
registerConfigCommand(program, ctx);
registerTestCommand(program, ctx);
return program;
}
import * as readline from 'node:readline';
import type { OnchainConfig } from '../lib/config.js';
function createPrompt(): {
question: (prompt: string) => Promise<string>;
close: () => void;
} {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return {
question: (prompt: string) =>
new Promise((resolve) => {
rl.question(prompt, resolve);
}),
close: () => rl.close(),
};
}
export async function runSetupWizard(
existingConfig: OnchainConfig,
colors: {
section: (s: string) => string;
accent: (s: string) => string;
muted: (s: string) => string;
},
): Promise<Partial<OnchainConfig>> {
const prompt = createPrompt();
const config: Partial<OnchainConfig> = {};
console.log();
console.log(colors.section('Onchain CLI Setup'));
console.log(colors.muted('Press Enter to skip any field and keep the existing value.'));
console.log();
try {
console.log(colors.accent('API Keys'));
console.log(colors.muted('These can also be set via environment variables.'));
console.log();
// DeBank API Key
const debankKey = await prompt.question(`DeBank API Key${existingConfig.debankApiKey ? ' (configured)' : ''}: `);
if (debankKey) {
config.debankApiKey = debankKey;
}
// Helius API Key
const heliusKey = await prompt.question(`Helius API Key${existingConfig.heliusApiKey ? ' (configured)' : ''}: `);
if (heliusKey) {
config.heliusApiKey = heliusKey;
}
// Zerion API Key
console.log();
console.log(colors.muted('Zerion (unified EVM + Solana portfolio & history, get key at https://zerion.io/api):'));
const zerionKey = await prompt.question(` Zerion API Key${existingConfig.zerionApiKey ? ' (configured)' : ''}: `);
if (zerionKey) {
config.zerionApiKey = zerionKey;
}
// Coinbase CDP API (JWT-based auth with ECDSA)
console.log();
console.log(colors.muted('Coinbase CDP API (create keys at https://portal.cdp.coinbase.com/):'));
console.log(colors.muted(' IMPORTANT: Select ECDSA as signature algorithm (NOT Ed25519)'));
console.log(colors.muted(' API Key ID format: organizations/{org_id}/apiKeys/{key_id}'));
const coinbaseApiKeyId = await prompt.question(
` Coinbase API Key ID${existingConfig.coinbaseApiKeyId ? ' (configured)' : ''}: `,
);
if (coinbaseApiKeyId) {
config.coinbaseApiKeyId = coinbaseApiKeyId;
}
console.log(colors.muted(' Secret: paste the EC private key PEM (multiline, end with empty line)'));
console.log(colors.muted(' Or set COINBASE_API_KEY_SECRET env var with the PEM content'));
const existingSecret = existingConfig.coinbaseApiKeySecret;
const secretPrompt = ` Coinbase API Key Secret${existingSecret ? ' (configured, press Enter to keep)' : ''}: `;
let coinbaseApiKeySecret = await prompt.question(secretPrompt);
// Handle multiline PEM input
if (coinbaseApiKeySecret.startsWith('-----BEGIN')) {
const lines = [coinbaseApiKeySecret];
while (true) {
const line = await prompt.question('');
if (!line) {
break;
}
lines.push(line);
if (line.includes('-----END')) {
break;
}
}
coinbaseApiKeySecret = lines.join('\n');
}
if (coinbaseApiKeySecret) {
config.coinbaseApiKeySecret = coinbaseApiKeySecret;
}
// Binance
console.log();
console.log(colors.muted('Binance (read-only API keys):'));
const binanceKey = await prompt.question(
` Binance API Key${existingConfig.binanceApiKey ? ' (configured)' : ''}: `,
);
if (binanceKey) {
config.binanceApiKey = binanceKey;
}
const binanceSecret = await prompt.question(
` Binance API Secret${existingConfig.binanceApiSecret ? ' (configured)' : ''}: `,
);
if (binanceSecret) {
config.binanceApiSecret = binanceSecret;
}
// CoinGecko (optional)
console.log();
const coingeckoKey = await prompt.question(
`CoinGecko Pro API Key (optional)${existingConfig.coingeckoApiKey ? ' (configured)' : ''}: `,
);
if (coingeckoKey) {
config.coingeckoApiKey = coingeckoKey;
}
// CoinMarketCap (optional fallback)
const cmcKey = await prompt.question(
`CoinMarketCap API Key (optional fallback)${existingConfig.coinmarketcapApiKey ? ' (configured)' : ''}: `,
);
if (cmcKey) {
config.coinmarketcapApiKey = cmcKey;
}
// Block Explorer APIs
console.log();
console.log(colors.accent('Block Explorer APIs'));
console.log(colors.muted('For transaction lookup (onchain tx <hash>)'));
console.log();
// Etherscan (works on multiple EVM chains)
const etherscanKey = await prompt.question(
`Etherscan API Key${existingConfig.etherscanApiKey ? ' (configured)' : ''}: `,
);
if (etherscanKey) {
config.etherscanApiKey = etherscanKey;
}
// Solscan (Solana)
const solscanKey = await prompt.question(`Solscan API Key${existingConfig.solscanApiKey ? ' (configured)' : ''}: `);
if (solscanKey) {
config.solscanApiKey = solscanKey;
}
// WalletConnect
console.log();
console.log(colors.accent('WalletConnect'));
console.log(colors.muted('For wallet connectivity (send transactions via mobile wallets)'));
console.log(colors.muted('Get a free Project ID at https://dashboard.walletconnect.com/'));
console.log();
const walletConnectProjectId = await prompt.question(
`WalletConnect Project ID${existingConfig.walletConnectProjectId ? ' (configured)' : ''}: `,
);
if (walletConnectProjectId) {
config.walletConnectProjectId = walletConnectProjectId;
}
// Polymarket Preferences
console.log();
console.log(colors.accent('Polymarket Preferences'));
console.log(colors.muted('Configure tag filters for polymarket trending/search commands.'));
console.log(colors.muted('Run `onchain polymarket tags --popular` to see available tags.'));
console.log();
const existingExcludes = existingConfig.polymarket?.excludeTags?.join(', ') || '';
const excludePrompt = existingExcludes
? `Exclude tags (comma-separated, currently: ${existingExcludes}): `
: 'Exclude tags (comma-separated, e.g., sports,nfl,nba): ';
const excludeInput = await prompt.question(excludePrompt);
if (excludeInput) {
const excludeTags = excludeInput
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
if (excludeTags.length > 0) {
config.polymarket = { ...config.polymarket, excludeTags };
}
}
const existingIncludes = existingConfig.polymarket?.includeTags?.join(', ') || '';
const includePrompt = existingIncludes
? `Include only tags (comma-separated, currently: ${existingIncludes}): `
: 'Include only tags (comma-separated, leave empty for all): ';
const includeInput = await prompt.question(includePrompt);
if (includeInput) {
const includeTags = includeInput
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
if (includeTags.length > 0) {
config.polymarket = { ...config.polymarket, includeTags };
}
}
console.log();
return config;
} finally {
prompt.close();
}
}
import type { Command } from 'commander';
import kleur from 'kleur';
import { loadConfig, type OnchainConfig } from '../lib/config.js';
import { type ResolvedCredentials, resolveCredentials } from '../lib/credentials.js';
import type { OnchainClientOptions } from '../lib/onchain-client-types.js';
import {
type LabelKind,
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
type StatusKind,
statusPrefix,
} from '../lib/output.js';
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
positive: (t: string) => string;
negative: (t: string) => string;
};
p: (kind: StatusKind) => string;
l: (kind: LabelKind) => string;
config: OnchainConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
resolveCredentials: () => ResolvedCredentials;
getClientOptions: () => OnchainClientOptions;
outputJson: (data: unknown) => void;
};
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
const isTty = process.stdout.isTTY ?? false;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap =
(styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().magenta(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.magenta(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.cyan(t)),
positive: wrap((t) => kleur.green(t)),
negative: wrap((t) => kleur.red(t)),
};
const p = (kind: StatusKind): string => {
const prefix = statusPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'ok') {
return kleur.green(prefix);
}
if (kind === 'warn') {
return kleur.yellow(prefix);
}
if (kind === 'err') {
return kleur.red(prefix);
}
if (kind === 'info') {
return kleur.cyan(prefix);
}
return kleur.gray(prefix);
};
const l = (kind: LabelKind): string => {
const prefix = labelPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
const colorMap: Record<LabelKind, (s: string) => string> = {
url: kleur.cyan,
date: kleur.magenta,
wallet: kleur.yellow,
chain: kleur.blue,
token: kleur.cyan,
price: kleur.green,
value: kleur.yellow,
change: kleur.magenta,
balance: kleur.blue,
position: kleur.cyan,
exchange: kleur.yellow,
};
return colorMap[kind](prefix);
};
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean; json?: boolean };
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.ONCHAIN_TIMEOUT_MS);
}
function getResolvedCredentials(): ResolvedCredentials {
return resolveCredentials(config, env);
}
function getClientOptions(): OnchainClientOptions {
const creds = getResolvedCredentials();
return {
debankApiKey: creds.debankApiKey,
heliusApiKey: creds.heliusApiKey,
coinbaseApiKeyId: creds.coinbaseApiKeyId,
coinbaseApiKeySecret: creds.coinbaseApiKeySecret,
binanceApiKey: creds.binanceApiKey,
binanceApiSecret: creds.binanceApiSecret,
coingeckoApiKey: creds.coingeckoApiKey,
coinmarketcapApiKey: creds.coinmarketcapApiKey,
etherscanApiKey: creds.etherscanApiKey,
solscanApiKey: creds.solscanApiKey,
walletConnectProjectId: creds.walletConnectProjectId,
zerionApiKey: creds.zerionApiKey,
timeoutMs: config.timeoutMs,
};
}
function outputJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveCredentials: getResolvedCredentials,
getClientOptions,
outputJson,
};
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { isAgentBrowserAvailable, scrapeDebankProfile, scrapeSolscanProfile } from '../lib/browser-scraper.js';
import { validateCredentials } from '../lib/credentials.js';
import { OnchainClient } from '../lib/onchain-client.js';
import type { EvmChain, SupportedChain, TokenBalance } from '../lib/onchain-client-types.js';
import { isEvmAddress, isSolanaAddress } from '../lib/utils/address.js';
import { formatChainName, formatTokenAmount, formatUsd } from '../lib/utils/formatters.js';
export function registerBalanceCommand(program: Command, ctx: CliContext): void {
program
.command('balance')
.description('Get wallet token balances')
.argument('<address>', 'Wallet address (EVM 0x... or Solana)')
.option('--chain <chain>', 'Filter by specific chain (for EVM wallets)')
.option('--min-value <usd>', 'Minimum USD value to show', '0')
.option('-n, --limit <number>', 'Maximum tokens to show', '50')
.option('--browser', 'Use browser scraping (requires agent-browser CLI)')
.option('--json', 'Output as JSON')
.action(
async (
address: string,
cmdOpts: { chain?: string; minValue?: string; limit?: string; browser?: boolean; json?: boolean },
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Detect chain type from address format
let chainType: 'evm' | 'solana';
if (isEvmAddress(address)) {
chainType = 'evm';
} else if (isSolanaAddress(address)) {
chainType = 'solana';
} else {
console.error(`${ctx.p('err')}Invalid address format. Expected EVM (0x...) or Solana address.`);
process.exit(1);
}
// Check credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
// Initialize client to check for Nansen availability
const client = new OnchainClient(clientOpts);
const useNansen = client.isNansenAvailable();
const hasApiKey = chainType === 'evm' ? validation.hasDebank : validation.hasHelius;
const useBrowser = cmdOpts.browser || (!validation.hasZerion && !useNansen && !hasApiKey);
const output = ctx.getOutput();
const colors = ctx.colors;
let balances: TokenBalance[] = [];
let totalValueUsd = 0;
let source: 'zerion' | 'nansen' | 'api' | 'browser' = 'api';
// Try Zerion first (highest priority when configured)
if (validation.hasZerion && !cmdOpts.browser) {
source = 'zerion';
const chains = cmdOpts.chain ? [cmdOpts.chain as EvmChain] : undefined;
const result = await client.getZerionBalances(address, chains);
if (result.success) {
balances = result.balances;
totalValueUsd = result.totalValueUsd;
} else {
console.error(colors.muted(`Zerion failed: ${result.error}, falling back...`));
source = 'api';
}
}
// Try Nansen CLI if Zerion not available or failed
if (source === 'api' && useNansen && !cmdOpts.browser) {
source = 'nansen';
const chain: SupportedChain = chainType === 'evm' ? ((cmdOpts.chain as EvmChain) ?? 'eth') : 'solana';
const result = await client.getNansenBalances(address, chain);
if (result.success) {
balances = result.balances;
totalValueUsd = result.totalValueUsd;
} else {
console.error(colors.muted(`Nansen failed: ${result.error}, falling back to API...`));
source = 'api';
}
}
// Use DeBank/Helius API fallback
if (source === 'api' && !useBrowser) {
if (chainType === 'evm') {
const chains = cmdOpts.chain ? [cmdOpts.chain as EvmChain] : undefined;
const result = await client.getEvmBalances(address, chains);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
balances = result.balances;
totalValueUsd = result.totalValueUsd;
} else if (chainType === 'solana') {
const result = await client.getSolanaBalances(address);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
balances = result.balances;
totalValueUsd = result.totalValueUsd;
}
}
// Use browser scraping as last resort
if (useBrowser || (source === 'api' && balances.length === 0 && !hasApiKey)) {
source = 'browser';
if (!isAgentBrowserAvailable()) {
const apiKeyName = chainType === 'evm' ? 'DEBANK_API_KEY' : 'HELIUS_API_KEY';
console.error(
`${ctx.p('err')}No API key configured and agent-browser CLI not available.\n` +
`Set ${apiKeyName} or install agent-browser for browser scraping.`,
);
process.exit(1);
}
console.error(colors.muted('Using browser scraping (no API key configured)...'));
const scrapeResult =
chainType === 'evm'
? await scrapeDebankProfile(address, timeoutMs ?? 60000)
: await scrapeSolscanProfile(address, timeoutMs ?? 60000);
if (!scrapeResult.success) {
console.error(`${ctx.p('err')}${scrapeResult.error}`);
process.exit(1);
}
// Convert scraped balances to TokenBalance format
balances = scrapeResult.balances.map((b) => ({
symbol: b.symbol,
name: b.name,
chain: (b.chain ?? chainType) as EvmChain | 'solana',
balanceRaw: String(b.amount),
balanceFormatted: b.amount,
decimals: 0,
priceUsd: b.valueUsd && b.amount > 0 ? b.valueUsd / b.amount : null,
valueUsd: b.valueUsd,
}));
totalValueUsd = scrapeResult.totalValueUsd;
}
// Apply filters
const minValue = Number.parseFloat(cmdOpts.minValue ?? '0');
const limit = Number.parseInt(cmdOpts.limit ?? '50', 10);
let filteredBalances = balances.filter((b) => (b.valueUsd ?? 0) >= minValue);
filteredBalances = filteredBalances.slice(0, limit);
if (cmdOpts.json || output.json) {
ctx.outputJson({
address,
chainType,
totalValueUsd,
balances: filteredBalances,
source,
});
return;
}
console.log();
console.log(colors.section('Wallet Balance'));
console.log(colors.muted(`${address}`));
if (source === 'nansen') {
console.log(colors.muted('(via Nansen CLI)'));
} else if (source === 'zerion') {
console.log(colors.muted('(via Zerion API)'));
} else if (source === 'browser') {
console.log(colors.muted('(via browser scraping)'));
}
console.log();
if (filteredBalances.length === 0) {
console.log(colors.muted('No tokens found with value above minimum threshold.'));
console.log();
return;
}
// Header
console.log(
`${'Token'.padEnd(12)} ${'Amount'.padStart(16)} ${'Price'.padStart(12)} ${'Value'.padStart(14)} ${'Chain'.padEnd(10)}`,
);
console.log('-'.repeat(70));
for (const balance of filteredBalances) {
const symbol = balance.symbol.slice(0, 10).padEnd(12);
const amount = formatTokenAmount(balance.balanceFormatted).padStart(16);
const price = balance.priceUsd !== null ? formatUsd(balance.priceUsd).padStart(12) : '-'.padStart(12);
const value = balance.valueUsd !== null ? formatUsd(balance.valueUsd).padStart(14) : '-'.padStart(14);
const chain = formatChainName(balance.chain).slice(0, 10).padEnd(10);
console.log(`${symbol} ${amount} ${price} ${value} ${colors.muted(chain)}`);
}
console.log('-'.repeat(70));
console.log(
`${'Total'.padEnd(12)} ${' '.padStart(16)} ${' '.padStart(12)} ${formatUsd(totalValueUsd).padStart(14)}`,
);
console.log();
if (balances.length > filteredBalances.length) {
console.log(
colors.muted(`Showing ${filteredBalances.length} of ${balances.length} tokens (use --limit to show more)`),
);
console.log();
}
},
);
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { getMissingCredentialsMessage, validateCredentials } from '../lib/credentials.js';
import { OnchainClient } from '../lib/onchain-client.js';
import { formatRelativeTime, formatTokenAmount, formatUsd } from '../lib/utils/formatters.js';
export function registerBinanceCommands(program: Command, ctx: CliContext): void {
const binance = program.command('binance').description('Binance exchange commands');
binance
.command('balance')
.description('Get Binance account balances')
.option('--min-value <usd>', 'Minimum USD value to show', '0')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { minValue?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
if (!validation.hasBinance) {
console.error(`${ctx.p('err')}${getMissingCredentialsMessage('Binance', ['binance'])}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const result = await client.getBinanceBalances();
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
// Apply filters
const minValue = Number.parseFloat(cmdOpts.minValue ?? '0');
const filteredBalances = result.balances.filter((b) => (b.valueUsd ?? 0) >= minValue);
if (cmdOpts.json || output.json) {
ctx.outputJson({
exchange: 'binance',
totalValueUsd: result.totalValueUsd,
balances: filteredBalances,
});
return;
}
console.log();
console.log(colors.section('Binance Balance'));
console.log();
if (filteredBalances.length === 0) {
console.log(colors.muted('No assets found with value above minimum threshold.'));
console.log();
return;
}
console.log(`${'Asset'.padEnd(10)} ${'Free'.padStart(14)} ${'Locked'.padStart(14)} ${'Value'.padStart(14)}`);
console.log('-'.repeat(56));
for (const balance of filteredBalances) {
const asset = balance.asset.padEnd(10);
const free = formatTokenAmount(balance.free).padStart(14);
const locked = formatTokenAmount(balance.locked).padStart(14);
const value = balance.valueUsd !== undefined ? formatUsd(balance.valueUsd).padStart(14) : '-'.padStart(14);
const lockedDisplay = balance.locked > 0 ? colors.muted(locked) : locked;
console.log(`${asset} ${free} ${lockedDisplay} ${value}`);
}
console.log('-'.repeat(56));
console.log(
`${'Total'.padEnd(10)} ${' '.padStart(14)} ${' '.padStart(14)} ${formatUsd(result.totalValueUsd).padStart(14)}`,
);
console.log();
});
binance
.command('history')
.description('Get Binance trade history')
.option('--symbol <symbol>', 'Trading pair (e.g., BTCUSDT)')
.option('-n, --limit <number>', 'Number of trades to fetch', '50')
.option('--cursor <cursor>', 'Pagination cursor (trade ID)')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { symbol?: string; limit?: string; cursor?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
if (!validation.hasBinance) {
console.error(`${ctx.p('err')}${getMissingCredentialsMessage('Binance', ['binance'])}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const limit = Number.parseInt(cmdOpts.limit ?? '50', 10);
const result = await client.getBinanceHistory({
symbol: cmdOpts.symbol,
limit,
cursor: cmdOpts.cursor,
});
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson({
exchange: 'binance',
trades: result.trades,
nextCursor: result.nextCursor,
});
return;
}
console.log();
console.log(colors.section('Binance Trade History'));
console.log();
if (result.trades.length === 0) {
console.log(colors.muted('No trades found.'));
console.log();
return;
}
for (const trade of result.trades) {
const sideColor = trade.side === 'buy' ? colors.positive : colors.negative;
const sideLabel = sideColor(trade.side.toUpperCase().padEnd(4));
console.log(`${sideLabel} ${trade.symbol}`);
console.log(` ${formatTokenAmount(trade.quantity)} @ ${formatUsd(trade.price)} = ${formatUsd(trade.total)}`);
if (trade.fee && trade.feeAsset) {
console.log(colors.muted(` Fee: ${formatTokenAmount(trade.fee)} ${trade.feeAsset}`));
}
console.log(colors.muted(` ${formatRelativeTime(trade.timestamp)}`));
console.log();
}
if (result.nextCursor) {
console.log(colors.muted(`Use --cursor "${result.nextCursor}" to see more trades`));
console.log();
}
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { getMissingCredentialsMessage, validateCredentials } from '../lib/credentials.js';
import { OnchainClient } from '../lib/onchain-client.js';
import { formatRelativeTime, formatTokenAmount, formatUsd } from '../lib/utils/formatters.js';
export function registerCoinbaseCommands(program: Command, ctx: CliContext): void {
const coinbase = program.command('coinbase').description('Coinbase exchange commands');
coinbase
.command('balance')
.description('Get Coinbase account balances')
.option('--min-value <usd>', 'Minimum USD value to show', '0')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { minValue?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
if (!validation.hasCoinbase) {
console.error(`${ctx.p('err')}${getMissingCredentialsMessage('Coinbase', ['coinbase'])}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const result = await client.getCoinbaseBalances();
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
// Apply filters
const minValue = Number.parseFloat(cmdOpts.minValue ?? '0');
const filteredBalances = result.balances.filter((b) => (b.valueUsd ?? 0) >= minValue);
if (cmdOpts.json || output.json) {
ctx.outputJson({
exchange: 'coinbase',
totalValueUsd: result.totalValueUsd,
balances: filteredBalances,
});
return;
}
console.log();
console.log(colors.section('Coinbase Balance'));
console.log();
if (filteredBalances.length === 0) {
console.log(colors.muted('No assets found with value above minimum threshold.'));
console.log();
return;
}
console.log(`${'Asset'.padEnd(10)} ${'Amount'.padStart(16)} ${'Value'.padStart(14)}`);
console.log('-'.repeat(44));
for (const balance of filteredBalances) {
const asset = balance.asset.padEnd(10);
const amount = formatTokenAmount(balance.total).padStart(16);
const value = balance.valueUsd !== undefined ? formatUsd(balance.valueUsd).padStart(14) : '-'.padStart(14);
console.log(`${asset} ${amount} ${value}`);
}
console.log('-'.repeat(44));
console.log(`${'Total'.padEnd(10)} ${' '.padStart(16)} ${formatUsd(result.totalValueUsd).padStart(14)}`);
console.log();
});
coinbase
.command('history')
.description('Get Coinbase trade history')
.option('-n, --limit <number>', 'Number of trades to fetch', '20')
.option('--cursor <cursor>', 'Pagination cursor')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { limit?: string; cursor?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
if (!validation.hasCoinbase) {
console.error(`${ctx.p('err')}${getMissingCredentialsMessage('Coinbase', ['coinbase'])}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const limit = Number.parseInt(cmdOpts.limit ?? '20', 10);
const result = await client.getCoinbaseHistory({
limit,
cursor: cmdOpts.cursor,
});
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson({
exchange: 'coinbase',
trades: result.trades,
nextCursor: result.nextCursor,
});
return;
}
console.log();
console.log(colors.section('Coinbase Trade History'));
console.log();
if (result.trades.length === 0) {
console.log(colors.muted('No trades found.'));
console.log();
return;
}
for (const trade of result.trades) {
const sideColor = trade.side === 'buy' ? colors.positive : colors.negative;
const sideLabel = sideColor(trade.side.toUpperCase().padEnd(4));
console.log(`${sideLabel} ${trade.symbol}`);
console.log(` ${formatTokenAmount(trade.quantity)} @ ${formatUsd(trade.price)} = ${formatUsd(trade.total)}`);
console.log(colors.muted(` ${formatRelativeTime(trade.timestamp)}`));
console.log();
}
if (result.nextCursor) {
console.log(colors.muted(`Use --cursor "${result.nextCursor}" to see more trades`));
console.log();
}
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { getConfigPath, saveConfig } from '../lib/config.js';
import { validateCredentials } from '../lib/credentials.js';
export function registerConfigCommand(program: Command, ctx: CliContext): void {
const configCmd = program.command('config').description('View and manage configuration');
// Default action: show config
configCmd
.command('show', { isDefault: true })
.description('Show current configuration')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { json?: boolean }) => {
const config = ctx.config;
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
const colors = ctx.colors;
const output = ctx.getOutput();
if (cmdOpts.json || output.json) {
// Don't expose secrets in JSON output
const safeConfig = {
timeoutMs: config.timeoutMs,
credentials: {
debank: validation.hasDebank,
helius: validation.hasHelius,
coinbase: validation.hasCoinbase,
binance: validation.hasBinance,
coingecko: validation.hasCoinGecko,
coinmarketcap: validation.hasCoinMarketCap,
},
};
ctx.outputJson(safeConfig);
return;
}
console.log();
console.log(colors.section('Configuration'));
console.log();
// Config paths
console.log(colors.accent('Config Files:'));
console.log(` Global: ${getConfigPath({ global: true })}`);
console.log(` Local: ${getConfigPath({ global: false })}`);
console.log();
// API Credentials Status
console.log(colors.accent('API Credentials:'));
const credStatus = (name: string, configured: boolean, source?: string): string => {
const status = configured ? colors.positive('\u2713 configured') : colors.muted('not configured');
const srcLabel = source ? colors.muted(` (${source})`) : '';
return ` ${name.padEnd(15)} ${status}${srcLabel}`;
};
console.log(
credStatus(
'DeBank',
validation.hasDebank,
creds.debankApiKey ? (config.debankApiKey ? 'config' : 'env') : undefined,
),
);
console.log(
credStatus(
'Helius',
validation.hasHelius,
creds.heliusApiKey ? (config.heliusApiKey ? 'config' : 'env') : undefined,
),
);
console.log(
credStatus(
'Coinbase',
validation.hasCoinbase,
creds.coinbaseApiKeyId ? (config.coinbaseApiKeyId ? 'config' : 'env') : undefined,
),
);
console.log(
credStatus(
'Binance',
validation.hasBinance,
creds.binanceApiKey ? (config.binanceApiKey ? 'config' : 'env') : undefined,
),
);
console.log(
credStatus(
'CoinGecko',
validation.hasCoinGecko,
creds.coingeckoApiKey ? (config.coingeckoApiKey ? 'config' : 'env') : undefined,
),
);
console.log(
credStatus(
'CoinMarketCap',
validation.hasCoinMarketCap,
creds.coinmarketcapApiKey ? (config.coinmarketcapApiKey ? 'config' : 'env') : undefined,
),
);
console.log();
// Settings
console.log(colors.accent('Settings:'));
console.log(` Timeout: ${config.timeoutMs ?? 30000}ms`);
console.log();
});
configCmd
.command('set')
.description('Set a configuration value')
.argument('<key>', 'Configuration key (e.g., timeoutMs)')
.argument('<value>', 'Value to set')
.option('--global', 'Save to global config')
.action(async (key: string, value: string, cmdOpts: { global?: boolean }) => {
const colors = ctx.colors;
const allowedKeys = ['timeoutMs'];
if (!allowedKeys.includes(key)) {
console.error(`${ctx.p('err')}Invalid key "${key}". Allowed keys: ${allowedKeys.join(', ')}`);
console.error(colors.muted('Use `onchain setup` to configure API keys.'));
process.exit(1);
}
const updates: Record<string, unknown> = {};
if (key === 'timeoutMs') {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error(`${ctx.p('err')}Invalid timeout value. Expected positive integer.`);
process.exit(1);
}
updates.timeoutMs = parsed;
} else {
updates[key] = value;
}
saveConfig(updates, { global: cmdOpts.global });
const configPath = getConfigPath({ global: cmdOpts.global });
console.log(`${ctx.p('ok')}Set ${key}=${value} in ${configPath}`);
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
import type { EtherscanChain } from '../lib/onchain-client-types.js';
// Only chains that support Etherscan's gas oracle API
const SUPPORTED_CHAINS: EtherscanChain[] = ['ethereum', 'polygon'];
const CHAIN_DISPLAY_NAMES: Record<EtherscanChain, string> = {
ethereum: 'Ethereum',
polygon: 'Polygon',
bsc: 'BNB Chain',
arbitrum: 'Arbitrum',
base: 'Base',
optimism: 'Optimism',
avalanche: 'Avalanche',
fantom: 'Fantom',
};
export function registerGasCommand(program: Command, ctx: CliContext): void {
program
.command('gas')
.description('Get current gas prices for EVM chains')
.option('-c, --chain <chain>', `Chain to check (${SUPPORTED_CHAINS.join(', ')})`, 'ethereum')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { chain: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const chain = cmdOpts.chain.toLowerCase() as EtherscanChain;
if (!SUPPORTED_CHAINS.includes(chain)) {
console.error(`${ctx.p('err')}Invalid chain: ${cmdOpts.chain}`);
console.error(`Supported chains: ${SUPPORTED_CHAINS.join(', ')}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const result = await client.getGasEstimate(chain);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
if (cmdOpts.json || output.json) {
ctx.outputJson(result.gas);
return;
}
const g = result.gas;
const colors = ctx.colors;
const chainName = CHAIN_DISPLAY_NAMES[chain];
console.log();
console.log(colors.section(`Gas Prices - ${chainName}`));
console.log();
// Gas prices in gwei
console.log(`Slow: ${colors.muted(g.safeGasPrice.toString())} gwei`);
console.log(`Standard: ${g.proposeGasPrice} gwei`);
console.log(`Fast: ${colors.positive(g.fastGasPrice.toString())} gwei`);
if (g.suggestBaseFee !== undefined) {
console.log();
console.log(`Base Fee: ${g.suggestBaseFee.toFixed(2)} gwei`);
}
console.log();
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { getMissingCredentialsMessage, validateCredentials } from '../lib/credentials.js';
import { OnchainClient } from '../lib/onchain-client.js';
import type { EvmChain, Transaction } from '../lib/onchain-client-types.js';
import { getExplorerUrl, hyperlink } from '../lib/output.js';
import { isEvmAddress, isSolanaAddress, truncateAddress } from '../lib/utils/address.js';
import { formatChainName, formatRelativeTime, formatTokenAmount, formatUsd } from '../lib/utils/formatters.js';
export function registerHistoryCommand(program: Command, ctx: CliContext): void {
program
.command('history')
.description('Get wallet transaction history')
.argument('<address>', 'Wallet address (EVM 0x... or Solana)')
.option('--chain <chain>', 'Filter by specific chain (for EVM wallets)')
.option('-n, --limit <number>', 'Number of transactions to fetch', '20')
.option('--cursor <cursor>', 'Pagination cursor')
.option('--json', 'Output as JSON')
.action(async (address: string, cmdOpts: { chain?: string; limit?: string; cursor?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Detect chain type from address format
let chainType: 'evm' | 'solana';
if (isEvmAddress(address)) {
chainType = 'evm';
} else if (isSolanaAddress(address)) {
chainType = 'solana';
} else {
console.error(`${ctx.p('err')}Invalid address format. Expected EVM (0x...) or Solana address.`);
process.exit(1);
}
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
const hasChainApi = chainType === 'evm' ? validation.hasDebank : validation.hasHelius;
if (!validation.hasZerion && !hasChainApi) {
const requiredCreds = chainType === 'evm' ? ['zerion', 'debank'] : ['zerion', 'helius'];
console.error(
`${ctx.p('err')}${getMissingCredentialsMessage(`${chainType === 'evm' ? 'EVM' : 'Solana'} wallet history`, requiredCreds)}`,
);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const output = ctx.getOutput();
const colors = ctx.colors;
const limit = Number.parseInt(cmdOpts.limit ?? '20', 10);
let transactions: Transaction[] = [];
let nextCursor: string | undefined;
let source: 'zerion' | 'api' = 'api';
// Try Zerion first (unified provider)
if (validation.hasZerion) {
const result = await client.getZerionHistory(address, {
limit,
cursor: cmdOpts.cursor,
});
if (result.success) {
transactions = result.transactions;
nextCursor = result.nextCursor;
source = 'zerion';
} else if (hasChainApi) {
console.error(colors.muted(`Zerion failed: ${result.error}, falling back to API...`));
} else {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
}
// Fall back to DeBank/Helius
if (source === 'api') {
if (chainType === 'evm') {
const result = await client.getEvmHistory(address, {
chain: cmdOpts.chain as EvmChain | undefined,
limit,
cursor: cmdOpts.cursor,
});
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
transactions = result.transactions;
nextCursor = result.nextCursor;
} else if (chainType === 'solana') {
const result = await client.getSolanaHistory(address, {
limit,
cursor: cmdOpts.cursor,
});
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
transactions = result.transactions;
nextCursor = result.nextCursor;
}
}
if (cmdOpts.json || output.json) {
ctx.outputJson({
address,
chainType,
transactions,
nextCursor,
});
return;
}
console.log();
console.log(colors.section(`Transaction History`));
console.log(colors.muted(`${address}`));
if (source === 'zerion') {
console.log(colors.muted('(via Zerion API)'));
}
console.log();
if (transactions.length === 0) {
console.log(colors.muted('No transactions found.'));
console.log();
return;
}
for (const tx of transactions) {
// Type and status
const typeLabel = tx.type.toUpperCase().padEnd(8);
const statusColor =
tx.status === 'success' ? colors.positive : tx.status === 'failed' ? colors.negative : colors.muted;
const statusIcon = tx.status === 'success' ? '\u2713' : tx.status === 'failed' ? '\u2717' : '\u2026';
console.log(
`${statusColor(statusIcon)} ${colors.accent(typeLabel)} ${colors.muted(formatRelativeTime(tx.timestamp))}`,
);
// Chain and hash
const explorerUrl = getExplorerUrl(tx.chain, 'tx', tx.hash);
const hashDisplay = truncateAddress(tx.hash, 10, 6);
console.log(` ${formatChainName(tx.chain)} ${hyperlink(explorerUrl, hashDisplay, output)}`);
// Token transfers
if (tx.tokens && tx.tokens.length > 0) {
for (const token of tx.tokens) {
const directionSymbol = token.direction === 'in' ? colors.positive('+') : colors.negative('-');
const amountStr = formatTokenAmount(token.amount);
const valueStr = token.valueUsd ? ` (${formatUsd(token.valueUsd)})` : '';
console.log(` ${directionSymbol} ${amountStr} ${token.symbol}${colors.muted(valueStr)}`);
}
}
// Fee
if (tx.fee) {
const feeValue = tx.fee.valueUsd ? ` (${formatUsd(tx.fee.valueUsd)})` : '';
console.log(colors.muted(` Fee: ${formatTokenAmount(tx.fee.amount)} ${tx.fee.symbol}${feeValue}`));
}
console.log();
}
if (nextCursor) {
console.log(colors.muted(`Use --cursor "${nextCursor}" to see more transactions`));
console.log();
}
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
import { isNotConfiguredError, isRateLimitedError } from '../lib/utils/fallback.js';
import { formatCompactUsd, formatPercent } from '../lib/utils/formatters.js';
export function registerMarketsCommand(program: Command, ctx: CliContext): void {
program
.command('markets')
.description('Get crypto market overview')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
let result = await client.getMarketOverview();
if (!result.success && isRateLimitedError(result.error)) {
const cmcResult = await client.cmcGetMarketOverview();
if (cmcResult.success) {
console.error(`${ctx.p('warn')}CoinGecko rate-limited; using CoinMarketCap fallback`);
result = cmcResult;
} else if (!isNotConfiguredError(cmcResult.error)) {
result = { success: false, error: `${result.error} | CMC fallback: ${cmcResult.error}` };
}
}
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
if (cmdOpts.json || output.json) {
ctx.outputJson(result.market);
return;
}
const m = result.market;
const colors = ctx.colors;
console.log();
console.log(colors.section('Crypto Market Overview'));
console.log();
console.log(`Total Market Cap: ${formatCompactUsd(m.totalMarketCap)}`);
if (m.marketCapChange24h !== undefined) {
const changeStr = formatPercent(m.marketCapChange24h, { showSign: true });
const coloredChange = m.marketCapChange24h >= 0 ? colors.positive(changeStr) : colors.negative(changeStr);
console.log(`24h Change: ${coloredChange}`);
}
console.log(`24h Volume: ${formatCompactUsd(m.totalVolume24h)}`);
console.log();
console.log(`BTC Dominance: ${formatPercent(m.btcDominance)}`);
if (m.ethDominance !== undefined) {
console.log(`ETH Dominance: ${formatPercent(m.ethDominance)}`);
}
// Trending tokens
if (m.trending && m.trending.length > 0) {
console.log();
console.log(colors.section('Trending'));
console.log();
for (const token of m.trending) {
const rank = token.marketCapRank ? `#${token.marketCapRank}` : '';
const change =
token.priceChange24h !== undefined
? token.priceChange24h >= 0
? colors.positive(formatPercent(token.priceChange24h, { showSign: true }))
: colors.negative(formatPercent(token.priceChange24h, { showSign: true }))
: '';
console.log(` ${token.symbol.padEnd(8)} ${token.name.padEnd(20)} ${rank.padStart(5)} ${change}`);
}
}
console.log();
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
import type { SupportedChain } from '../lib/onchain-client-types.js';
import { formatCompactUsd, formatPercent, formatUsd } from '../lib/utils/formatters.js';
export function registerNansenCommands(program: Command, ctx: CliContext): void {
const nansen = program.command('nansen').description('Nansen analytics commands (requires nansen CLI)');
// Labels command
nansen
.command('labels')
.description('Get wallet labels and entity information')
.argument('<address>', 'Wallet address')
.option('--chain <chain>', 'Blockchain (ethereum, solana, base, etc.)', 'ethereum')
.option('--json', 'Output as JSON')
.action(async (address: string, cmdOpts: { chain?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
if (!client.isNansenAvailable()) {
console.error(`${ctx.p('err')}Nansen CLI not installed. Install it with: npm install -g nansen`);
process.exit(1);
}
const chain = (cmdOpts.chain ?? 'ethereum') as SupportedChain;
const result = await client.getNansenLabels(address, chain);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson({
address,
chain,
entity: result.entity,
labels: result.labels,
});
return;
}
console.log();
console.log(colors.section('Wallet Labels'));
console.log(colors.muted(address));
console.log();
if (result.entity) {
console.log(`${colors.accent('Entity:')} ${result.entity}`);
console.log();
}
if (result.labels.length === 0) {
console.log(colors.muted('No labels found for this address.'));
console.log();
return;
}
// Group labels by category
const byCategory = new Map<string, typeof result.labels>();
for (const label of result.labels) {
const category = label.category || 'other';
const categoryLabels = byCategory.get(category) ?? [];
categoryLabels.push(label);
byCategory.set(category, categoryLabels);
}
for (const [category, labels] of byCategory) {
console.log(colors.muted(`[${category}]`));
for (const label of labels) {
const smartMoneyBadge = label.isSmartMoney ? colors.accent(' [SM]') : '';
console.log(` ${label.label}${smartMoneyBadge}`);
if (label.definition) {
console.log(` ${colors.muted(label.definition)}`);
}
}
console.log();
}
});
// Smart Money Holdings command
nansen
.command('smart-money')
.description('Get Smart Money token holdings')
.option('--chain <chain>', 'Blockchain (ethereum, solana, base, etc.)', 'solana')
.option('-n, --limit <number>', 'Number of tokens to show', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { chain?: string; limit?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
if (!client.isNansenAvailable()) {
console.error(`${ctx.p('err')}Nansen CLI not installed. Install it with: npm install -g nansen`);
process.exit(1);
}
const chain = (cmdOpts.chain ?? 'solana') as SupportedChain;
const limit = Number.parseInt(cmdOpts.limit ?? '20', 10);
const result = await client.getNansenSmartMoneyHoldings(chain, { limit });
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson({
chain,
holdings: result.holdings,
});
return;
}
console.log();
console.log(colors.section(`Smart Money Holdings (${chain})`));
console.log();
if (result.holdings.length === 0) {
console.log(colors.muted('No Smart Money holdings found.'));
console.log();
return;
}
// Header
console.log(
`${'Symbol'.padEnd(12)} ${'Value'.padStart(14)} ${'24h'.padStart(10)} ${'Holders'.padStart(8)} ${'MCap'.padStart(12)} ${'Age'.padStart(6)}`,
);
console.log('-'.repeat(70));
for (const holding of result.holdings) {
const symbol = holding.symbol.slice(0, 10).padEnd(12);
const value = formatCompactUsd(holding.valueUsd).padStart(14);
const change = formatPercent(holding.change24hPercent / 100).padStart(10);
const holders = String(holding.holdersCount).padStart(8);
const mcap = formatCompactUsd(holding.marketCapUsd).padStart(12);
const age = `${holding.tokenAgeDays}d`.padStart(6);
const changeColor = holding.change24hPercent >= 0 ? colors.positive : colors.negative;
console.log(`${symbol} ${value} ${changeColor(change)} ${holders} ${colors.muted(mcap)} ${colors.muted(age)}`);
}
console.log();
});
// Token Screener command
nansen
.command('screener')
.description('Token screener with volume and flow data')
.option('--chain <chain>', 'Blockchain (ethereum, solana, base, etc.)', 'solana')
.option('-n, --limit <number>', 'Number of tokens to show', '20')
.option('--timeframe <tf>', 'Timeframe (1h, 6h, 24h)', '24h')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { chain?: string; limit?: string; timeframe?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
if (!client.isNansenAvailable()) {
console.error(`${ctx.p('err')}Nansen CLI not installed. Install it with: npm install -g nansen`);
process.exit(1);
}
const chain = (cmdOpts.chain ?? 'solana') as SupportedChain;
const limit = Number.parseInt(cmdOpts.limit ?? '20', 10);
const timeframe = cmdOpts.timeframe ?? '24h';
const result = await client.getNansenTokenScreener(chain, { limit, timeframe });
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson({
chain,
timeframe,
tokens: result.tokens,
});
return;
}
console.log();
console.log(colors.section(`Token Screener (${chain} - ${timeframe})`));
console.log();
if (result.tokens.length === 0) {
console.log(colors.muted('No tokens found.'));
console.log();
return;
}
// Header
console.log(
`${'Symbol'.padEnd(10)} ${'Price'.padStart(12)} ${'Chg%'.padStart(8)} ${'Volume'.padStart(12)} ${'Netflow'.padStart(12)} ${'MCap'.padStart(12)}`,
);
console.log('-'.repeat(72));
for (const token of result.tokens) {
const symbol = token.symbol.slice(0, 8).padEnd(10);
const price = formatUsd(token.priceUsd).padStart(12);
const change = formatPercent(token.priceChangePercent / 100).padStart(8);
const volume = formatCompactUsd(token.volume).padStart(12);
const netflow = formatCompactUsd(token.netflow).padStart(12);
const mcap = formatCompactUsd(token.marketCapUsd).padStart(12);
const changeColor = token.priceChangePercent >= 0 ? colors.positive : colors.negative;
const netflowColor = token.netflow >= 0 ? colors.positive : colors.negative;
console.log(
`${symbol} ${price} ${changeColor(change)} ${volume} ${netflowColor(netflow)} ${colors.muted(mcap)}`,
);
}
console.log();
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
import type { PolymarketFilterOptions } from '../lib/onchain-client-types.js';
import { formatCompactUsd, formatPercent, formatTimestamp } from '../lib/utils/formatters.js';
function parseTagList(tags: string | undefined): string[] | undefined {
if (!tags) {
return undefined;
}
return tags.split(',').map((t) => t.trim().toLowerCase());
}
export function registerPolymarketCommands(program: Command, ctx: CliContext): void {
const polymarket = program.command('polymarket').description('Polymarket prediction market commands');
// Tags command
polymarket
.command('tags')
.description('List available Polymarket categories/tags')
.option('--popular', 'Show popular tags sorted by market count')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { popular?: boolean; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
const result = await client.getPolymarketTags({ popular: cmdOpts.popular });
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson(result.tags);
return;
}
console.log();
console.log(colors.section(cmdOpts.popular ? 'Popular Polymarket Tags' : 'Available Polymarket Tags'));
console.log();
if (result.tags.length === 0) {
console.log(colors.muted('No tags found.'));
console.log();
return;
}
for (const tag of result.tags) {
if (cmdOpts.popular && tag.eventCount !== undefined) {
console.log(
`${colors.accent(tag.label.padEnd(25))} ${colors.muted(`(${tag.eventCount} markets)`)} ${colors.muted(`slug: ${tag.slug}`)}`,
);
} else {
console.log(`${colors.accent(tag.label.padEnd(25))} ${colors.muted(`slug: ${tag.slug}`)}`);
}
}
console.log();
console.log(
colors.muted('Use --exclude <tags> or --include <tags> with trending/search to filter by these tags.'),
);
console.log(colors.muted('Configure defaults in ~/.config/onchain/config.json5 under polymarket.excludeTags'));
console.log();
});
polymarket
.command('trending')
.description('Get trending prediction markets')
.option('-n, --limit <number>', 'Number of markets to show', '10')
.option('--exclude <tags>', 'Exclude tags (comma-separated, e.g., sports,nfl)')
.option('--include <tags>', 'Only include tags (comma-separated)')
.option('--all', 'Ignore config excludes, show all markets')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { limit?: string; exclude?: string; include?: string; all?: boolean; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
const limit = Number.parseInt(cmdOpts.limit ?? '10', 10);
// Build filter options
const filterOptions: PolymarketFilterOptions = { limit };
if (!cmdOpts.all) {
// Apply config defaults first
const configPrefs = ctx.config.polymarket;
if (configPrefs?.excludeTags && configPrefs.excludeTags.length > 0) {
filterOptions.excludeTags = [...configPrefs.excludeTags];
}
if (configPrefs?.includeTags && configPrefs.includeTags.length > 0) {
filterOptions.includeTags = [...configPrefs.includeTags];
}
// Command-line options override config
const cliExclude = parseTagList(cmdOpts.exclude);
if (cliExclude) {
filterOptions.excludeTags = cliExclude;
}
const cliInclude = parseTagList(cmdOpts.include);
if (cliInclude) {
filterOptions.includeTags = cliInclude;
}
}
const result = await client.getPolymarketTrending(filterOptions);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson(result.markets);
return;
}
console.log();
const hasFilters = filterOptions.excludeTags?.length || filterOptions.includeTags?.length;
const title = hasFilters ? 'Trending Prediction Markets (filtered)' : 'Trending Prediction Markets';
console.log(colors.section(title));
if (hasFilters && !cmdOpts.all) {
const filterDesc: string[] = [];
if (filterOptions.excludeTags?.length) {
filterDesc.push(`excluding: ${filterOptions.excludeTags.join(', ')}`);
}
if (filterOptions.includeTags?.length) {
filterDesc.push(`including: ${filterOptions.includeTags.join(', ')}`);
}
console.log(colors.muted(` ${filterDesc.join(' | ')}`));
}
console.log();
if (result.markets.length === 0) {
console.log(colors.muted('No markets found.'));
console.log();
return;
}
for (const market of result.markets) {
console.log(colors.accent(market.question));
// Outcomes with prices
const outcomeStr = market.outcomes
.map((o) => {
const pct = formatPercent(o.price * 100);
return `${o.name}: ${pct}`;
})
.join(' | ');
console.log(` ${outcomeStr}`);
// Stats
const stats: string[] = [];
if (market.volume > 0) {
stats.push(`Vol: ${formatCompactUsd(market.volume)}`);
}
if (market.liquidity > 0) {
stats.push(`Liq: ${formatCompactUsd(market.liquidity)}`);
}
if (market.category) {
stats.push(market.category);
}
if (stats.length > 0) {
console.log(colors.muted(` ${stats.join(' | ')}`));
}
// End date
if (market.endDate) {
const endTimestamp = new Date(market.endDate).getTime() / 1000;
console.log(colors.muted(` Ends: ${formatTimestamp(endTimestamp)}`));
}
console.log();
}
});
polymarket
.command('search')
.description('Search prediction markets')
.argument('<query>', 'Search query')
.option('-n, --limit <number>', 'Number of markets to show', '10')
.option('--exclude <tags>', 'Exclude tags (comma-separated)')
.option('--include <tags>', 'Only include tags (comma-separated)')
.option('--all', 'Ignore config excludes')
.option('--json', 'Output as JSON')
.action(
async (
query: string,
cmdOpts: { limit?: string; exclude?: string; include?: string; all?: boolean; json?: boolean },
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
const limit = Number.parseInt(cmdOpts.limit ?? '10', 10);
// Build filter options
const filterOptions: PolymarketFilterOptions = { limit };
if (!cmdOpts.all) {
// Apply config defaults first
const configPrefs = ctx.config.polymarket;
if (configPrefs?.excludeTags && configPrefs.excludeTags.length > 0) {
filterOptions.excludeTags = [...configPrefs.excludeTags];
}
if (configPrefs?.includeTags && configPrefs.includeTags.length > 0) {
filterOptions.includeTags = [...configPrefs.includeTags];
}
// Command-line options override config
const cliExclude = parseTagList(cmdOpts.exclude);
if (cliExclude) {
filterOptions.excludeTags = cliExclude;
}
const cliInclude = parseTagList(cmdOpts.include);
if (cliInclude) {
filterOptions.includeTags = cliInclude;
}
}
const result = await client.searchPolymarkets(query, filterOptions);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson(result.markets);
return;
}
console.log();
console.log(colors.section(`Markets matching "${query}"`));
console.log();
if (result.markets.length === 0) {
console.log(colors.muted('No markets found.'));
console.log();
return;
}
for (const market of result.markets) {
console.log(colors.accent(market.question));
const outcomeStr = market.outcomes
.map((o) => {
const pct = formatPercent(o.price * 100);
return `${o.name}: ${pct}`;
})
.join(' | ');
console.log(` ${outcomeStr}`);
const stats: string[] = [];
if (market.volume > 0) {
stats.push(`Vol: ${formatCompactUsd(market.volume)}`);
}
if (market.category) {
stats.push(market.category);
}
if (stats.length > 0) {
console.log(colors.muted(` ${stats.join(' | ')}`));
}
console.log();
}
},
);
// Sentiment analysis command
polymarket
.command('sentiment')
.description('Analyze Polymarket sentiment for a topic (crypto, fed, politics, etc.)')
.argument('<topic>', 'Topic to analyze (e.g., btc, eth, fed, trump)')
.option('-n, --limit <number>', 'Max signals to analyze', '10')
.option('--json', 'Output as JSON')
.action(async (topic: string, cmdOpts: { limit?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
const limit = Number.parseInt(cmdOpts.limit ?? '10', 10);
const result = await client.getPolymarketSentiment(topic, { limit });
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson(result.sentiment);
return;
}
const sentiment = result.sentiment;
console.log();
console.log(colors.section(`Polymarket Sentiment: ${topic.toUpperCase()}`));
console.log();
// Overall sentiment with emoji indicator
const sentimentEmoji =
sentiment.overallSentiment === 'bullish'
? '📈'
: sentiment.overallSentiment === 'bearish'
? '📉'
: sentiment.overallSentiment === 'mixed'
? '↔️'
: '➖';
const sentimentColor =
sentiment.overallSentiment === 'bullish'
? colors.accent
: sentiment.overallSentiment === 'bearish'
? (s: string) => s // no color for bearish, could add red if available
: colors.muted;
console.log(
`${sentimentEmoji} Overall: ${sentimentColor(sentiment.overallSentiment.toUpperCase())} (score: ${sentiment.sentimentScore > 0 ? '+' : ''}${sentiment.sentimentScore})`,
);
console.log(` Confidence: ${sentiment.confidence}% | Signals: ${sentiment.signals.length}`);
console.log();
// Summary
console.log(colors.muted(sentiment.summary));
console.log();
// Top signals
console.log(colors.accent('Key Signals:'));
for (const signal of sentiment.signals.slice(0, 5)) {
const signalEmoji = signal.sentiment === 'bullish' ? '🟢' : signal.sentiment === 'bearish' ? '🔴' : '⚪';
const prob = formatPercent(signal.probability * 100);
console.log(` ${signalEmoji} ${signal.question}`);
console.log(colors.muted(` ${prob} probability | Vol: ${formatCompactUsd(signal.volume)}`));
}
if (sentiment.signals.length > 5) {
console.log(colors.muted(` ... and ${sentiment.signals.length - 5} more signals`));
}
console.log();
});
polymarket
.command('view')
.description('View a specific market')
.argument('<market>', 'Market ID or slug')
.option('--json', 'Output as JSON')
.action(async (marketId: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
const result = await client.getPolymarketMarket(marketId);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
const colors = ctx.colors;
if (cmdOpts.json || output.json) {
ctx.outputJson(result.market);
return;
}
const market = result.market;
console.log();
console.log(colors.section(market.question));
console.log();
if (market.description) {
console.log(market.description);
console.log();
}
// Outcomes
console.log(colors.accent('Outcomes:'));
for (const outcome of market.outcomes) {
const pct = formatPercent(outcome.price * 100);
const bar = '\u2588'.repeat(Math.floor(outcome.price * 20));
console.log(` ${outcome.name.padEnd(20)} ${pct.padStart(8)} ${colors.muted(bar)}`);
}
console.log();
// Stats
if (market.volume > 0) {
console.log(`Volume: ${formatCompactUsd(market.volume)}`);
}
if (market.liquidity > 0) {
console.log(`Liquidity: ${formatCompactUsd(market.liquidity)}`);
}
if (market.category) {
console.log(`Category: ${market.category}`);
}
if (market.endDate) {
const endTimestamp = new Date(market.endDate).getTime() / 1000;
console.log(`Ends: ${formatTimestamp(endTimestamp)}`);
}
if (market.slug) {
console.log(`URL: https://polymarket.com/event/${market.slug}`);
}
console.log();
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { getMissingCredentialsMessage, validateCredentials } from '../lib/credentials.js';
import { OnchainClient } from '../lib/onchain-client.js';
import { isEvmAddress, isSolanaAddress } from '../lib/utils/address.js';
import { formatCompactUsd, formatPercent, formatUsd } from '../lib/utils/formatters.js';
export function registerPortfolioCommand(program: Command, ctx: CliContext): void {
program
.command('portfolio')
.description('Get full portfolio overview including DeFi positions')
.argument('<address>', 'Wallet address (EVM 0x... or Solana)')
.option('--json', 'Output as JSON')
.action(async (address: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
// Detect chain type from address format
let chainType: 'evm' | 'solana';
if (isEvmAddress(address)) {
chainType = 'evm';
} else if (isSolanaAddress(address)) {
chainType = 'solana';
} else {
console.error(`${ctx.p('err')}Invalid address format. Expected EVM (0x...) or Solana address.`);
process.exit(1);
}
// Validate credentials
const creds = ctx.resolveCredentials();
const validation = validateCredentials(creds);
const hasChainApi = chainType === 'evm' ? validation.hasDebank : validation.hasHelius;
if (!validation.hasZerion && !hasChainApi) {
const requiredCreds = chainType === 'evm' ? ['zerion', 'debank'] : ['zerion', 'helius'];
console.error(
`${ctx.p('err')}${getMissingCredentialsMessage(`${chainType === 'evm' ? 'EVM' : 'Solana'} portfolio data`, requiredCreds)}`,
);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const output = ctx.getOutput();
const colors = ctx.colors;
interface PortfolioData {
address: string;
chainType: string;
tokens: {
totalValueUsd: number;
count: number;
topHoldings: Array<{ symbol: string; valueUsd: number | null }>;
};
defi?: {
totalValueUsd: number;
positionCount: number;
protocols: Array<{ name: string; valueUsd: number | null }>;
};
nfts?: {
count: number;
estimatedValueUsd?: number;
};
totalValueUsd: number;
change24h?: {
absolute: number;
relative: number;
};
}
const portfolioData: PortfolioData = {
address,
chainType,
tokens: { totalValueUsd: 0, count: 0, topHoldings: [] },
totalValueUsd: 0,
};
let usedZerion = false;
// Try Zerion first (unified provider for any chain)
if (validation.hasZerion) {
const balanceResult = await client.getZerionBalances(address);
if (balanceResult.success) {
usedZerion = true;
portfolioData.tokens = {
totalValueUsd: balanceResult.totalValueUsd,
count: balanceResult.balances.length,
topHoldings: balanceResult.balances.slice(0, 5).map((b) => ({
symbol: b.symbol,
valueUsd: b.valueUsd,
})),
};
portfolioData.totalValueUsd += balanceResult.totalValueUsd;
// Fetch portfolio change data from Zerion
const portfolioResult = await client.getZerionPortfolio(address);
if (portfolioResult.success) {
portfolioData.change24h = {
absolute: portfolioResult.portfolio.absoluteChange24h,
relative: portfolioResult.portfolio.relativeChange24h,
};
}
} else if (hasChainApi) {
console.error(colors.muted(`Zerion failed: ${balanceResult.error}, falling back to API...`));
} else {
console.error(`${ctx.p('err')}${balanceResult.error}`);
process.exit(1);
}
}
// Fall back to DeBank/Helius if Zerion not used
if (!usedZerion) {
if (chainType === 'evm') {
// Fetch token balances
const balanceResult = await client.getEvmBalances(address);
if (balanceResult.success) {
portfolioData.tokens = {
totalValueUsd: balanceResult.totalValueUsd,
count: balanceResult.balances.length,
topHoldings: balanceResult.balances.slice(0, 5).map((b) => ({
symbol: b.symbol,
valueUsd: b.valueUsd,
})),
};
portfolioData.totalValueUsd += balanceResult.totalValueUsd;
}
// Fetch DeFi positions
const defiResult = await client.getEvmDefiPositions(address);
if (defiResult.success) {
// Group by protocol
const protocolTotals = new Map<string, number>();
for (const pos of defiResult.positions) {
const current = protocolTotals.get(pos.protocol) ?? 0;
protocolTotals.set(pos.protocol, current + (pos.totalValueUsd ?? 0));
}
portfolioData.defi = {
totalValueUsd: defiResult.totalValueUsd,
positionCount: defiResult.positions.length,
protocols: Array.from(protocolTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, valueUsd]) => ({ name, valueUsd })),
};
portfolioData.totalValueUsd += defiResult.totalValueUsd;
}
// Fetch NFTs
const nftResult = await client.getEvmNfts(address);
if (nftResult.success) {
portfolioData.nfts = {
count: nftResult.nfts.length,
estimatedValueUsd: nftResult.totalEstimatedValueUsd,
};
}
} else if (chainType === 'solana') {
// Fetch token balances
const balanceResult = await client.getSolanaBalances(address);
if (balanceResult.success) {
portfolioData.tokens = {
totalValueUsd: balanceResult.totalValueUsd,
count: balanceResult.balances.length,
topHoldings: balanceResult.balances.slice(0, 5).map((b) => ({
symbol: b.symbol,
valueUsd: b.valueUsd,
})),
};
portfolioData.totalValueUsd += balanceResult.totalValueUsd;
}
// Fetch NFTs
const nftResult = await client.getSolanaNfts(address);
if (nftResult.success) {
portfolioData.nfts = {
count: nftResult.nfts.length,
};
}
}
}
if (cmdOpts.json || output.json) {
ctx.outputJson(portfolioData);
return;
}
console.log();
console.log(colors.section('Portfolio Overview'));
console.log(colors.muted(address));
if (usedZerion) {
console.log(colors.muted('(via Zerion API)'));
}
console.log();
// Total value
let totalLine = `Total Value: ${formatUsd(portfolioData.totalValueUsd)}`;
if (portfolioData.change24h) {
const sign = portfolioData.change24h.absolute >= 0 ? '+' : '';
const changeColor = portfolioData.change24h.absolute >= 0 ? colors.positive : colors.negative;
totalLine += ` ${changeColor(`${sign}${formatUsd(portfolioData.change24h.absolute)} (${formatPercent(portfolioData.change24h.relative)}) 24h`)}`;
}
console.log(colors.section(totalLine));
console.log();
// Token holdings
console.log(colors.accent('Tokens'));
console.log(` ${portfolioData.tokens.count} tokens worth ${formatUsd(portfolioData.tokens.totalValueUsd)}`);
if (portfolioData.tokens.topHoldings.length > 0) {
console.log();
for (const holding of portfolioData.tokens.topHoldings) {
const valueStr = holding.valueUsd !== null ? formatUsd(holding.valueUsd) : '-';
const pctStr =
holding.valueUsd !== null && portfolioData.totalValueUsd > 0
? formatPercent((holding.valueUsd / portfolioData.totalValueUsd) * 100)
: '';
console.log(` ${holding.symbol.padEnd(10)} ${valueStr.padStart(14)} ${colors.muted(pctStr)}`);
}
}
console.log();
// DeFi positions (EVM only)
if (portfolioData.defi) {
console.log(colors.accent('DeFi Positions'));
console.log(
` ${portfolioData.defi.positionCount} positions worth ${formatUsd(portfolioData.defi.totalValueUsd)}`,
);
if (portfolioData.defi.protocols.length > 0) {
console.log();
for (const protocol of portfolioData.defi.protocols) {
const valueStr = protocol.valueUsd !== null ? formatUsd(protocol.valueUsd) : '-';
console.log(` ${protocol.name.padEnd(20)} ${valueStr.padStart(14)}`);
}
}
console.log();
}
// NFTs
if (portfolioData.nfts && portfolioData.nfts.count > 0) {
console.log(colors.accent('NFTs'));
let nftLine = ` ${portfolioData.nfts.count} NFTs`;
if (portfolioData.nfts.estimatedValueUsd) {
nftLine += ` (est. ${formatCompactUsd(portfolioData.nfts.estimatedValueUsd)})`;
}
console.log(nftLine);
console.log();
}
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
import { isNotConfiguredError, isRateLimitedError } from '../lib/utils/fallback.js';
import { formatCompactUsd, formatPercent, formatUsd } from '../lib/utils/formatters.js';
export function registerPriceCommand(program: Command, ctx: CliContext): void {
program
.command('price')
.description('Get token price and market data')
.argument('<token>', 'Token symbol or CoinGecko ID (e.g., btc, eth, bitcoin, solana)')
.option('--json', 'Output as JSON')
.action(async (token: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const client = new OnchainClient(clientOpts);
let result = await client.getTokenPrice(token);
if (!result.success && isRateLimitedError(result.error)) {
const cmcResult = await client.cmcGetTokenPrice(token);
if (cmcResult.success) {
console.error(`${ctx.p('warn')}CoinGecko rate-limited; using CoinMarketCap fallback`);
result = cmcResult;
} else if (!isNotConfiguredError(cmcResult.error)) {
result = { success: false, error: `${result.error} | CMC fallback: ${cmcResult.error}` };
}
}
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
if (cmdOpts.json || output.json) {
ctx.outputJson(result.token);
return;
}
const t = result.token;
const colors = ctx.colors;
console.log();
console.log(colors.section(`${t.name} (${t.symbol})`));
console.log();
// Price and changes
console.log(`${ctx.l('price')}${formatUsd(t.priceUsd)}`);
const changeColor = (val: number | undefined): string => {
if (val === undefined) {
return '-';
}
const formatted = formatPercent(val, { showSign: true });
return val >= 0 ? colors.positive(formatted) : colors.negative(formatted);
};
if (t.priceChange24h !== undefined) {
console.log(`${ctx.l('change')}24h: ${changeColor(t.priceChange24h)}`);
}
// Additional changes in a row
const changes: string[] = [];
if (t.priceChange1h !== undefined) {
changes.push(`1h: ${changeColor(t.priceChange1h)}`);
}
if (t.priceChange7d !== undefined) {
changes.push(`7d: ${changeColor(t.priceChange7d)}`);
}
if (t.priceChange30d !== undefined) {
changes.push(`30d: ${changeColor(t.priceChange30d)}`);
}
if (changes.length > 0) {
console.log(` ${changes.join(' ')}`);
}
console.log();
// Market data
if (t.marketCapRank) {
console.log(`Rank: #${t.marketCapRank}`);
}
if (t.marketCap) {
console.log(`Market Cap: ${formatCompactUsd(t.marketCap)}`);
}
if (t.volume24h) {
console.log(`Volume 24h: ${formatCompactUsd(t.volume24h)}`);
}
// Supply info
if (t.circulatingSupply || t.totalSupply) {
console.log();
if (t.circulatingSupply) {
console.log(`Circulating: ${formatCompactUsd(t.circulatingSupply).replace('$', '')} ${t.symbol}`);
}
if (t.totalSupply) {
console.log(`Total: ${formatCompactUsd(t.totalSupply).replace('$', '')} ${t.symbol}`);
}
if (t.maxSupply) {
console.log(`Max: ${formatCompactUsd(t.maxSupply).replace('$', '')} ${t.symbol}`);
}
}
// ATH/ATL
if (t.ath || t.atl) {
console.log();
if (t.ath) {
const athChange =
t.athChangePercent !== undefined ? ` (${formatPercent(t.athChangePercent, { showSign: true })})` : '';
console.log(`ATH: ${formatUsd(t.ath)}${athChange}`);
}
if (t.atl) {
const atlChange =
t.atlChangePercent !== undefined ? ` (${formatPercent(t.atlChangePercent, { showSign: true })})` : '';
console.log(`ATL: ${formatUsd(t.atl)}${atlChange}`);
}
}
console.log();
});
}
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { OnchainClient } from '../lib/onchain-client.js';
export function registerSearchCommand(program: Command, ctx: CliContext): void {
program
.command('search')
.description('Search for tokens by name or symbol')
.argument('<query>', 'Search term (token name or symbol)')
.option('-l, --limit <n>', 'Maximum results to show', '10')
.option('--json', 'Output as JSON')
.action(async (query: string, cmdOpts: { limit: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const clientOpts = ctx.getClientOptions();
clientOpts.timeoutMs = timeoutMs;
const limit = Number.parseInt(cmdOpts.limit, 10);
if (Number.isNaN(limit) || limit < 1) {
console.error(`${ctx.p('err')}Invalid limit: ${cmdOpts.limit}`);
process.exit(1);
}
const client = new OnchainClient(clientOpts);
const result = await client.searchTokens(query, limit);
if (!result.success) {
console.error(`${ctx.p('err')}${result.error}`);
process.exit(1);
}
const output = ctx.getOutput();
if (cmdOpts.json || output.json) {
ctx.outputJson(result.tokens);
return;
}
const colors = ctx.colors;
if (result.tokens.length === 0) {
console.log(`${ctx.p('info')}No tokens found for "${query}"`);
return;
}
console.log();
console.log(colors.section(`Search Results: "${query}"`));
console.log();
// Find max widths for alignment
const maxSymbol = Math.max(...result.tokens.map((t) => t.symbol.length));
for (const token of result.tokens) {
const symbol = token.symbol.padEnd(maxSymbol);
const rank = token.marketCapRank ? colors.muted(`#${token.marketCapRank}`) : colors.muted('-');
console.log(` ${colors.accent(symbol)} ${token.name} ${rank}`);
}
console.log();
console.log(colors.muted(`Use 'onchain price <symbol>' to get detailed price info`));
console.log();
});
}
import type { Command } from 'commander';
import { runSetupWizard } from '../cli/setup-wizard.js';
import type { CliContext } from '../cli/shared.js';
import { getConfigPath, saveConfig } from '../lib/config.js';
export function registerSetupCommand(program: Command, ctx: CliContext): void {
program
.command('setup')
.description('Interactive setup wizard for configuring wallets and API keys')
.option('--global', 'Save configuration globally (~/.config/onchain/config.json5)')
.action(async (cmdOpts: { global?: boolean }) => {
const colors = ctx.colors;
try {
const configUpdates = await runSetupWizard(ctx.config, colors);
if (Object.keys(configUpdates).length === 0) {
console.log(colors.muted('No changes made.'));
return;
}
saveConfig(configUpdates, { global: cmdOpts.global });
const configPath = getConfigPath({ global: cmdOpts.global });
console.log(`${ctx.p('ok')}Configuration saved to ${configPath}`);
console.log();
// Show what was configured
const apiKeys = [
configUpdates.debankApiKey ? 'DeBank' : null,
configUpdates.heliusApiKey ? 'Helius' : null,
configUpdates.coinbaseApiKeyId ? 'Coinbase' : null,
configUpdates.binanceApiKey ? 'Binance' : null,
configUpdates.coingeckoApiKey ? 'CoinGecko' : null,
configUpdates.coinmarketcapApiKey ? 'CoinMarketCap' : null,
configUpdates.etherscanApiKey ? 'Etherscan' : null,
configUpdates.solscanApiKey ? 'Solscan' : null,
].filter(Boolean);
if (apiKeys.length > 0) {
console.log(colors.accent('API keys configured:'));
console.log(` ${apiKeys.join(', ')}`);
console.log();
}
// Show Polymarket preferences if configured
if (configUpdates.polymarket) {
console.log(colors.accent('Polymarket preferences:'));
if (configUpdates.polymarket.excludeTags?.length) {
console.log(` Exclude tags: ${configUpdates.polymarket.excludeTags.join(', ')}`);
}
if (configUpdates.polymarket.includeTags?.length) {
console.log(` Include tags: ${configUpdates.polymarket.includeTags.join(', ')}`);
}
console.log();
}
console.log(colors.muted('Run `onchain config` to view your configuration.'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ERR_USE_AFTER_CLOSE') {
// User pressed Ctrl+C
console.log();
console.log(colors.muted('Setup cancelled.'));
return;
}
throw error;
}
});
}
// Library exports for programmatic use
export { OnchainClient } from './lib/onchain-client.js';
export type {
BalanceResult,
CexBalance,
CexBalanceResult,
CexHistoryResult,
CexTrade,
ChainType,
DefiPosition,
DefiResult,
EvmChain,
HistoryResult,
MarketOverview,
MarketResult,
NftAsset,
NftResult,
OnchainClientOptions,
PolymarketDetailResult,
PolymarketMarket,
PolymarketResult,
PriceResult,
PricesResult,
SupportedChain,
TokenBalance,
TokenPrice,
Transaction,
} from './lib/onchain-client-types.js';
// Utility exports
export { detectChainType, isEvmAddress, isSolanaAddress, truncateAddress } from './lib/utils/address.js';
export {
formatChainName,
formatCompactNumber,
formatCompactUsd,
formatPercent,
formatRelativeTime,
formatTimestamp,
formatTokenAmount,
formatUsd,
} from './lib/utils/formatters.js';
const RATE_LIMIT_PATTERN = /\b429\b|rate.?limit|too.?many.?requests/i;
const NOT_CONFIGURED_PATTERN = /not configured|api key required/i;
export function isRateLimitedError(error: string): boolean {
return RATE_LIMIT_PATTERN.test(error);
}
export function isNotConfiguredError(error: string): boolean {
return NOT_CONFIGURED_PATTERN.test(error);
}
{
"extends": "./tsconfig.json",
"include": ["src/**/*", "tests/**/*"]
}