Add cg contract command for token price lookup by contract address#22
Add cg contract command for token price lookup by contract address#22
cg contract command for token price lookup by contract address#22Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new cg contract command to look up token prices by contract address, supporting CoinGecko aggregated pricing by platform and GeckoTerminal onchain DEX pricing by network, with dry-run/OAS catalog integration.
Changes:
- Introduces
cg contractcommand with flags for aggregated vs onchain mode, currency selection, and CSV export. - Extends the API client with token-price and exchange-rate endpoints plus response types.
- Refactors dry-run OAS spec/operation resolution to support per-mode overrides with fallback, and adds tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
cmd/contract.go |
Implements the new contract command (aggregated + onchain flow, output formatting, export). |
cmd/contract_test.go |
Adds command-level tests for validation, dry-run, outputs, conversion, export, and catalog metadata. |
internal/api/coins.go |
Adds API methods for simple token price, onchain token price, and exchange rates. |
internal/api/coins_test.go |
Adds unit tests for the new API client methods. |
internal/api/types.go |
Adds response types for token price, onchain token price, and exchange rates. |
cmd/dryrun.go |
Updates dry-run OAS spec/operation ID selection to support per-mode overrides with fallback. |
cmd/dryrun_test.go |
Adds regression test for partial override fallback behavior. |
cmd/commands.go |
Registers contract in the command catalog and extends annotations/catalog fields for OAS spec overrides and paid-modes metadata. |
cmd/test_helpers_test.go |
Adds captureStdout helper for dry-run tests. |
CLAUDE.md |
Documents the new command mapping and onchain currency conversion behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
cmd/contract.go
Outdated
| priceStr, ok := resp.Data.Attributes.TokenPrices[address] | ||
| if !ok { | ||
| return fmt.Errorf("no data returned for address %s", address) | ||
| } |
There was a problem hiding this comment.
In onchain mode, the response maps are keyed by contract address strings. If the API normalizes keys (commonly lowercase), using the raw user input as the lookup key can incorrectly hit the "no data returned" path for checksum/mixed-case addresses. Consider normalizing the address for request + response lookup (e.g., strings.ToLower) while still displaying the original address if desired.
cmd/contract.go
Outdated
| marketCap, _ = strconv.ParseFloat(mcStr, 64) | ||
| } | ||
| if volStr, ok := resp.Data.Attributes.H24VolumeUSD[address]; ok { | ||
| volume, _ = strconv.ParseFloat(volStr, 64) | ||
| } | ||
| if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[address]; ok { | ||
| change, _ = strconv.ParseFloat(chgStr, 64) |
There was a problem hiding this comment.
marketCap, volume, and change parsing errors are currently ignored (ParseFloat(..., 64) with _). If the API returns a non-numeric string (or an empty string) for these fields, the command will silently emit 0 values, which is misleading. Please handle these parse errors similarly to price (either return an error or at least surface a warning).
| marketCap, _ = strconv.ParseFloat(mcStr, 64) | |
| } | |
| if volStr, ok := resp.Data.Attributes.H24VolumeUSD[address]; ok { | |
| volume, _ = strconv.ParseFloat(volStr, 64) | |
| } | |
| if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[address]; ok { | |
| change, _ = strconv.ParseFloat(chgStr, 64) | |
| marketCap, err = strconv.ParseFloat(mcStr, 64) | |
| if err != nil { | |
| return fmt.Errorf("parsing market cap: %w", err) | |
| } | |
| } | |
| if volStr, ok := resp.Data.Attributes.H24VolumeUSD[address]; ok { | |
| volume, err = strconv.ParseFloat(volStr, 64) | |
| if err != nil { | |
| return fmt.Errorf("parsing 24h volume: %w", err) | |
| } | |
| } | |
| if chgStr, ok := resp.Data.Attributes.H24PriceChangePct[address]; ok { | |
| change, err = strconv.ParseFloat(chgStr, 64) | |
| if err != nil { | |
| return fmt.Errorf("parsing 24h price change: %w", err) | |
| } |
| note = fmt.Sprintf("Additional request: GET /exchange_rates (currency conversion from USD to %s)", vs) | ||
| } | ||
| return printDryRunFull(cfg, "contract", "--onchain", | ||
| fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), address), |
There was a problem hiding this comment.
The onchain dry-run endpoint embeds the raw address in the URL path without escaping. Even if addresses are typically hex, this makes the URL construction inconsistent with other path parameters (platform/network) and can break dry-run output if multiple addresses/comma-separated values are ever supported. Use url.PathEscape(address) (or escape each address before joining) when building the path segment.
| fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), address), | |
| fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s", url.PathEscape(network), url.PathEscape(address)), |
| } | ||
| var result OnchainTokenPriceResponse | ||
| err := c.get(ctx, fmt.Sprintf("/onchain/simple/networks/%s/token_price/%s?%s", | ||
| url.PathEscape(network), strings.Join(addresses, ","), params.Encode()), &result) |
There was a problem hiding this comment.
OnchainSimpleTokenPrice builds a URL path segment from strings.Join(addresses, ",") without url.PathEscape. This is inconsistent with other path params in this file and can produce invalid paths or unexpected routing if an address contains reserved characters. Consider escaping each address (or the joined string) before interpolating it into the path.
| url.PathEscape(network), strings.Join(addresses, ","), params.Encode()), &result) | |
| url.PathEscape(network), url.PathEscape(strings.Join(addresses, ",")), params.Encode()), &result) |
| func captureStdout(t *testing.T, fn func()) string { | ||
| t.Helper() | ||
| oldStdout := os.Stdout | ||
| r, w, _ := os.Pipe() | ||
| os.Stdout = w | ||
|
|
||
| fn() | ||
|
|
||
| _ = w.Close() | ||
| os.Stdout = oldStdout | ||
| var buf bytes.Buffer | ||
| _, _ = io.Copy(&buf, r) | ||
| return buf.String() | ||
| } |
There was a problem hiding this comment.
captureStdout mutates the global os.Stdout but doesn't restore it if fn() panics, and it doesn't close the read end of the pipe. Using defer to restore os.Stdout and to close both pipe ends will make the helper safer and prevent cross-test contamination/leaks on failures.
…dress
Add a new command that looks up token price by contract address using two
data sources: CoinGecko's aggregated price (default) and GeckoTerminal's
DEX price (--onchain). Both modes produce identical output columns
(address, price, market cap, 24h volume, 24h change).
- Aggregated mode: queries /simple/token_price/{platform} with --platform
- Onchain mode: queries /onchain/simple/networks/{network}/token_price/{addresses}
with --network and --onchain flags (paid plans only)
- Non-USD currency support: aggregated uses API natively, onchain converts
via /exchange_rates endpoint
- Supports --dry-run, -o json, --vs, --export CSV
- Includes validation with discovery doc hints in error messages
- 16 command tests + 6 API-layer tests covering both modes
Address three review issues with the --onchain variant: 1. Catalog metadata: add per-mode `paid_modes` and `oas_specs` maps to the command annotation and catalog output so agents/LLMs know that --onchain requires a paid plan and uses coingecko-pro.json spec. 2. Dry-run output: --onchain --dry-run now always emits a pro-tier request (pro base URL, pro auth header, coingecko-pro.json spec) regardless of the user's actual tier. Demo users get a note: "Paid plan required for --onchain". 3. Flag help: --onchain description now says "(paid plan required)" so the restriction is visible in --help and the flag catalog.
The onchain-simple-price endpoint is available on both demo and paid tiers per CoinGecko docs (demo: api.coingecko.com, pro: pro-api). The previous implementation incorrectly treated it as paid-only. Changes: - Remove requirePaid() guard from OnchainSimpleTokenPrice - Remove forced pro-tier config override in onchain dry-run - Remove "(paid plan required)" from --onchain flag description - Remove PaidModes/OASSpecs per-mode metadata from contract entry (both modes work on demo tier, single OASSpec suffices) - Replace TestOnchainSimpleTokenPrice_RequiresPaid with _DemoTier test that verifies demo clients can call the endpoint - Replace TestContract_Onchain_RequiresPaid with _DemoTier test - Update dry-run and catalog tests to reflect demo-compatible behavior
- Remove redundant displayAddr variable and inline comments in contract.go - Improve printDryRunFull per-mode override logic: set command-level defaults first, then override only when the opKey exists in the per-mode maps (proper fallback for missing keys) - Add captureStdout test helper for capturing stdout in unit tests - Add TestDryRun_PartialOverrideFallback to verify fallback behavior when a command defines per-mode maps but the requested opKey is absent
Add address-only smart routing that auto-resolves network/platform via
parallel pool search + networks list, tries CG aggregated price first,
and falls back to onchain DEX price if unavailable.
- Smart routing: /onchain/search/pools + /onchain/networks (parallel)
extracts network from token relationship ID prefix ({network}_{addr}),
maps to CG platform via coingecko_asset_platform_id
- CG-first with onchain fallback when CG returns no data
- --onchain without --network also triggers smart routing
- FDV fallback: mcap_fdv_fallback=true always sent to onchain endpoint
- Reserve/liquidity: include_total_reserve_in_usd=true, shown in onchain
output only (table Reserve column, JSON total_reserve, CSV)
- Address case normalization for API response key matching
- Dry-run shows resolution endpoint info for smart routing mode
- 12 new command tests + 3 new API tests covering all routing paths
a0e9736 to
d769899
Compare
PaidModes was added when --onchain was thought to be paid-only but the restriction was removed in e8ff189. The field was never populated for any command. Remove it from commandAnnotation, commandInfo, and the catalog builder.
Both contract modes use the same OAS spec (coingecko-demo.json), so the per-mode OASSpecs map was never populated. Remove the field from structs, the override logic from dryrun.go, and simplify the fallback test to only cover OASOperationIDs (which is actually used).
The lowercase normalization broke Solana token lookups — base58 addresses are case-sensitive, so lowercasing the key produced no match against the API response. Fix: try original case first, fall back to lowercase. This handles both Solana (case-sensitive base58) and Ethereum (API may return lowercase hex).
The API echoes back the same case we send, so original case always matches. The lowercase fallback was defending against a scenario that doesn't happen and broke Solana addresses unnecessarily.
CG aggregated path sends multiple currencies natively to the API in a single call (vs_currencies=usd,eur,sgd). Onchain path fetches USD from the API and converts to each currency via a single /exchange_rates call. Single currency output is backward compatible (flat JSON, no Currency column). Multiple currencies nest JSON by currency and add a Currency column to the table.
Summary
Adds
cg contract— a new command that looks up token price by contract address. Uses CoinGecko's aggregated price by default, or DEX price from GeckoTerminal with--onchain.Smart routing: when
--platformand--networkare omitted, the CLI auto-detects the token's network via pool search, maps it to the CG platform, tries CG aggregated price first, and falls back to onchain DEX price if unavailable.Smart Routing Flow
flowchart TD Start["cg contract --address 0x..."] --> HasPlatform{--platform?} Start --> HasOnchain{--onchain?} HasPlatform -->|yes| CG["GET /simple/token_price/{platform}"] HasOnchain -->|"yes + --network"| Onchain["GET /onchain/simple/networks/{network}/token_price/{address}"] HasPlatform -->|no| NeedsResolve HasOnchain -->|"yes, no --network"| NeedsResolve NeedsResolve["Smart Routing"] --> Parallel subgraph Parallel ["Parallel API calls"] PoolSearch["GET /onchain/search/pools?query={address}"] NetworksList["GET /onchain/networks"] end Parallel --> ExtractNetwork["Extract network from token ID prefix\n(e.g. eth_0x1f98... → eth)"] ExtractNetwork --> MapPlatform["Map network → CG platform\n(e.g. eth → ethereum)"] MapPlatform --> HasMapping{Platform\nmapping exists?} HasMapping -->|"yes (not --onchain)"| CG CG --> CGData{CG returned\ndata?} CGData -->|yes| Output CGData -->|"no + network known"| FallbackWarn["stderr: falling back to onchain"] FallbackWarn --> Onchain HasMapping -->|no| NoMapWarn["stderr: no CG platform mapping"] NoMapWarn --> Onchain HasMapping -->|"yes (--onchain)"| Onchain Onchain --> NonUSD{--vs != usd?} NonUSD -->|yes| ExchangeRates["GET /exchange_rates\n(currency conversion)"] ExchangeRates --> Output NonUSD -->|no| Output Output["Output\n(price, mcap, vol, 24h%, reserve*)"] style NeedsResolve fill:#f9f,stroke:#333 style Parallel fill:#e8f4fd,stroke:#333 style Output fill:#d4edda,stroke:#333*
reserve(total_reserve) only shown in onchain mode.Links
Changes
cmd/contract.go— new `cg contract` command with `--address`, `--platform`, `--network`, `--onchain`, `--vs`, and `--export` flagsPre-release Checklist
Rollback Steps