Skip to content

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

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

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

Conversation

@khooihzhz
Copy link
Copy Markdown
Collaborator

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.

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
    • internal/api/coins.go — three new API client methods: SimpleTokenPrice, OnchainSimpleTokenPrice, ExchangeRates
    • internal/api/types.go — response types: TokenPriceResponse, OnchainTokenPriceResponse, ExchangeRatesResponse
    • cmd/contract_test.go — comprehensive tests (validation, dry-run, aggregated/onchain JSON & table output, currency conversion, CSV export, catalog metadata)
    • cmd/dryrun_test.go — tests for partial-override fallback in dry-run OAS spec/operation resolution
    • internal/api/coins_test.go — unit tests for the three new API methods
    • cmd/test_helpers_test.gocaptureStdout helper for dry-run tests
  • What it modifies:
    • cmd/commands.go — register contract in commandMeta with per-mode endpoints/OAS IDs; add PaidModes, OASSpecs fields to annotation and catalog structs
    • cmd/dryrun.go — refactor spec/operation ID resolution to support per-mode overrides with fallback to command-level defaults
    • CLAUDE.md — document new command mapping, onchain currency conversion, and platform/network discoverability
  • What it removes: Nothing

Pre-release Checklist

  • Tests pass
  • No new warnings or lint errors
  • --platform and --onchain mutual exclusivity validated
  • Onchain --vs non-USD currency conversion via /exchange_rates works correctly
  • Dry-run output includes correct OAS spec and operation IDs for both modes

Rollback Steps

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

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

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