Skip to content

Add cg contract command for token price lookup by contract address#22

Open
khooihzhz wants to merge 11 commits intomainfrom
feat/contract-command
Open

Add cg contract command for token price lookup by contract address#22
khooihzhz wants to merge 11 commits intomainfrom
feat/contract-command

Conversation

@khooihzhz
Copy link
Copy Markdown
Collaborator

@khooihzhz khooihzhz commented Mar 24, 2026

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 --platform and --network are 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.

cg contract --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984           # smart routing
cg contract --address 0x1f98... --platform ethereum                        # explicit CG
cg contract --address 0x1f98... --platform ethereum --vs eur
cg contract --address 0x1f98... --onchain                                  # smart routing, onchain only
cg contract --address 0x1f98... --network eth --onchain
cg contract --address 0x1f98... --network eth --onchain --vs eur

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
Loading

* reserve (total_reserve) only shown in onchain mode.

Links

  • Linear: cg contract

Changes

  • Why: Web3 users start with a contract address, not a CoinGecko coin ID. There was no way to go from address → price without manually resolving the coin ID first. This bridges the gap.
  • What it adds:
    • cmd/contract.go — new `cg contract` command with `--address`, `--platform`, `--network`, `--onchain`, `--vs`, and `--export` flags
    • Smart routing: `resolveAddress()` fires `/onchain/search/pools` and `/onchain/networks` in parallel, extracts network from token relationship ID prefix, maps to CG platform
    • CG-first routing with onchain fallback when CG returns no data
    • FDV fallback: `mcap_fdv_fallback=true` always sent to onchain endpoint
    • Reserve/liquidity: `include_total_reserve_in_usd=true`, Reserve column in onchain output
    • Address case normalization for API response key matching
    • `internal/api/coins.go` — five new API client methods: `SimpleTokenPrice`, `OnchainSimpleTokenPrice`, `ExchangeRates`, `OnchainSearchPools`, `OnchainNetworks`
    • `internal/api/types.go` — response types for all new endpoints including pool/network/reserve types
    • `cmd/contract_test.go` — 28 command tests (validation, dry-run, smart routing, fallback, reserve, CSV export, catalog)
    • `internal/api/coins_test.go` — 9 API client tests
    • `cmd/commands.go` — register `contract` in `commandMeta` with per-mode endpoints/OAS IDs
    • `CLAUDE.md` — document new command mapping, smart routing, FDV fallback, reserve
  • What it modifies:
    • `cmd/commands.go` — `PaidModes`, `OASSpecs` fields on annotation and catalog structs
    • `cmd/dryrun.go` — refactor spec/operation ID resolution to support per-mode overrides with fallback
  • What it removes: Nothing

Pre-release Checklist

  • Tests pass (28 command + 9 API tests)
  • No new warnings or lint errors
  • `--platform` and `--onchain` mutual exclusivity validated
  • Onchain `--vs` non-USD currency conversion via `/exchange_rates` works correctly
  • Smart routing: pool search → network extraction → CG-first → onchain fallback
  • FDV fallback (`mcap_fdv_fallback=true`) sent on all onchain requests
  • Reserve/liquidity shown in onchain mode only (table, JSON, CSV)
  • Dry-run output includes correct OAS spec and operation IDs for all modes
  • Address case normalization for API response key matching
  • CLAUDE.md updated with smart routing design decisions

Rollback Steps

  • Revert this PR's merge commit on `main`
  • No database or config migrations to undo — purely additive code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 contract command 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
Comment on lines +115 to +118
priceStr, ok := resp.Data.Attributes.TokenPrices[address]
if !ok {
return fmt.Errorf("no data returned for address %s", address)
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
cmd/contract.go Outdated
Comment on lines +126 to +132
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)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
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),
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)),

Copilot uses AI. Check for mistakes.
}
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)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
url.PathEscape(network), strings.Join(addresses, ","), params.Encode()), &result)
url.PathEscape(network), url.PathEscape(strings.Join(addresses, ",")), params.Encode()), &result)

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +118
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()
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
…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
@khooihzhz khooihzhz force-pushed the feat/contract-command branch from a0e9736 to d769899 Compare April 5, 2026 02:02
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants