diff --git a/.tickets/sa-besp.md b/.tickets/sa-besp.md new file mode 100644 index 00000000..f4cf2ee8 --- /dev/null +++ b/.tickets/sa-besp.md @@ -0,0 +1,52 @@ +--- +id: sa-besp +status: open +deps: [] +links: [] +created: 2026-03-14T00:06:28Z +type: bug +priority: 2 +assignee: Jibles +--- +# Transaction history: swaps misclassified as contract interactions & duplicate tool calls + +## Summary + +When asking "what was my last swap?", the transaction history tool has two issues: + +1. **Swaps displayed as "Contract interaction"** — the UI cards show generic "Contract interaction / ETH / N/A" instead of properly parsed swap details, even though the LLM text response correctly identifies the swap. +2. **Duplicate tool calls** — `transactionHistoryTool` is called twice with identical params `{"offset": 0, "includeTransactions": true, "renderTransactions": 1}`. + +## Root Cause Analysis + +### Misclassification +- `evmParser.ts:74-114` — `determineTransactionType()` falls back to `'contract'` when token transfer data is missing or incomplete from the indexer +- When type is `'contract'` instead of `'swap'`, `transactionUtils.ts:getSwapTokens()` returns null (requires `tx.type === 'swap'` AND 2+ token transfers) +- UI then renders the fallback "Contract interaction" card instead of swap pair display + +### Missing type filter +- The LLM is not setting `types: ["swap"]` filter when asking about swaps +- System prompt guidance exists at `chat.ts:348` but is advisory only — LLM sometimes ignores it +- Without the filter, any recent transaction type can be returned + +### Duplicate calls +- Likely LLM behavior (up to 5 sequential tool calls allowed via `stepCountIs(5)`) +- LLM may call twice when first result doesn't match expectations, or simply non-deterministic duplication + +## Key Files +- `apps/agentic-server/src/lib/transactionHistory/evmParser.ts` — tx type classification +- `apps/agentic-server/src/routes/chat.ts:343-348` — LLM type filter guidance +- `apps/agentic-chat/src/components/tools/GetTransactionHistoryUI.tsx` — UI rendering +- `apps/agentic-chat/src/lib/transactionUtils.ts` — swap token detection + +## Reproduction +1. Wallet: `0xeD3EFA66B743e2f0B5579d67E14599D01eFA2440` on Arbitrum +2. Ask: "what was my last swap?" +3. Observe: cards show "Contract interaction" instead of swap details + +## Acceptance Criteria + +- [ ] Swaps are correctly classified as 'swap' type even when indexer token transfer data is sparse +- [ ] UI displays swap pair (e.g., "ETH → PEPE") for swap transactions +- [ ] Type-specific queries use the types filter (e.g., types: ["swap"] for swap queries) +- [ ] Tool is not called with duplicate identical params for simple queries diff --git a/.tickets/sa-qczu.md b/.tickets/sa-qczu.md new file mode 100644 index 00000000..e5eac379 --- /dev/null +++ b/.tickets/sa-qczu.md @@ -0,0 +1,70 @@ +--- +id: sa-qczu +status: open +deps: [] +links: [] +created: 2026-03-14T00:06:56Z +type: bug +priority: 1 +assignee: Jibles +--- +# Parallel swap execution: stale quotes and approval race conditions + +## Summary + +When executing multiple swaps in parallel (e.g., 3x PEPE→ETH on Arbitrum), the lock mechanism correctly serializes execution, but subsequent swaps fail because: + +1. **Quote staleness** — quotes expire while waiting in the lock queue +2. **Approval race condition** — allowance is checked at quote time but executed later, causing insufficient allowance errors + +## Observed Behavior + +**Test 1 (3 swaps: $0.5, $0.7, $0.9 PEPE→ETH):** +- Swap 1: succeeds +- Swaps 2 & 3: fail at "Sign swap transaction" with "Execution reverted for an unknown reason" + +**Test 2 (2 swaps: $0.5, $0.7 PEPE→ETH):** +- Swap 1: succeeds +- Swap 2: skips approval step, fails with "ERC20: insufficient allowance" + +## Root Cause Analysis + +### Quote Staleness +- Quotes are generated in parallel BEFORE the lock (`initiateSwap.ts:209-272`) +- Bebop quotes include an `expiry` field (`getBebopRate/types.ts:36`) but it is **never returned or validated** +- The lock (`walletMutex.ts`) queues swaps for serial execution +- By the time swap 2+ executes, its quote/unsignedTx is expired on-chain → revert + +### Approval Race Condition +- Allowance is checked at quote time (`initiateSwap.ts:224-231`) +- All parallel quotes see the same initial allowance state (e.g., 0) +- Each builds an approval for its exact amount (`approvalHelpers.ts:10-34`) +- Swap 1's approval sets allowance to its amount, then swap 1 consumes it +- Swap 2's pre-built approval may or may not re-set allowance, but even if it does, the swap tx was built against stale state +- If swap 2 skips approval (because it was marked as not needed at quote time), it gets insufficient allowance + +### No Nonce Management +- EVM transaction sending (`chains/evm/transaction.ts:12-79`) relies on wagmi's automatic nonce +- No explicit nonce tracking between queued transactions + +## Key Files +- `apps/agentic-chat/src/lib/walletMutex.ts` — lock mechanism (works, but masks deeper issues) +- `apps/agentic-server/src/tools/initiateSwap.ts` — quote generation + allowance check +- `apps/agentic-server/src/utils/approvalHelpers.ts` — builds exact-amount approvals +- `apps/agentic-server/src/utils/getAllowance.ts` — allowance check at quote time +- `apps/agentic-server/src/utils/getBebopRate/types.ts` — BebopQuote has unused `expiry` +- `apps/agentic-chat/src/components/tools/useSwapExecution.tsx` — execution orchestration + +## Suggested Fix Direction +1. **Re-fetch quote inside the lock** — move quote fetching (or at least re-validation) into the locked section of `useSwapExecution.tsx` so each swap has a fresh quote +2. **Re-check allowance before approval** — don't rely on the quote-time allowance check; re-check just before executing the approval tx +3. **Use max approval or accumulate** — approve for max uint256 or sum of all pending swap amounts +4. **Validate quote expiry** — store and check `expiry` before submitting swap tx + +## Acceptance Criteria + +- [ ] Multiple parallel swaps (2-3) all complete successfully +- [ ] Each swap gets a fresh/valid quote at execution time +- [ ] Allowance is sufficient for each swap at execution time +- [ ] Quote expiry is validated before submitting swap transaction +- [ ] No "Execution reverted" or "insufficient allowance" errors for queued swaps diff --git a/.tickets/sa-xwbn.md b/.tickets/sa-xwbn.md new file mode 100644 index 00000000..86a8db24 --- /dev/null +++ b/.tickets/sa-xwbn.md @@ -0,0 +1,747 @@ +--- +id: sa-xwbn +status: closed +deps: [] +links: [] +created: 2026-03-13T00:56:54Z +type: task +priority: 2 +assignee: Jibles +--- +# Historical Asset Pricing — Research Brief + +# Historical Asset Pricing + +## Problem Statement +The agent can't answer questions about past prices ("what was ETH worth 2 months ago?", "how much has my portfolio grown?"). Users need historical price data for single assets, comparisons, and portfolio valuation at past dates. The tool should support bulk lookups (like `getAssetPrices`) so the LLM can combine with wallet balances for portfolio calculations. + +## Research Findings + +### Current State +- All pricing flows through CoinGecko Pro API (`api.ts`) — only current prices, no historical +- `getAssetPrices` tool is the model: accepts array of `{assetId?, searchTerm?, network?}`, resolves to CAIP-19 IDs, bulk-fetches from CoinGecko, returns enriched results +- `assetIdToCoingecko()` mapping handles asset ID → CoinGecko ID conversion (O(1) map lookups, already used in bulk) +- `date-fns` v4.1.0 is a dependency, already imported in `chat.ts` — available for date math +- System prompt already injects current date + unix timestamp, so LLM can compute relative dates + +### Available Tools & Patterns +- Tool pattern: zod schema with `.describe()` → `execute` function → exported tool object +- `getAssetPrices` accepts `assets: [{assetId?, searchTerm?, network?}]` — resolves via AssetService search, then bulk CoinGecko call +- `getSimplePrices(assetIds)` in `lib/asset/coingecko/api.ts` handles the assetId→coingeckoId mapping and batch API call +- Tools registered in `routes/chat.ts` `buildTools()` — non-wallet tools wrapped directly +- `transactionHistory` tool shows date range parameter pattern (`dateFrom`, `dateTo`) +- No tool index file — each tool imported directly in `chat.ts` + +### External Context + +**CoinGecko Pro (already integrated):** +- `GET /coins/{id}/market_chart?vs_currency=usd&days=N` — time series, auto-granularity (daily 90d+, hourly 2-90d, 5min 1d) +- `GET /coins/{id}/market_chart/range?from=&to=&vs_currency=usd` — custom date range +- `GET /coins/{id}/history?date=dd-mm-yyyy` — single-date snapshot with full market data +- Pro tier: full history back to 2014, generous rate limits +- Solana confirmed working (coin ID: `solana`) +- Per-coin endpoints only (no batch market_chart) +- Cache: 30s (1d), 30min (2-90d), 12h (90d+) +- Docs: https://docs.coingecko.com/v3.0.1/reference/coins-id-market-chart + +**DeFiLlama (free, no auth, coins.llama.fi):** +- `GET /prices/historical/{timestamp}/{coins}` — true batch (comma-separated coins), returns price at timestamp +- `GET /chart/{coins}?start=&span=&period=1d` — time series chart +- `GET /percentage/{coins}?period=` — direct growth percentage +- `POST /batchHistorical` — multiple coins × multiple timestamps in one call +- Token format: `chain:address` or `coingecko:{id}` — all EVM chains + Solana confirmed +- Response includes `confidence` score (0-1) and `decimals`, `symbol` +- Docs: https://defillama.com/docs/api + +### Validated Behaviors +- CoinGecko `market_chart` returns `{ prices: [[timestamp_ms, price], ...], market_caps: [...], total_volumes: [...] }` +- CoinGecko `history` returns full coin snapshot with `market_data.current_price.usd` for that date +- DeFiLlama `prices/historical` returns `{ coins: { "coingecko:ethereum": { price, timestamp, confidence, symbol } } }` +- DeFiLlama `chart` returns `{ coins: { "coingecko:ethereum": { symbol, confidence, prices: [{timestamp, price}] } } }` +- `assetIdToCoingecko()` returns `undefined` for unmapped tokens — current code defaults to price '0' +- DeFiLlama Avalanche USDC returned anomalous $0.74 — confidence field should be checked + +## Constraints +- CoinGecko `market_chart` is per-coin (no batch) — portfolio of 10 assets = 10 API calls +- CoinGecko `history` uses `dd-mm-yyyy` date format (not unix timestamp) +- `assetIdToCoingecko()` returns `undefined` for some tokens — need fallback +- CoinGecko forced interval control (5m, hourly) on `market_chart/range` is Enterprise-only +- Auto-granularity: can't get hourly for >90d ranges on Pro tier +- DeFiLlama has occasional data anomalies on certain chain/token combos + +## Dead Ends +- **CoinCap v3** — old v2 API is dead (DNS doesn't resolve). v3 gives only 50 free credits (~30 calls), then requires on-chain USDC payment ($1/500 credits). Fewer assets (~1000 vs CoinGecko 15K+), no contract-address-based historical lookup. Not viable as free or primary source. + +## Loose Recommendations +- CoinGecko `market_chart` fits naturally as primary — same client, same ID mapping, already proven +- DeFiLlama is a strong complement for batch historical lookups (true batch in one call) and as fallback for tokens missing CoinGecko mappings +- Tool can mirror `getAssetPrices` input pattern — same `assets` array with `assetId/searchTerm/network` — plus date/time parameter +- For "how much has X grown?", time series (`market_chart`) is more useful than single snapshot — LLM computes delta +- For "portfolio value at date X", DeFiLlama batch endpoint is more efficient than N individual CoinGecko calls +- `date-fns` already available for converting relative dates to timestamps/formatted dates + +## Open Questions +- Should we expose both "price at date" and "price chart over range" as separate tools, or one tool with mode selection? +- Should DeFiLlama be fallback only, or primary for batch queries? +- How to handle tokens with no CoinGecko mapping and no contract address? +- Should the tool return raw time series data, or pre-compute useful metrics (start price, end price, % change)? + +## Notes + +**2026-03-13T01:29:37Z** + +# Design + +## Architecture + +Single tool `getHistoricalPrices` following the existing `getAssetPrices` pattern: + +- **Input:** `assets` array (same `{assetId?, searchTerm?, network?}` shape), `startDate` (ISO string), `endDate` (ISO string, defaults to now), `dataPoints` (1-30, default 2) +- **Resolution:** Same AssetService search → CAIP-19 → `assetIdToCoingecko()` pipeline +- **Fetch:** CoinGecko `market_chart/range` per resolved coin, parallelized with `Promise.all` +- **Downsample:** Pick `dataPoints` evenly-spaced entries from the returned time series +- **Output:** Per-asset array of `{ timestamp, price }` plus computed `startPrice`, `endPrice`, `percentChange` + +Summary fields (`startPrice`, `endPrice`, `percentChange`) come from the first and last downsampled points — gives the LLM a quick answer without parsing the array for simple growth questions. + +## Data Flow + +1. LLM calls `getHistoricalPrices` with assets + date range + dataPoints +2. Resolve each asset via AssetService search (same as `getAssetPrices`) → CAIP-19 IDs +3. Map CAIP-19 → CoinGecko ID via `assetIdToCoingecko()` +4. `Promise.all` — fetch `market_chart/range?vs_currency=usd&from={startUnix}&to={endUnix}` for each resolved coin +5. Each response returns `{ prices: [[timestamp_ms, price], ...] }` — take the `prices` array +6. Downsample: divide array into `dataPoints` evenly-spaced indices, pick those entries +7. Return per-asset result: `{ asset, dataPoints: [{timestamp, price}], startPrice, endPrice, percentChange }` + +**Unmapped tokens:** If `assetIdToCoingecko()` returns `undefined`, skip with error message in response rather than failing the batch. + +**Rate limits:** Cap at 10 assets per call — more than 10 returns an error telling the LLM to narrow the selection. + +## Error Handling + +- **Asset not found:** AssetService search returns no match → include in response with error string, don't fail the batch +- **No CoinGecko mapping:** `assetIdToCoingecko()` returns `undefined` → same treatment, skip with error message +- **CoinGecko API error:** Individual coin fetch fails → catch per-promise, return error for that asset, don't fail the batch +- **Too many assets:** More than 10 → reject with message telling the LLM to reduce the list +- **Invalid date range:** `startDate` after `endDate`, or future `startDate` → reject with descriptive error +- **dataPoints out of range:** Clamp to 1-30 silently + +Partial success pattern throughout — return what we can, report what we couldn't. + +## Testing Approach + +- Unit test downsampling function in isolation (even spacing, edge cases like fewer points than requested, single point) +- Unit test date validation and asset count validation +- Integration test with mocked CoinGecko responses: asset resolution → fetch → downsample → response shape +- Test partial failure: one asset resolves, one doesn't — verify both appear correctly + +## Approved Approach + +CoinGecko-only, single tool (`getHistoricalPrices`), client-side downsampling with max 30 data points. Mirrors `getAssetPrices` input pattern. Portfolio growth queries are out of scope — future separate tool/feature. + +**2026-03-13T01:33:02Z** + +# Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use /run tk:sa-xwbn to implement this plan task-by-task via subagent-driven-development. + +**Goal:** Add a `getHistoricalPrices` tool that fetches historical price data from CoinGecko for one or more assets over a date range, with client-side downsampling. + +**Architecture:** Single tool mirroring `getAssetPrices` input pattern. Resolves assets via AssetService search → CAIP-19 → CoinGecko ID, then fetches `market_chart/range` per coin in parallel. Downsamples response to N evenly-spaced data points. Partial success pattern — individual asset failures don't fail the batch. + +**Tech Stack:** CoinGecko Pro API (`market_chart/range`), zod schemas, axios (existing client), date-fns (already a dependency), bun:test + +--- + +### Task 1: Add `MarketChartRangeResponse` type + +**Files:** +- Modify: `apps/agentic-server/src/lib/asset/coingecko/types.ts` + +**Step 1: Add the type at the end of the types file (before the trimmed output types section)** + +```typescript +// Historical market chart types +export type MarketChartRangeResponse = { + prices: [number, number][] // [timestamp_ms, price] + market_caps: [number, number][] + total_volumes: [number, number][] +} +``` + +Add this block above the `// Trimmed output types for tools` comment (around line 191). + +**Step 2: Commit** + +```bash +git add apps/agentic-server/src/lib/asset/coingecko/types.ts +git commit -m "feat: add MarketChartRangeResponse type for historical pricing" +``` + +--- + +### Task 2: Add `getMarketChartRange` API function + +**Files:** +- Modify: `apps/agentic-server/src/lib/asset/coingecko/api.ts` +- Modify: `apps/agentic-server/src/lib/asset/coingecko/index.ts` + +**Step 1: Import the new type in api.ts** + +Add `MarketChartRangeResponse` to the existing type import block at line 5: + +```typescript +import type { + CategoriesResponse, + CoinResponse, + MarketChartRangeResponse, + NewCoinsResponse, + SimplePriceData, + SimplePriceResult, + TopGainersLosersResponse, + TrendingPoolsResponse, + TrendingSearchResponse, +} from './types' +``` + +**Step 2: Add the function after `getMarketData` (after line 29)** + +```typescript +export async function getMarketChartRange( + coinGeckoId: string, + fromUnix: number, + toUnix: number +): Promise { + const { data } = await client.get(`/coins/${coinGeckoId}/market_chart/range`, { + params: { + vs_currency: 'usd', + from: fromUnix, + to: toUnix, + }, + }) + return data +} +``` + +**Step 3: Re-export from index.ts** + +Add `getMarketChartRange` to the export list in `apps/agentic-server/src/lib/asset/coingecko/index.ts`: + +```typescript +export { + getMarketChartRange, + getMarketData, + getSimplePrices, + getTrendingSearch, + getTopGainersLosers, + getTrendingPools, + getCategories, + getNewCoins, +} from './api' +``` + +Also add to the type exports: + +```typescript +export type { + CoinResponse, + MarketChartRangeResponse, + SimplePriceResult, + // ... rest unchanged +} from './types' +``` + +**Step 4: Commit** + +```bash +git add apps/agentic-server/src/lib/asset/coingecko/api.ts apps/agentic-server/src/lib/asset/coingecko/index.ts +git commit -m "feat: add getMarketChartRange CoinGecko API function" +``` + +--- + +### Task 3: Write downsample utility with tests (TDD) + +**Files:** +- Create: `apps/agentic-server/src/lib/asset/coingecko/downsample.ts` +- Create: `apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts` + +**Step 1: Write the failing tests** + +Create `apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts`: + +```typescript +import { describe, expect, test } from 'bun:test' + +import { downsample } from '../downsample' + +describe('downsample', () => { + const tenPoints: [number, number][] = [ + [1000, 100], + [2000, 110], + [3000, 105], + [4000, 120], + [5000, 115], + [6000, 130], + [7000, 125], + [8000, 140], + [9000, 135], + [10000, 150], + ] + + test('returns evenly spaced points', () => { + const result = downsample(tenPoints, 3) + expect(result).toHaveLength(3) + // first, middle, last + expect(result[0]).toEqual([1000, 100]) + expect(result[2]).toEqual([10000, 150]) + }) + + test('returns all points when dataPoints >= array length', () => { + const result = downsample(tenPoints, 10) + expect(result).toHaveLength(10) + expect(result).toEqual(tenPoints) + }) + + test('returns all points when dataPoints > array length', () => { + const result = downsample(tenPoints, 20) + expect(result).toHaveLength(10) + expect(result).toEqual(tenPoints) + }) + + test('returns single point (last) when dataPoints is 1', () => { + const result = downsample(tenPoints, 1) + expect(result).toHaveLength(1) + expect(result[0]).toEqual([10000, 150]) + }) + + test('returns first and last when dataPoints is 2', () => { + const result = downsample(tenPoints, 2) + expect(result).toHaveLength(2) + expect(result[0]).toEqual([1000, 100]) + expect(result[1]).toEqual([10000, 150]) + }) + + test('returns empty array for empty input', () => { + expect(downsample([], 5)).toEqual([]) + }) + + test('returns single element for single element input', () => { + const result = downsample([[1000, 100]], 5) + expect(result).toEqual([[1000, 100]]) + }) +}) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd apps/agentic-server && bun test src/lib/asset/coingecko/__tests__/downsample.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `apps/agentic-server/src/lib/asset/coingecko/downsample.ts`: + +```typescript +export function downsample(points: [number, number][], dataPoints: number): [number, number][] { + if (points.length === 0) return [] + if (dataPoints >= points.length) return points + + if (dataPoints === 1) return [points[points.length - 1]] + + const result: [number, number][] = [] + for (let i = 0; i < dataPoints; i++) { + const index = Math.round((i * (points.length - 1)) / (dataPoints - 1)) + result.push(points[index]) + } + return result +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps/agentic-server && bun test src/lib/asset/coingecko/__tests__/downsample.test.ts` +Expected: All 7 tests PASS + +**Step 5: Commit** + +```bash +git add apps/agentic-server/src/lib/asset/coingecko/downsample.ts apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts +git commit -m "feat: add downsample utility for historical price data" +``` + +--- + +### Task 4: Write `getHistoricalPrices` tool with tests (TDD) + +**Files:** +- Create: `apps/agentic-server/src/tools/getHistoricalPrices.ts` +- Create: `apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts` + +**Step 1: Write the failing tests** + +Create `apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts`: + +```typescript +import { describe, expect, mock, test, beforeEach } from 'bun:test' + +import { getHistoricalPricesSchema, executeGetHistoricalPrices } from '../getHistoricalPrices' + +// Mock the coingecko API +const mockGetMarketChartRange = mock(() => + Promise.resolve({ + prices: [ + [1704067200000, 2300], + [1704153600000, 2350], + [1704240000000, 2400], + [1704326400000, 2380], + [1704412800000, 2450], + ] as [number, number][], + market_caps: [], + total_volumes: [], + }) +) + +mock.module('../lib/asset/coingecko/api', () => ({ + getMarketChartRange: mockGetMarketChartRange, +})) + +// Mock AssetService +const mockSearchWithFilters = mock(() => [{ assetId: 'eip155:1/slip44:60' }]) +mock.module('@shapeshiftoss/utils', () => ({ + AssetService: { + getInstance: () => ({ + searchWithFilters: mockSearchWithFilters, + }), + }, +})) + +// Mock assetIdToCoingecko +mock.module('@shapeshiftoss/caip', () => ({ + assetIdToCoingecko: () => 'ethereum', +})) + +describe('getHistoricalPricesSchema', () => { + test('validates valid input with ISO dates', () => { + const input = { + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-02-01', + dataPoints: 5, + } + expect(() => getHistoricalPricesSchema.parse(input)).not.toThrow() + }) + + test('rejects more than 10 assets', () => { + const input = { + assets: Array.from({ length: 11 }, (_, i) => ({ searchTerm: `TOKEN${i}` })), + startDate: '2024-01-01', + } + expect(() => getHistoricalPricesSchema.parse(input)).toThrow() + }) + + test('rejects dataPoints > 30', () => { + const input = { + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + dataPoints: 31, + } + expect(() => getHistoricalPricesSchema.parse(input)).toThrow() + }) + + test('defaults dataPoints to 2', () => { + const input = { + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + } + const parsed = getHistoricalPricesSchema.parse(input) + expect(parsed.dataPoints).toBe(2) + }) +}) + +describe('executeGetHistoricalPrices', () => { + beforeEach(() => { + mockGetMarketChartRange.mockClear() + mockSearchWithFilters.mockClear() + }) + + test('returns price data with summary fields', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-02-01', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const ethResult = result.results[0] + expect(ethResult.assetId).toBe('eip155:1/slip44:60') + expect(ethResult.dataPoints).toHaveLength(2) + expect(ethResult.startPrice).toBeDefined() + expect(ethResult.endPrice).toBeDefined() + expect(ethResult.percentChange).toBeDefined() + }) + + test('computes percent change correctly', async () => { + // Mock returns prices from 2300 to 2450 + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + const ethResult = result.results[0] + expect(ethResult.startPrice).toBe(2300) + expect(ethResult.endPrice).toBe(2450) + // (2450 - 2300) / 2300 * 100 ≈ 6.52 + expect(ethResult.percentChange).toBeCloseTo(6.52, 1) + }) + + test('handles asset not found gracefully', async () => { + mockSearchWithFilters.mockReturnValueOnce([]) + + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'NONEXISTENT' }], + startDate: '2024-01-01', + }) + + expect(result.results).toHaveLength(1) + expect(result.results[0].error).toBeDefined() + }) +}) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd apps/agentic-server && bun test src/tools/__tests__/getHistoricalPrices.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the tool implementation** + +Create `apps/agentic-server/src/tools/getHistoricalPrices.ts`: + +```typescript +import { assetIdToCoingecko } from '@shapeshiftoss/caip' +import { NETWORKS } from '@shapeshiftoss/types' +import { AssetService } from '@shapeshiftoss/utils' +import { getUnixTime, parseISO } from 'date-fns' +import { z } from 'zod' + +import { getMarketChartRange } from '../lib/asset/coingecko/api' +import { downsample } from '../lib/asset/coingecko/downsample' + +export const getHistoricalPricesSchema = z.object({ + assets: z + .array( + z.object({ + assetId: z.string().optional().describe('CAIP-19 assetId (e.g., "eip155:1/erc20:0xa0b8...")'), + searchTerm: z.string().optional().describe('Search by symbol or name (e.g., "ETH", "USDC", "Bitcoin")'), + network: z.enum(NETWORKS).optional().describe('Network to search on (e.g., "ethereum", "arbitrum")'), + }) + ) + .min(1) + .max(10) + .describe('Array of assets to get historical prices for (max 10)'), + startDate: z.string().describe('Start date as ISO 8601 string (e.g., "2024-01-01", "2024-06-15T00:00:00Z")'), + endDate: z.string().optional().describe('End date as ISO 8601 string. Defaults to now.'), + dataPoints: z + .number() + .int() + .min(1) + .max(30) + .default(2) + .describe('Number of evenly-spaced price points to return (1-30, default 2 for start/end comparison)'), +}) + +export type GetHistoricalPricesInput = z.infer + +type AssetPriceResult = { + assetId: string + symbol: string + name: string + dataPoints: { timestamp: number; price: number }[] + startPrice: number + endPrice: number + percentChange: number +} + +type AssetPriceError = { + searchTerm?: string + assetId?: string + error: string +} + +export type GetHistoricalPricesOutput = { + results: (AssetPriceResult | AssetPriceError)[] +} + +export async function executeGetHistoricalPrices( + input: GetHistoricalPricesInput +): Promise { + console.log('[getHistoricalPrices]:', input) + + const startUnix = getUnixTime(parseISO(input.startDate)) + const endUnix = input.endDate ? getUnixTime(parseISO(input.endDate)) : Math.floor(Date.now() / 1000) + + if (startUnix >= endUnix) { + return { results: [{ error: 'startDate must be before endDate' }] } + } + + // Resolve assets to CAIP-19 IDs and CoinGecko IDs + type ResolvedAsset = { assetId: string; coinGeckoId: string; symbol: string; name: string } + const resolved: (ResolvedAsset | AssetPriceError)[] = input.assets.map(assetInput => { + let assetId: string + + if (assetInput.assetId) { + assetId = assetInput.assetId + } else if (assetInput.searchTerm) { + const result = AssetService.getInstance().searchWithFilters(assetInput.searchTerm, { + network: assetInput.network, + })[0] + if (!result) { + return { + searchTerm: assetInput.searchTerm, + error: `Asset not found: ${assetInput.searchTerm}${assetInput.network ? ` on ${assetInput.network}` : ''}`, + } + } + assetId = result.assetId + } else { + return { error: 'Each asset must have either assetId or searchTerm' } + } + + const coinGeckoId = assetIdToCoingecko(assetId) + if (!coinGeckoId) { + return { assetId, error: `No CoinGecko mapping for asset: ${assetId}` } + } + + const asset = AssetService.getInstance().getAsset(assetId) + return { + assetId, + coinGeckoId, + symbol: asset?.symbol ?? 'UNKNOWN', + name: asset?.name ?? 'Unknown', + } + }) + + // Fetch historical data in parallel for resolved assets + const results: (AssetPriceResult | AssetPriceError)[] = await Promise.all( + resolved.map(async item => { + if ('error' in item) return item + + try { + const chartData = await getMarketChartRange(item.coinGeckoId, startUnix, endUnix) + const sampled = downsample(chartData.prices, input.dataPoints) + const dataPoints = sampled.map(([ts, price]) => ({ timestamp: Math.floor(ts / 1000), price })) + + if (dataPoints.length === 0) { + return { ...item, error: 'No price data available for this date range' } + } + + const startPrice = dataPoints[0].price + const endPrice = dataPoints[dataPoints.length - 1].price + const percentChange = startPrice !== 0 ? ((endPrice - startPrice) / startPrice) * 100 : 0 + + return { + assetId: item.assetId, + symbol: item.symbol, + name: item.name, + dataPoints, + startPrice, + endPrice, + percentChange: Math.round(percentChange * 100) / 100, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { assetId: item.assetId, error: `Failed to fetch historical data: ${message}` } + } + }) + ) + + return { results } +} + +export const getHistoricalPricesTool = { + description: `Get historical price data for assets over a date range. Returns evenly-spaced price points plus start/end price and percent change. Use for questions like "what was ETH worth 2 months ago?" or "how much has BTC grown since January?". + +Examples: +- { assets: [{ searchTerm: "ETH" }], startDate: "2024-01-01" } +- { assets: [{ searchTerm: "BTC" }, { searchTerm: "ETH" }], startDate: "2024-01-01", endDate: "2024-06-01", dataPoints: 10 } +- { assets: [{ assetId: "eip155:1/slip44:60" }], startDate: "2024-06-01", dataPoints: 5 }`, + inputSchema: getHistoricalPricesSchema, + execute: executeGetHistoricalPrices, +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd apps/agentic-server && bun test src/tools/__tests__/getHistoricalPrices.test.ts` +Expected: All tests PASS + +Note: The mocking approach may need adjustment depending on how bun:test resolves module mocks with relative imports. If mocks don't intercept correctly, refactor mock paths to match the actual import paths. The test file may need `mock.module` paths adjusted to be relative from the test file location (e.g., `../../lib/asset/coingecko/api`). + +**Step 5: Commit** + +```bash +git add apps/agentic-server/src/tools/getHistoricalPrices.ts apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts +git commit -m "feat: add getHistoricalPrices tool with tests" +``` + +--- + +### Task 5: Register the tool in chat.ts + +**Files:** +- Modify: `apps/agentic-server/src/routes/chat.ts` + +**Step 1: Add the import** + +Add after the `getAssetPricesTool` import (line 22): + +```typescript +import { getHistoricalPricesTool } from '../tools/getHistoricalPrices' +``` + +**Step 2: Register in buildTools** + +Add `getHistoricalPricesTool` to the first `wrapTools` group (the non-wallet group), after `getAssetPricesTool` (line 128): + +```typescript + ...wrapTools({ + mathCalculatorTool: mathCalculator, + getAssetsTool, + getAssetPricesTool, + getHistoricalPricesTool, + lookupExternalAddress: lookupExternalAddressTool, + // ... rest unchanged + }), +``` + +**Step 3: Commit** + +```bash +git add apps/agentic-server/src/routes/chat.ts +git commit -m "feat: register getHistoricalPrices tool" +``` + +--- + +### Task 6: Run full test suite and verify + +**Step 1: Run all tests** + +Run: `cd apps/agentic-server && bun test` +Expected: All existing tests still pass, new tests pass + +**Step 2: If any failures, fix them** + +**Step 3: Final commit if any fixes were needed** + +```bash +git add -A && git commit -m "fix: resolve test issues from historical pricing integration" +``` + +**2026-03-13T02:14:11Z** + +Tasks 1-6 complete: Added MarketChartRangeResponse type, getMarketChartRange API function, downsample utility, getHistoricalPrices tool with 17 tests, registered in chat router. Full suite 231/231 passing. diff --git a/CLAUDE.md b/CLAUDE.md index 6fe68868..c096524e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,8 @@ # Claude Programming Guidelines -## Testing -- Use `bun test` to run tests (not vitest/jest) +## Verification +- After each change, run lint, type-check, and test: + - `bun run lint` + - `bun run type-check` + - `bun test` - Tests import from `bun:test` diff --git a/apps/agentic-chat/src/components/Execution.tsx b/apps/agentic-chat/src/components/Execution.tsx index 27841fdf..4f023466 100644 --- a/apps/agentic-chat/src/components/Execution.tsx +++ b/apps/agentic-chat/src/components/Execution.tsx @@ -118,7 +118,7 @@ function ErrorFooter() { ) } diff --git a/apps/agentic-chat/src/components/Markdown.tsx b/apps/agentic-chat/src/components/Markdown.tsx index 551ff005..df93048c 100644 --- a/apps/agentic-chat/src/components/Markdown.tsx +++ b/apps/agentic-chat/src/components/Markdown.tsx @@ -147,6 +147,8 @@ export function Markdown({ children }: MarkdownProps) { a]:text-xs [&>a]:no-underline', className)} {...props} /> ), del: ({ ...props }) => , + strong: ({ className, ...props }) => , + em: ({ className, ...props }) => , pre: ({ children, ...props }) => { // Extract code content and language const codeElement = (children as ReactNode[])?.[0] diff --git a/apps/agentic-server/src/index.ts b/apps/agentic-server/src/index.ts index ea9f7752..9eae62b0 100644 --- a/apps/agentic-server/src/index.ts +++ b/apps/agentic-server/src/index.ts @@ -14,6 +14,12 @@ export { type GetAssetPricesInput, type GetAssetPricesOutput, } from './tools/getAssetPrices' +export { + getHistoricalPricesTool, + executeGetHistoricalPrices, + type GetHistoricalPricesInput, + type GetHistoricalPricesOutput, +} from './tools/getHistoricalPrices' export { lookupExternalAddressTool, executeGetAccount, diff --git a/apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts b/apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts new file mode 100644 index 00000000..678fbb2b --- /dev/null +++ b/apps/agentic-server/src/lib/asset/coingecko/__tests__/downsample.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' + +import { downsample } from '../downsample' + +describe('downsample', () => { + const tenPoints: [number, number][] = [ + [1000, 100], + [2000, 110], + [3000, 105], + [4000, 120], + [5000, 115], + [6000, 130], + [7000, 125], + [8000, 140], + [9000, 135], + [10000, 150], + ] + + test('returns evenly spaced points', () => { + const result = downsample(tenPoints, 3) + expect(result).toHaveLength(3) + // first, middle, last + expect(result[0]).toEqual([1000, 100]) + expect(result[2]).toEqual([10000, 150]) + }) + + test('returns all points when dataPoints >= array length', () => { + const result = downsample(tenPoints, 10) + expect(result).toHaveLength(10) + expect(result).toEqual(tenPoints) + }) + + test('returns all points when dataPoints > array length', () => { + const result = downsample(tenPoints, 20) + expect(result).toHaveLength(10) + expect(result).toEqual(tenPoints) + }) + + test('returns single point (last) when dataPoints is 1', () => { + const result = downsample(tenPoints, 1) + expect(result).toHaveLength(1) + expect(result[0]).toEqual([10000, 150]) + }) + + test('returns first and last when dataPoints is 2', () => { + const result = downsample(tenPoints, 2) + expect(result).toHaveLength(2) + expect(result[0]).toEqual([1000, 100]) + expect(result[1]).toEqual([10000, 150]) + }) + + test('returns empty array for empty input', () => { + expect(downsample([], 5)).toEqual([]) + }) + + test('returns single element for single element input', () => { + const result = downsample([[1000, 100]], 5) + expect(result).toEqual([[1000, 100]]) + }) +}) diff --git a/apps/agentic-server/src/lib/asset/coingecko/api.ts b/apps/agentic-server/src/lib/asset/coingecko/api.ts index 2a53fa43..f02a478f 100644 --- a/apps/agentic-server/src/lib/asset/coingecko/api.ts +++ b/apps/agentic-server/src/lib/asset/coingecko/api.ts @@ -5,6 +5,7 @@ import axios from 'axios' import type { CategoriesResponse, CoinResponse, + MarketChartRangeResponse, NewCoinsResponse, SimplePriceData, SimplePriceResult, @@ -28,6 +29,21 @@ export async function getMarketData(coinGeckoId: string): Promise return data } +export async function getMarketChartRange( + coinGeckoId: string, + fromUnix: number, + toUnix: number +): Promise { + const { data } = await client.get(`/coins/${coinGeckoId}/market_chart/range`, { + params: { + vs_currency: 'usd', + from: fromUnix, + to: toUnix, + }, + }) + return data +} + export async function getBulkPrices(coinGeckoIds: string[]): Promise { if (coinGeckoIds.length === 0) return {} diff --git a/apps/agentic-server/src/lib/asset/coingecko/downsample.ts b/apps/agentic-server/src/lib/asset/coingecko/downsample.ts new file mode 100644 index 00000000..c1730f12 --- /dev/null +++ b/apps/agentic-server/src/lib/asset/coingecko/downsample.ts @@ -0,0 +1,13 @@ +export function downsample(points: [number, number][], dataPoints: number): [number, number][] { + if (points.length === 0) return [] + if (dataPoints >= points.length) return points + + if (dataPoints === 1) return [points[points.length - 1]!] + + const result: [number, number][] = [] + for (let i = 0; i < dataPoints; i++) { + const index = Math.round((i * (points.length - 1)) / (dataPoints - 1)) + result.push(points[index]!) + } + return result +} diff --git a/apps/agentic-server/src/lib/asset/coingecko/index.ts b/apps/agentic-server/src/lib/asset/coingecko/index.ts index 3bfb752b..0dfd8439 100644 --- a/apps/agentic-server/src/lib/asset/coingecko/index.ts +++ b/apps/agentic-server/src/lib/asset/coingecko/index.ts @@ -1,5 +1,6 @@ export { getMarketData, + getMarketChartRange, getSimplePrices, getTrendingSearch, getTopGainersLosers, @@ -9,6 +10,7 @@ export { } from './api' export type { CoinResponse, + MarketChartRangeResponse, SimplePriceResult, TrendingSearchResponse, TopGainersLosersResponse, diff --git a/apps/agentic-server/src/lib/asset/coingecko/types.ts b/apps/agentic-server/src/lib/asset/coingecko/types.ts index 845c288f..09c11544 100644 --- a/apps/agentic-server/src/lib/asset/coingecko/types.ts +++ b/apps/agentic-server/src/lib/asset/coingecko/types.ts @@ -188,6 +188,13 @@ export type NewCoinData = { export type NewCoinsResponse = NewCoinData[] +// Historical market chart types +export type MarketChartRangeResponse = { + prices: [number, number][] // [timestamp_ms, price] + market_caps: [number, number][] + total_volumes: [number, number][] +} + // Trimmed output types for tools (minimal fields to reduce LLM context) export type TrimmedTrendingCoin = { id: string diff --git a/apps/agentic-server/src/lib/asset/resolveAsset.ts b/apps/agentic-server/src/lib/asset/resolveAsset.ts new file mode 100644 index 00000000..e980ba97 --- /dev/null +++ b/apps/agentic-server/src/lib/asset/resolveAsset.ts @@ -0,0 +1,13 @@ +import { assetIdToCoingecko } from '@shapeshiftoss/caip' +import type { Network } from '@shapeshiftoss/types' +import { AssetService } from '@shapeshiftoss/utils' + +export function searchAsset(searchTerm: string, filters: { network?: Network }) { + return AssetService.getInstance().searchWithFilters(searchTerm, filters)[0] as { assetId: string } | undefined +} + +export function getAssetMeta(assetId: string) { + return AssetService.getInstance().getAsset(assetId) as { symbol: string; name: string } | undefined +} + +export { assetIdToCoingecko } diff --git a/apps/agentic-server/src/routes/chat.ts b/apps/agentic-server/src/routes/chat.ts index acb61797..a79380de 100644 --- a/apps/agentic-server/src/routes/chat.ts +++ b/apps/agentic-server/src/routes/chat.ts @@ -22,6 +22,7 @@ import { getAllowanceTool } from '../tools/getAllowance' import { getAssetPricesTool } from '../tools/getAssetPrices' import { getAssetsTool } from '../tools/getAssets' import { getCategoriesTool } from '../tools/getCategories' +import { getHistoricalPricesTool } from '../tools/getHistoricalPrices' import { getNewCoinsTool } from '../tools/getNewCoins' import { getPriceFeedTokensTool } from '../tools/getPriceFeedTokens' import { getShapeShiftKnowledgeTool } from '../tools/getShapeShiftKnowledge' @@ -126,6 +127,7 @@ function buildTools(walletContext: WalletContext) { mathCalculatorTool: mathCalculator, getAssetsTool, getAssetPricesTool, + getHistoricalPricesTool, lookupExternalAddress: lookupExternalAddressTool, switchNetworkTool, getShapeShiftKnowledgeTool, @@ -165,7 +167,7 @@ function buildTools(walletContext: WalletContext) { description: getAllowanceTool.description, inputSchema: getAllowanceTool.inputSchema, execute: async (args: Parameters[0]) => { - console.log('[Tool] getAllowanceTool:', JSON.stringify(args, null, 2)) + const chainId = args?.asset?.chainId const from = args?.from ?? (chainId ? walletContext.connectedWallets?.[chainId]?.address : undefined) if (!from) { @@ -306,6 +308,7 @@ Select the single tool matching the user's intent (these names are internal — | Intent | Tool | |---|---| | Quick price check (no UI card) | getAssetPrices | +| Historical prices / price at past date / price growth over time | getHistoricalPrices | | Detailed market data (UI card) | getAssets | | Trending/gainers/new coins | getTrendingTokens, getTopGainersLosers, getNewCoins | | Trending pools | getTrendingPools | diff --git a/apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts b/apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts new file mode 100644 index 00000000..0287c2c2 --- /dev/null +++ b/apps/agentic-server/src/tools/__tests__/getHistoricalPrices.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, mock, test } from 'bun:test' + +import { executeGetHistoricalPrices, getHistoricalPricesSchema } from '../getHistoricalPrices' + +// Mock local dependencies — avoids polluting global @shapeshiftoss/* modules +void mock.module('../../lib/asset/resolveAsset', () => ({ + searchAsset: (term: string) => { + const assets: Record = { + ETH: { assetId: 'eip155:1/slip44:60' }, + BTC: { assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0' }, + NOTFOUND: undefined, + FAIL: { assetId: 'eip155:1/erc20:0xfailcoin' }, + EMPTY: { assetId: 'eip155:1/erc20:0xemptycoin' }, + } + return assets[term] + }, + getAssetMeta: (assetId: string) => { + const map: Record = { + 'eip155:1/slip44:60': { symbol: 'ETH', name: 'Ethereum' }, + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { symbol: 'BTC', name: 'Bitcoin' }, + 'eip155:1/erc20:0xfailcoin': { symbol: 'FAIL', name: 'FailCoin' }, + 'eip155:1/erc20:0xemptycoin': { symbol: 'EMPTY', name: 'EmptyCoin' }, + } + return map[assetId] + }, + assetIdToCoingecko: (assetId: string) => { + const map: Record = { + 'eip155:1/slip44:60': 'ethereum', + 'bip122:000000000019d6689c085ae165831e93/slip44:0': 'bitcoin', + 'eip155:1/erc20:0xunmapped': undefined, + 'eip155:1/erc20:0xfailcoin': 'failcoin', + 'eip155:1/erc20:0xemptycoin': 'emptycoin', + } + return map[assetId] + }, +})) + +void mock.module('../../lib/asset/coingecko/api', () => ({ + getMarketChartRange: async (coinGeckoId: string) => { + if (coinGeckoId === 'ethereum') { + return { + prices: [ + [1704067200000, 2000], + [1704153600000, 2100], + [1704240000000, 2200], + [1704326400000, 2300], + [1704412800000, 2400], + ] as [number, number][], + market_caps: [], + total_volumes: [], + } + } + if (coinGeckoId === 'bitcoin') { + return { + prices: [ + [1704067200000, 40000], + [1704153600000, 41000], + [1704240000000, 42000], + [1704326400000, 43000], + [1704412800000, 44000], + ] as [number, number][], + market_caps: [], + total_volumes: [], + } + } + if (coinGeckoId === 'emptycoin') { + return { prices: [] as [number, number][], market_caps: [], total_volumes: [] } + } + throw new Error('API error') + }, +})) + +describe('getHistoricalPricesSchema', () => { + test('enforces max 10 assets', () => { + const input = { + assets: Array.from({ length: 11 }, () => ({ searchTerm: 'ETH' })), + startDate: '2024-01-01', + } + const result = getHistoricalPricesSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('enforces min 1 asset', () => { + const input = { assets: [], startDate: '2024-01-01' } + const result = getHistoricalPricesSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('defaults dataPoints to 2', () => { + const input = { assets: [{ searchTerm: 'ETH' }], startDate: '2024-01-01' } + const result = getHistoricalPricesSchema.parse(input) + expect(result.dataPoints).toBe(2) + }) + + test('enforces dataPoints max 30', () => { + const input = { assets: [{ searchTerm: 'ETH' }], startDate: '2024-01-01', dataPoints: 31 } + const result = getHistoricalPricesSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('enforces dataPoints min 1', () => { + const input = { assets: [{ searchTerm: 'ETH' }], startDate: '2024-01-01', dataPoints: 0 } + const result = getHistoricalPricesSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + test('accepts valid input', () => { + const input = { + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-06-01', + dataPoints: 10, + } + const result = getHistoricalPricesSchema.safeParse(input) + expect(result.success).toBe(true) + }) +}) + +describe('executeGetHistoricalPrices', () => { + test('returns price data with correct summary fields', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const eth = result.results[0] as any + expect(eth.symbol).toBe('ETH') + expect(eth.name).toBe('Ethereum') + expect(eth.assetId).toBe('eip155:1/slip44:60') + expect(eth.startPrice).toBe(2000) + expect(eth.endPrice).toBe(2400) + expect(eth.dataPoints).toHaveLength(2) + }) + + test('computes percent change correctly', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + const eth = result.results[0] as any + // (2400 - 2000) / 2000 * 100 = 20% + expect(eth.percentChange).toBe(20) + }) + + test('handles asset not found gracefully (partial failure)', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }, { searchTerm: 'NOTFOUND' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(2) + const eth = result.results[0] as any + expect(eth.symbol).toBe('ETH') + const notFound = result.results[1] as any + expect(notFound.error).toContain('Asset not found') + }) + + test('returns error when startDate >= endDate', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-06-01', + endDate: '2024-01-01', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const err = result.results[0] as any + expect(err.error).toContain('startDate must be before endDate') + }) + + test('handles missing assetId and searchTerm', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{}], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const err = result.results[0] as any + expect(err.error).toContain('must have either assetId or searchTerm') + }) + + test('handles no CoinGecko mapping for asset', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ assetId: 'eip155:1/erc20:0xunmapped' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const err = result.results[0] as any + expect(err.error).toContain('No CoinGecko mapping') + }) + + test('fetches multiple assets in parallel', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }, { searchTerm: 'BTC' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(2) + const eth = result.results[0] as any + const btc = result.results[1] as any + expect(eth.symbol).toBe('ETH') + expect(btc.symbol).toBe('BTC') + expect(btc.startPrice).toBe(40000) + expect(btc.endPrice).toBe(44000) + }) + + test('converts timestamps from ms to seconds in dataPoints', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + const eth = result.results[0] as any + // Timestamps should be in seconds, not milliseconds + expect(eth.dataPoints[0].timestamp).toBeLessThan(10000000000) + }) + + test('propagates API errors with asset context', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'FAIL' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const err = result.results[0] as any + expect(err.assetId).toBe('eip155:1/erc20:0xfailcoin') + expect(err.error).toBeDefined() + }) + + test('returns single data point when dataPoints is 1', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'ETH' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 1, + }) + + const eth = result.results[0] as any + expect(eth.dataPoints).toHaveLength(1) + expect(eth.startPrice).toBe(eth.endPrice) + }) + + test('returns error when chart data has no prices', async () => { + const result = await executeGetHistoricalPrices({ + assets: [{ searchTerm: 'EMPTY' }], + startDate: '2024-01-01', + endDate: '2024-01-05', + dataPoints: 2, + }) + + expect(result.results).toHaveLength(1) + const err = result.results[0] as any + expect(err.error).toBeDefined() + }) +}) diff --git a/apps/agentic-server/src/tools/getAllowance.ts b/apps/agentic-server/src/tools/getAllowance.ts index e6a515c9..0a1a8633 100644 --- a/apps/agentic-server/src/tools/getAllowance.ts +++ b/apps/agentic-server/src/tools/getAllowance.ts @@ -8,7 +8,7 @@ export type GetAllowanceInput = z.infer export type GetAllowanceOutput = typeof getAllowanceOutput export async function executeGetAllowance(input: GetAllowanceInput) { - console.log('[getAllowance]:', input) + return getAllowance(input) } diff --git a/apps/agentic-server/src/tools/getAssetPrices.ts b/apps/agentic-server/src/tools/getAssetPrices.ts index 723df871..9c551fd6 100644 --- a/apps/agentic-server/src/tools/getAssetPrices.ts +++ b/apps/agentic-server/src/tools/getAssetPrices.ts @@ -31,7 +31,6 @@ export type GetAssetPricesOutput = { } export async function executeGetAssetPrices(input: GetAssetPricesInput): Promise { - console.log('[getAssetPrices]:', input) const assetIds: string[] = [] diff --git a/apps/agentic-server/src/tools/getAssets.ts b/apps/agentic-server/src/tools/getAssets.ts index fde0b3f4..6f235204 100644 --- a/apps/agentic-server/src/tools/getAssets.ts +++ b/apps/agentic-server/src/tools/getAssets.ts @@ -105,7 +105,6 @@ async function getAssetWithMarketData(input: GetAssetsInput): Promise { - console.log('[getAssets]:', input) const { searchTerm, assetId, contractAddress } = input diff --git a/apps/agentic-server/src/tools/getCategories.ts b/apps/agentic-server/src/tools/getCategories.ts index de9b0e71..95a1b0d2 100644 --- a/apps/agentic-server/src/tools/getCategories.ts +++ b/apps/agentic-server/src/tools/getCategories.ts @@ -19,7 +19,6 @@ export type GetCategoriesOutput = { } export async function executeGetCategories(input: GetCategoriesInput): Promise { - console.log('[getCategories]:', input) const sortBy = input.sortBy ?? 'market_cap_change_24h' const limit = input.limit ?? 10 diff --git a/apps/agentic-server/src/tools/getHistoricalPrices.ts b/apps/agentic-server/src/tools/getHistoricalPrices.ts new file mode 100644 index 00000000..bd82ab55 --- /dev/null +++ b/apps/agentic-server/src/tools/getHistoricalPrices.ts @@ -0,0 +1,141 @@ +import { NETWORKS } from '@shapeshiftoss/types' +import { getUnixTime, parseISO } from 'date-fns' +import { z } from 'zod' + +import { getMarketChartRange } from '../lib/asset/coingecko/api' +import { downsample } from '../lib/asset/coingecko/downsample' +import { assetIdToCoingecko, getAssetMeta, searchAsset } from '../lib/asset/resolveAsset' + +export const getHistoricalPricesSchema = z.object({ + assets: z + .array( + z.object({ + assetId: z.string().optional().describe('CAIP-19 assetId (e.g., "eip155:1/erc20:0xa0b8...")'), + searchTerm: z.string().optional().describe('Search by symbol or name (e.g., "ETH", "USDC", "Bitcoin")'), + network: z.enum(NETWORKS).optional().describe('Network to search on (e.g., "ethereum", "arbitrum")'), + }) + ) + .min(1) + .max(10) + .describe('Array of assets to get historical prices for (max 10)'), + startDate: z.string().describe('Start date as ISO 8601 string (e.g., "2024-01-01", "2024-06-15T00:00:00Z")'), + endDate: z.string().optional().describe('End date as ISO 8601 string. Defaults to now.'), + dataPoints: z + .number() + .int() + .min(1) + .max(30) + .default(2) + .describe('Number of evenly-spaced price points to return (1-30, default 2 for start/end comparison)'), +}) + +export type GetHistoricalPricesInput = z.infer + +type AssetPriceResult = { + assetId: string + symbol: string + name: string + dataPoints: { timestamp: number; price: number }[] + startPrice: number + endPrice: number + percentChange: number +} + +type AssetPriceError = { + searchTerm?: string + assetId?: string + error: string +} + +export type GetHistoricalPricesOutput = { + results: (AssetPriceResult | AssetPriceError)[] +} + +export async function executeGetHistoricalPrices(input: GetHistoricalPricesInput): Promise { + + const startUnix = getUnixTime(parseISO(input.startDate)) + const endUnix = input.endDate ? getUnixTime(parseISO(input.endDate)) : Math.floor(Date.now() / 1000) + + if (startUnix >= endUnix) { + return { results: [{ error: 'startDate must be before endDate' }] } + } + + type ResolvedAsset = { assetId: string; coinGeckoId: string; symbol: string; name: string } + const resolved: (ResolvedAsset | AssetPriceError)[] = input.assets.map(assetInput => { + let assetId: string + + if (assetInput.assetId) { + assetId = assetInput.assetId + } else if (assetInput.searchTerm) { + const result = searchAsset(assetInput.searchTerm, { network: assetInput.network }) + if (!result) { + return { + searchTerm: assetInput.searchTerm, + error: `Asset not found: ${assetInput.searchTerm}${assetInput.network ? ` on ${assetInput.network}` : ''}`, + } + } + assetId = result.assetId + } else { + return { error: 'Each asset must have either assetId or searchTerm' } + } + + const coinGeckoId = assetIdToCoingecko(assetId) + if (!coinGeckoId) { + return { assetId, error: `No CoinGecko mapping for asset: ${assetId}` } + } + + const asset = getAssetMeta(assetId) + return { + assetId, + coinGeckoId, + symbol: asset?.symbol ?? 'UNKNOWN', + name: asset?.name ?? 'Unknown', + } + }) + + const results: (AssetPriceResult | AssetPriceError)[] = await Promise.all( + resolved.map(async item => { + if ('error' in item) return item + + try { + const chartData = await getMarketChartRange(item.coinGeckoId, startUnix, endUnix) + const sampled = downsample(chartData.prices, input.dataPoints) + const dataPoints = sampled.map(([ts, price]) => ({ timestamp: Math.floor(ts / 1000), price })) + + if (dataPoints.length === 0) { + return { assetId: item.assetId, error: 'No price data available for this date range' } + } + + const startPrice = dataPoints[0]!.price + const endPrice = dataPoints[dataPoints.length - 1]!.price + const percentChange = startPrice !== 0 ? ((endPrice - startPrice) / startPrice) * 100 : 0 + + return { + assetId: item.assetId, + symbol: item.symbol, + name: item.name, + dataPoints, + startPrice, + endPrice, + percentChange: Math.round(percentChange * 100) / 100, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { assetId: item.assetId, error: `Failed to fetch historical data: ${message}` } + } + }) + ) + + return { results } +} + +export const getHistoricalPricesTool = { + description: `Get historical price data for assets over a date range. Returns evenly-spaced price points plus start/end price and percent change. Use for questions like "what was ETH worth 2 months ago?" or "how much has BTC grown since January?". + +Examples: +- { assets: [{ searchTerm: "ETH" }], startDate: "2024-01-01" } +- { assets: [{ searchTerm: "BTC" }, { searchTerm: "ETH" }], startDate: "2024-01-01", endDate: "2024-06-01", dataPoints: 10 } +- { assets: [{ assetId: "eip155:1/slip44:60" }], startDate: "2024-06-01", dataPoints: 5 }`, + inputSchema: getHistoricalPricesSchema, + execute: executeGetHistoricalPrices, +} diff --git a/apps/agentic-server/src/tools/getNewCoins.ts b/apps/agentic-server/src/tools/getNewCoins.ts index 1d14bf92..c1c3fec9 100644 --- a/apps/agentic-server/src/tools/getNewCoins.ts +++ b/apps/agentic-server/src/tools/getNewCoins.ts @@ -17,7 +17,6 @@ export type GetNewCoinsOutput = { } export async function executeGetNewCoins(input: GetNewCoinsInput): Promise { - console.log('[getNewCoins]:', input) const limit = input.limit ?? 5 const data = await getNewCoins() diff --git a/apps/agentic-server/src/tools/getPriceFeedTokens.ts b/apps/agentic-server/src/tools/getPriceFeedTokens.ts index 636d5c5c..be07ab68 100644 --- a/apps/agentic-server/src/tools/getPriceFeedTokens.ts +++ b/apps/agentic-server/src/tools/getPriceFeedTokens.ts @@ -18,7 +18,6 @@ export type GetPriceFeedTokensOutput = { } export function executeGetPriceFeedTokens(input: GetPriceFeedTokensInput): GetPriceFeedTokensOutput { - console.log('[getPriceFeedTokens]:', input) const chainId = NETWORK_TO_CHAIN_ID[input.network] as number const tokens = [...getSupportedOracleTokens(chainId)].sort((a, b) => a.localeCompare(b)) diff --git a/apps/agentic-server/src/tools/getTopGainersLosers.ts b/apps/agentic-server/src/tools/getTopGainersLosers.ts index bd564817..018097c7 100644 --- a/apps/agentic-server/src/tools/getTopGainersLosers.ts +++ b/apps/agentic-server/src/tools/getTopGainersLosers.ts @@ -22,7 +22,6 @@ export type GetTopGainersLosersOutput = { } export async function executeGetTopGainersLosers(input: GetTopGainersLosersInput): Promise { - console.log('[getTopGainersLosers]:', input) const duration = input.duration ?? '24h' const limit = input.limit ?? 5 diff --git a/apps/agentic-server/src/tools/getTrendingPools.ts b/apps/agentic-server/src/tools/getTrendingPools.ts index c308e9b5..0e4886c2 100644 --- a/apps/agentic-server/src/tools/getTrendingPools.ts +++ b/apps/agentic-server/src/tools/getTrendingPools.ts @@ -19,7 +19,6 @@ export type GetTrendingPoolsOutput = { } export async function executeGetTrendingPools(input: GetTrendingPoolsInput): Promise { - console.log('[getTrendingPools]:', input) const duration = input.duration ?? '24h' const limit = input.limit ?? 5 diff --git a/apps/agentic-server/src/tools/getTrendingTokens.ts b/apps/agentic-server/src/tools/getTrendingTokens.ts index 5e0000f0..20539012 100644 --- a/apps/agentic-server/src/tools/getTrendingTokens.ts +++ b/apps/agentic-server/src/tools/getTrendingTokens.ts @@ -16,7 +16,6 @@ export type GetTrendingTokensOutput = { } export async function executeGetTrendingTokens(input: GetTrendingTokensInput): Promise { - console.log('[getTrendingTokens]:', input) const limit = input.limit ?? 5 const data = await getTrendingSearch() diff --git a/apps/agentic-server/src/tools/mathCalculator.ts b/apps/agentic-server/src/tools/mathCalculator.ts index f82d318d..b643c761 100644 --- a/apps/agentic-server/src/tools/mathCalculator.ts +++ b/apps/agentic-server/src/tools/mathCalculator.ts @@ -30,7 +30,6 @@ export const mathCalculator = { execute: (input: MathCalculatorInput): MathCalculatorOutput => { const { expression, precision } = input - console.log('[mathCalculator]:', { expression, precision }) try { // Validate expression safety before evaluation diff --git a/apps/agentic-server/src/tools/receive.ts b/apps/agentic-server/src/tools/receive.ts index 25b79c98..2c6750ef 100644 --- a/apps/agentic-server/src/tools/receive.ts +++ b/apps/agentic-server/src/tools/receive.ts @@ -6,7 +6,6 @@ import { getAddressForChain } from '../utils/walletContextSimple' import type { WalletContext } from '../utils/walletContextSimple' export async function executeReceive(input: ReceiveInput, walletContext?: WalletContext): Promise { - console.log('[receive]:', input) const asset = await resolveAsset(input.asset, walletContext) diff --git a/apps/agentic-server/src/tools/send.ts b/apps/agentic-server/src/tools/send.ts index 91299edf..62643ba2 100644 --- a/apps/agentic-server/src/tools/send.ts +++ b/apps/agentic-server/src/tools/send.ts @@ -22,7 +22,6 @@ const SOLANA_RPC_URL = (() => { })() export async function executeSend(input: SendInput, walletContext?: WalletContext): Promise { - console.log('[send]:', input) // 1. Resolve asset (prioritize tokens user owns) const asset = await resolveAsset(input.asset, walletContext) diff --git a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts index 1785d164..706ce386 100644 --- a/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts +++ b/apps/agentic-server/src/tools/stopLoss/createStopLoss.ts @@ -99,7 +99,7 @@ export async function executeCreateStopLoss( input: CreateStopLossInput, walletContext?: WalletContext ): Promise { - console.log('[createStopLoss] raw input:', JSON.stringify(input)) + const evmChainId = NETWORK_TO_CHAIN_ID[input.network]! // Validate Safe address is available on the target chain diff --git a/apps/agentic-server/src/tools/switchNetwork.ts b/apps/agentic-server/src/tools/switchNetwork.ts index 004a3592..956a5c65 100644 --- a/apps/agentic-server/src/tools/switchNetwork.ts +++ b/apps/agentic-server/src/tools/switchNetwork.ts @@ -13,7 +13,6 @@ export type SwitchNetworkOutput = { } export function executeSwitchNetwork(input: SwitchNetworkInput): SwitchNetworkOutput { - console.log('[switchNetwork]:', input) return { network: input.network,