From ab19e27ad5cfff59a5bd51fac902ea8f859f5712 Mon Sep 17 00:00:00 2001 From: gapview01 <107860548+gapview01@users.noreply.github.com> Date: Sun, 10 May 2026 22:28:23 +1000 Subject: [PATCH 1/4] =?UTF-8?q?feat(sdk):=20R1=20truthfulness=20=E2=80=94?= =?UTF-8?q?=20earnCompare=20+=20tokenReceive=20+=20perps=20DISCOVERY=5FONL?= =?UTF-8?q?Y=20guard=20(v0.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @toreva/sdk 0.1.1 → 0.2.0 and ships honest R1 surface area: - New earnCompare({ asset:'USDC', venue:'kamino'|'marginfi' }) — calls live mcp.toreva.com /mcp JSON-RPC tools/call. Returns canonical APY snapshot with the evidence triple (readEvidenceId, venueIntelligenceReceiptId, sentinelReviewReceiptId) for downstream audit. - New tokenReceive({ wallet, limit }) — calls toreva_token_receive_scan. Returns recent inbound SPL token transfers, evidence-attached. - perps.ts: every exported function (PerpsApi.call, openLong, openShort, closePosition, addMargin, removeMargin, cancelOrder) now throws DISCOVERY_ONLY with a roadmap link instead of silently returning CLASS_A_PENDING. Type exports retained for IDE discoverability. - @toreva/mcp tools/perps.ts: perpsToolDefinitions[].discovery_only=true flag so the surface advertises honestly that the perps tool/call path is gated until R3. - README.md: per-family operational matrix sourced from gateway DoD matrix v1. Every row tagged DISCOVERY_ONLY / NOT YET OPERATIONAL where applicable. Tier definitions T0–T5. R1 is exactly 3 primitives. - package.json description rewritten to truthful R1 framing; keywords trimmed to remove perps overclaims and add kamino/marginfi/spl-token. - tsconfig: exclude __tests__ from publish dist. Tests: vitest packages/sdk/src/__tests__/r1.test.ts — 16 cases covering shape, runtime venue/asset rejection, JSON-RPC envelope, error paths, DISCOVERY_ONLY guard for every perps export. All green (94/94 across kit). Closes the SDK over-claim risk flagged in plan addendum 2 Phase A. Flips two DoD matrix gates ✅ for earn_lending and token_ops: - sdk_kit_wired_and_tested (file present + tests pass) The CLI gate is unblocked by the companion CLI commit. Published: @toreva/sdk@0.2.0 → npm registry (latest tag). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp/src/tools/perps.ts | 14 +- packages/sdk/README.md | 140 +++++++++++++ packages/sdk/package.json | 10 +- packages/sdk/src/__tests__/r1.test.ts | 283 ++++++++++++++++++++++++++ packages/sdk/src/earn.ts | 145 +++++++++++++ packages/sdk/src/index.ts | 2 + packages/sdk/src/perps.ts | 89 +++++++- packages/sdk/src/tokens.ts | 135 ++++++++++++ packages/sdk/tsconfig.json | 4 + 9 files changed, 807 insertions(+), 15 deletions(-) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/src/__tests__/r1.test.ts create mode 100644 packages/sdk/src/earn.ts create mode 100644 packages/sdk/src/tokens.ts diff --git a/packages/mcp/src/tools/perps.ts b/packages/mcp/src/tools/perps.ts index 1101de4..5eab816 100644 --- a/packages/mcp/src/tools/perps.ts +++ b/packages/mcp/src/tools/perps.ts @@ -1,9 +1,21 @@ import { PERPS_RELAY_TYPES, perpsToolSchemas, type PerpsToolName, type RelayRequest } from '@toreva/types'; +/** + * R1 truthfulness flag (2026-05-10). + * + * Every perps tool below is `discovery_only: true`. The MCP surface advertises + * them so callers can introspect schemas (`tools/list`), but `tools/call` + * routes via the gateway relay which returns CLASS_A_PENDING for any perps + * verb today. Execution lands in R3 once Sentinel adversarial review and + * Risk admission complete for at least one perps venue. + */ +export const PERPS_DISCOVERY_ONLY = true; + export const perpsToolDefinitions = (Object.keys(perpsToolSchemas) as PerpsToolName[]).map((toolName) => ({ name: toolName, relayType: PERPS_RELAY_TYPES[toolName], - inputSchema: perpsToolSchemas[toolName] + inputSchema: perpsToolSchemas[toolName], + discovery_only: PERPS_DISCOVERY_ONLY, })); export function toPerpsRelayRequest(toolName: PerpsToolName, payload: unknown): RelayRequest { diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..a26ad3b --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,140 @@ +# @toreva/sdk + +MCP-native Solana primitive platform — TypeScript SDK. + +> **Truthfulness rule.** This README is generated against the gateway's +> [operational definition-of-done matrix](https://github.com/toreva/gateway/blob/main/docs/operational-definition-of-done-matrix.v1.md). +> Every row maps to live evidence in that matrix. If a primitive is not in +> the table, it does not exist. If it says `DISCOVERY_ONLY`, the SDK call +> throws — by design — until the underlying admission gates close. + +## Install + +```bash +pnpm add @toreva/sdk +# or +npm install @toreva/sdk +``` + +## Quick start (R1 — read-only) + +```ts +import { earnCompare, tokenReceive } from '@toreva/sdk'; + +// Compare current Kamino USDC lending APY (calls live mcp.toreva.com) +const r = await earnCompare({ asset: 'USDC', venue: 'kamino' }); +console.log(r.apyPct, r.evidenceRef.sentinelReviewReceiptId); + +// Scan a wallet for recent inbound SPL token transfers +const t = await tokenReceive({ wallet: 'YOUR_WALLET_PUBKEY', limit: 10 }); +console.log(t.count, t.receives[0]?.signature); +``` + +Default endpoint is `https://mcp.toreva.com`. Override with `mcpUrl` or set +`TOREVA_API_KEY` (defaults to a public synthetic litmus key for read-only). + +## Per-family operational matrix (live state) + +Source of truth: gateway `docs/operational-definition-of-done-matrix.v1.md`. +Last regenerated: 2026-05-10. + +| Family | Tier | SDK status | Notes | +| ------------------------- | ---- | --------------------------------- | --------------------------------------------------------------- | +| earn_lending | T1→T2| `earnCompare` (read-only, R1) | Kamino + Marginfi USDC compare live on mcp.toreva.com | +| token_ops | T1→T2| `tokenReceive` (read-only, R1) | SPL token receive scan live on mcp.toreva.com | +| perps | T0 | DISCOVERY_ONLY | SDK throws on call. Execution lands in R3. | +| options | T0 | NOT YET OPERATIONAL | No venue admitted. | +| swap_route | T0 | NOT YET OPERATIONAL | Jupiter venue plan only. | +| advanced_orders_dca | T0 | NOT YET OPERATIONAL | Jupiter venue plan only. | +| wallet_session_funding | T0 | NOT YET OPERATIONAL | No venue admitted. | +| commerce_billing | T0 | NOT YET OPERATIONAL | Solana Pay venue plan only. | +| staking | T0 | NOT YET OPERATIONAL | Jito venue plan only. | +| prediction_markets | T0 | NOT YET OPERATIONAL | DePredict venue plan only. | +| nft | T0 | NOT YET OPERATIONAL | No venue admitted. | +| governance | T0 | NOT YET OPERATIONAL | Realms venue plan only. | +| claims | T0 | NOT YET OPERATIONAL | No venue admitted. | +| vault | T0 | NOT YET OPERATIONAL | Kamino-vaults venue plan only. | +| lp_liquidity | T0 | NOT YET OPERATIONAL | Orca-whirlpools venue plan only. | +| bridge_wrap | T0 | NOT YET OPERATIONAL | Wormhole venue plan only. | +| market_data | T0 | NOT YET OPERATIONAL | Birdeye + Rugcheck venue plan only. | +| balance_simulate_compare | T0 | NOT YET OPERATIONAL | Jupiter feeds venue plan only. | + +Tier definitions: + +- **T0** — catalogued only (venue plan exists; admission gates not closed) +- **T1** — venue admitted (sentinel + risk + venue intelligence all pass) +- **T2** — read-only operational (this is where R1 lands earn_lending + + token_ops) +- **T3** — write-operational on mainnet +- **T4** — first-party + machine-marketed +- **T5** — full GREEN (all 21 actionable gates pass) + +## R1 — what's live today + +R1 ships exactly three primitives that pass all admission gates: + +1. **`earnCompare({ asset: 'USDC', venue: 'kamino' })`** — calls + `toreva_earn_compare_kamino` MCP tool. Returns current APY snapshot from + DefiLlama for the Kamino USDC pool, with the evidence triple + (`readEvidenceId`, `venueIntelligenceReceiptId`, + `sentinelReviewReceiptId`) for downstream audit. +2. **`earnCompare({ asset: 'USDC', venue: 'marginfi' })`** — same, Marginfi + pool. +3. **`tokenReceive({ wallet, limit })`** — calls + `toreva_token_receive_scan` MCP tool. Returns recent inbound SPL token + transfers for a wallet, evidence-attached. + +Every other call surface throws `DISCOVERY_ONLY` or is absent from the SDK. + +## R2-R5 roadmap (truthful, per-family) + +R2-R5 unlock follows the per-family matrix. Each family advances tier-by-tier +as its admission gates close: + +- **R2** — read-only adapters for the next 3-5 families + (likely staking, swap_route, market_data candidates depending on venue + intelligence ETA). Read primitives only — no signing, no state changes. +- **R3** — first write-operational family on mainnet, with first-party use + flag and Class A canary approval. Perps execution candidate. +- **R4** — first family at full GREEN (T5) — all 21 gates closed, including + affiliate program, brand announce, friendly-prospect feedback, treasury + receives funds. +- **R5** — all 18 families at T5. + +Live tier rollup: see gateway `pnpm tier-rollup`. + +## Authentication + +R1 uses bearer-token auth against `mcp.toreva.com/mcp`. Default key (public +synthetic, read-only): + +``` +tk_litmus_r1_synthetic_3rd_party_trading_bot_demo_only +``` + +Set `TOREVA_API_KEY` env var to override. Production keys are provisioned +via the IAM agent on a per-consumer basis. + +## Discovery & introspection + +The MCP endpoint is fully introspectable: + +```bash +curl -X POST https://mcp.toreva.com/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +`GET https://mcp.toreva.com/health` returns the service banner with the +list of admitted primitives. + +## Why MCP-native + +Toreva is built for AI agents first. Every primitive is exposed as an MCP +tool that any MCP-aware client (Claude Desktop, Cursor, OpenClaw) can call. +The SDK is a typed wrapper around those tools — same wire format, stronger +ergonomics for TypeScript apps. + +## License + +MIT. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a76c7ff..ffffac9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,11 +1,11 @@ { "name": "@toreva/sdk", - "version": "0.1.1", - "description": "Non-custodial execution primitives for Solana. Best-execution routing across Jupiter Perps, Pacifica, Drift, and Flash Trade. 1 bps to open. Everything else is free.", + "version": "0.2.0", + "description": "MCP-native Solana primitive platform. R1 (today): read-only Kamino + Marginfi APY compare and SPL token receive detection. R2-R5: family-by-family rollout. See toreva.com/operational-matrix for current state.", "keywords": [ - "solana", "defi", "perps", "perpetual-futures", "yield", - "non-custodial", "jupiter", "drift", "execution-primitives", - "sdk" + "solana", "defi", "kamino", "marginfi", "spl-token", + "non-custodial", "mcp", "agent-execution", "agent-skills", + "read-only", "discovery", "earn", "lending", "apy", "sdk" ], "repository": { "type": "git", diff --git a/packages/sdk/src/__tests__/r1.test.ts b/packages/sdk/src/__tests__/r1.test.ts new file mode 100644 index 0000000..0a77bb9 --- /dev/null +++ b/packages/sdk/src/__tests__/r1.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from 'vitest'; +import { earnCompare, type EarnCompareResult } from '../earn.js'; +import { tokenReceive, type TokenReceiveResult } from '../tokens.js'; +import { + PerpsApi, + openLong, + openShort, + closePosition, + addMargin, + removeMargin, + cancelOrder, +} from '../perps.js'; +import { TorevaClient } from '../client.js'; + +// --------------------------------------------------------------------------- +// Helpers — build a fake fetch that returns a JSON-RPC tools/call response. +// --------------------------------------------------------------------------- + +function makeMockFetch(structuredContent: T, opts: { ok?: boolean; isError?: boolean } = {}) { + const ok = opts.ok ?? true; + const isError = opts.isError ?? false; + return vi.fn().mockResolvedValue({ + ok, + status: ok ? 200 : 500, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + result: { structuredContent, isError }, + }), + } as unknown as Response); +} + +// --------------------------------------------------------------------------- +// earnCompare +// --------------------------------------------------------------------------- + +describe('earnCompare', () => { + const baseResult: EarnCompareResult = { + ok: true, + venue: 'kamino', + asset: 'USDC', + apyPct: 4.36, + apyBasePct: 4.36, + apyRewardPct: null, + tvlUsd: 7_799_705, + underlyingTokens: ['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + chain: 'Solana', + project: 'kamino-lend', + poolId: 'd2141a59-c199-4be7-8d4b-c8223954836b', + source: 'defillama', + sourceUrl: 'https://yields.llama.fi/pools', + fetchedAt: '2026-05-10T12:20:09.082Z', + evidenceRef: { + readEvidenceId: 'NETSOL-READ-kamino-earn-compare-2026-05-08-slot-418488624', + venueIntelligenceReceiptId: 'VS-kamino-earn-compare-2026-05-08', + sentinelReviewReceiptId: 'SENT-REVIEW-kamino-earn-compare-2026-05-08', + }, + tool: 'toreva_earn_compare_kamino', + familyId: 'earn_lending', + primitiveId: 'exec.earn_compare', + operation: 'compare', + latencyMs: 1263, + }; + + it('returns the canonical EarnCompareResult shape for kamino USDC', async () => { + const fetchImpl = makeMockFetch(baseResult); + const r = await earnCompare( + { asset: 'USDC', venue: 'kamino' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ); + + expect(r.ok).toBe(true); + expect(r.venue).toBe('kamino'); + expect(r.asset).toBe('USDC'); + expect(typeof r.apyPct).toBe('number'); + expect(r.source).toBe('defillama'); + expect(r.evidenceRef).toEqual({ + readEvidenceId: expect.stringContaining('NETSOL-READ'), + venueIntelligenceReceiptId: expect.stringContaining('VS-'), + sentinelReviewReceiptId: expect.stringContaining('SENT-REVIEW'), + }); + expect(r.tool).toBe('toreva_earn_compare_kamino'); + expect(r.familyId).toBe('earn_lending'); + }); + + it('returns marginfi shape when venue=marginfi', async () => { + const marginfiResult: EarnCompareResult = { + ...baseResult, + venue: 'marginfi', + project: 'marginfi', + tool: 'toreva_earn_compare_marginfi', + }; + const fetchImpl = makeMockFetch(marginfiResult); + const r = await earnCompare( + { asset: 'USDC', venue: 'marginfi' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ); + expect(r.venue).toBe('marginfi'); + expect(r.tool).toBe('toreva_earn_compare_marginfi'); + }); + + it('rejects venues outside the admitted set at runtime', async () => { + const fetchImpl = makeMockFetch(baseResult); + await expect( + earnCompare( + // @ts-expect-error — TypeScript should also reject 'save' + { asset: 'USDC', venue: 'save' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/venue "save" not in admitted set/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects non-USDC assets at runtime', async () => { + const fetchImpl = makeMockFetch(baseResult); + await expect( + earnCompare( + // @ts-expect-error — only USDC is admitted in R1 + { asset: 'SOL', venue: 'kamino' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/asset "SOL" not supported/); + }); + + it('throws on HTTP error', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: false, + status: 502, + json: async () => ({}), + } as unknown as Response); + await expect( + earnCompare( + { asset: 'USDC', venue: 'kamino' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/HTTP 502/); + }); + + it('throws when MCP returns isError', async () => { + const fetchImpl = makeMockFetch(baseResult, { isError: true }); + await expect( + earnCompare( + { asset: 'USDC', venue: 'kamino' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/error payload/); + }); + + it('uses the JSON-RPC tools/call envelope with the right tool name', async () => { + const fetchImpl = makeMockFetch(baseResult); + await earnCompare( + { asset: 'USDC', venue: 'kamino' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ); + const call = (fetchImpl as ReturnType).mock.calls[0]; + expect(call?.[0]).toMatch(/\/mcp$/); + const body = JSON.parse((call?.[1] as RequestInit)?.body as string); + expect(body.jsonrpc).toBe('2.0'); + expect(body.method).toBe('tools/call'); + expect(body.params.name).toBe('toreva_earn_compare_kamino'); + }); +}); + +// --------------------------------------------------------------------------- +// tokenReceive +// --------------------------------------------------------------------------- + +describe('tokenReceive', () => { + const baseResult: TokenReceiveResult = { + ok: true, + wallet: 'TestWallet1111111111111111111111111111111111', + receives: [ + { + signature: '4xF2...sig', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: 100, + decimals: 6, + fromWallet: 'Sender11111111111111111111111111111111111111', + blockTime: '2026-05-10T11:50:00.000Z', + slot: 418_488_700, + }, + ], + count: 1, + source: 'solana-rpc', + fetchedAt: '2026-05-10T12:20:09.082Z', + evidenceRef: { + readEvidenceId: 'NETSOL-READ-spl-token-receive-2026-05-08', + venueIntelligenceReceiptId: 'VS-spl-token-receive-2026-05-08', + sentinelReviewReceiptId: 'SENT-REVIEW-spl-token-receive-2026-05-08', + }, + tool: 'toreva_token_receive_scan', + familyId: 'token_ops', + primitiveId: 'exec.receive', + operation: 'scan', + latencyMs: 600, + }; + + it('returns the canonical TokenReceiveResult shape', async () => { + const fetchImpl = makeMockFetch(baseResult); + const r = await tokenReceive( + { wallet: baseResult.wallet, limit: 10 }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ); + expect(r.ok).toBe(true); + expect(r.wallet).toBe(baseResult.wallet); + expect(Array.isArray(r.receives)).toBe(true); + expect(r.evidenceRef.sentinelReviewReceiptId).toMatch(/SENT-REVIEW/); + expect(r.familyId).toBe('token_ops'); + expect(r.tool).toBe('toreva_token_receive_scan'); + }); + + it('rejects empty wallet', async () => { + const fetchImpl = makeMockFetch(baseResult); + await expect( + tokenReceive( + { wallet: '' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/wallet/); + }); + + it('rejects out-of-range limit', async () => { + const fetchImpl = makeMockFetch(baseResult); + await expect( + tokenReceive( + { wallet: baseResult.wallet, limit: 9999 }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/limit/); + }); + + it('passes wallet + limit through to MCP arguments', async () => { + const fetchImpl = makeMockFetch(baseResult); + await tokenReceive( + { wallet: baseResult.wallet, limit: 5 }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ); + const call = (fetchImpl as ReturnType).mock.calls[0]; + const body = JSON.parse((call?.[1] as RequestInit)?.body as string); + expect(body.params.name).toBe('toreva_token_receive_scan'); + expect(body.params.arguments).toEqual({ wallet: baseResult.wallet, limit: 5 }); + }); +}); + +// --------------------------------------------------------------------------- +// perps DISCOVERY_ONLY +// --------------------------------------------------------------------------- + +describe('perps DISCOVERY_ONLY guard', () => { + it('PerpsApi.call throws DISCOVERY_ONLY error', async () => { + const client = new TorevaClient({ relayAuthToken: 'irrelevant' }); + const api = new PerpsApi(client); + expect(() => + api.call('toreva_perps_open_long' as never, {} as never) + ).toThrow(/DISCOVERY_ONLY/); + }); + + it('openLong throws DISCOVERY_ONLY', () => { + expect(() => openLong({})).toThrow(/DISCOVERY_ONLY/); + }); + + it('openShort throws DISCOVERY_ONLY', () => { + expect(() => openShort({})).toThrow(/DISCOVERY_ONLY/); + }); + + it('closePosition throws DISCOVERY_ONLY', () => { + expect(() => closePosition({})).toThrow(/DISCOVERY_ONLY/); + }); + + it('addMargin / removeMargin / cancelOrder throw DISCOVERY_ONLY', () => { + expect(() => addMargin({})).toThrow(/DISCOVERY_ONLY/); + expect(() => removeMargin({})).toThrow(/DISCOVERY_ONLY/); + expect(() => cancelOrder({})).toThrow(/DISCOVERY_ONLY/); + }); + + it('error message points to roadmap URL', () => { + try { + openLong({}); + } catch (err) { + expect((err as Error).message).toMatch(/toreva\.com\/roadmap/); + } + }); +}); diff --git a/packages/sdk/src/earn.ts b/packages/sdk/src/earn.ts new file mode 100644 index 0000000..74f96c3 --- /dev/null +++ b/packages/sdk/src/earn.ts @@ -0,0 +1,145 @@ +/** + * R1 GREEN primitive: read-only USDC lending APY compare. + * + * Calls `mcp.toreva.com` JSON-RPC `tools/call` with the venue-specific tool + * name (`toreva_earn_compare_kamino` or `toreva_earn_compare_marginfi`). + * + * This is a thin, type-safe wrapper around the canonical MCP endpoint. + * Production traffic must call mcp.toreva.com directly; tests mock fetch. + * + * Family: earn_lending. Operation: compare. Tier: T2 (read-only operational). + * + * Truthfulness rule: every response includes the `evidenceRef` triple + * (readEvidenceId, venueIntelligenceReceiptId, sentinelReviewReceiptId) + * so the caller can audit the chain back to the admission decision. + */ + +export type EarnCompareAsset = 'USDC'; +export type EarnCompareVenue = 'kamino' | 'marginfi'; + +export interface EarnCompareParams { + asset: EarnCompareAsset; + venue: EarnCompareVenue; +} + +export interface EarnCompareEvidenceRef { + readEvidenceId: string; + venueIntelligenceReceiptId: string; + sentinelReviewReceiptId: string; +} + +export interface EarnCompareResult { + ok: boolean; + venue: EarnCompareVenue; + asset: EarnCompareAsset; + apyPct: number; + apyBasePct?: number | null; + apyRewardPct?: number | null; + tvlUsd?: number | null; + underlyingTokens?: string[]; + chain?: string; + project?: string; + poolId?: string; + source: string; + sourceUrl?: string; + fetchedAt?: string; + evidenceRef: EarnCompareEvidenceRef; + tool: string; + familyId: string; + primitiveId: string; + operation: string; + latencyMs?: number; +} + +export interface EarnCompareOptions { + /** Override MCP base URL (default: https://mcp.toreva.com). */ + mcpUrl?: string; + /** Override API bearer token (default: TOREVA_API_KEY env, else public synthetic key). */ + apiKey?: string; + /** Override fetch (test seam). */ + fetchImpl?: typeof fetch; +} + +const DEFAULT_MCP_URL = 'https://mcp.toreva.com'; +const PUBLIC_SYNTHETIC_KEY = 'tk_litmus_r1_synthetic_3rd_party_trading_bot_demo_only'; + +const TOOL_NAMES: Record = { + kamino: 'toreva_earn_compare_kamino', + marginfi: 'toreva_earn_compare_marginfi', +}; + +/** + * Compare current APY for `asset` on a single admitted lending `venue`. + * + * Throws if `venue` is not in the admitted set ({'kamino' | 'marginfi'}) or + * if the MCP call fails. On success returns the structured APY snapshot + * plus the evidence triple required for downstream audit. + * + * @example + * ```ts + * const r = await earnCompare({ asset: 'USDC', venue: 'kamino' }); + * console.log(r.apyPct, r.evidenceRef.sentinelReviewReceiptId); + * ``` + */ +export async function earnCompare( + params: EarnCompareParams, + options: EarnCompareOptions = {} +): Promise { + const toolName = TOOL_NAMES[params.venue]; + if (!toolName) { + throw new Error( + `earnCompare: venue "${params.venue}" not in admitted set. R1 supports: ${Object.keys(TOOL_NAMES).join(', ')}.` + ); + } + if (params.asset !== 'USDC') { + throw new Error( + `earnCompare: asset "${params.asset}" not supported. R1 supports USDC only.` + ); + } + + const mcpUrl = (options.mcpUrl ?? DEFAULT_MCP_URL).replace(/\/+$/, ''); + const apiKey = options.apiKey ?? process.env.TOREVA_API_KEY ?? PUBLIC_SYNTHETIC_KEY; + const fetchImpl = options.fetchImpl ?? fetch; + + const response = await fetchImpl(`${mcpUrl}/mcp`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: toolName, + arguments: { asset: params.asset }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`earnCompare: MCP call failed with HTTP ${response.status}`); + } + + const body = (await response.json()) as { + jsonrpc?: string; + id?: number; + result?: { structuredContent?: EarnCompareResult; isError?: boolean }; + error?: { code: number; message: string }; + }; + + if (body.error) { + throw new Error(`earnCompare: MCP error ${body.error.code}: ${body.error.message}`); + } + + const structured = body.result?.structuredContent; + if (!structured) { + throw new Error('earnCompare: MCP response missing structuredContent'); + } + if (body.result?.isError) { + throw new Error(`earnCompare: MCP tool returned error payload`); + } + + return structured; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 88a6ec4..8080df7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,2 +1,4 @@ export * from './client.js'; +export * from './earn.js'; +export * from './tokens.js'; export * from './perps.js'; diff --git a/packages/sdk/src/perps.ts b/packages/sdk/src/perps.ts index feef1d3..0274ca3 100644 --- a/packages/sdk/src/perps.ts +++ b/packages/sdk/src/perps.ts @@ -1,14 +1,85 @@ -import { PERPS_RELAY_TYPES, type PerpsToolName, type RelayResponse } from '@toreva/types'; +import { + type PerpsToolInput, + type PerpsToolName, + type PerpsToolResult, + type RelayResponse, +} from '@toreva/types'; import { TorevaClient } from './client.js'; +/** + * DISCOVERY_ONLY guard for perps execution. + * + * As of v0.2.0 (R1, 2026-05-10) perps tools are MCP-discoverable but every + * `tools/call` returns CLASS_A_PENDING. Execution lands in R3 once Sentinel + * adversarial review and Risk admission complete for at least one perps + * venue (pacifica candidate). Until then any SDK invocation throws — better + * to fail fast at the caller than to silently return CLASS_A_PENDING. + * + * Read-only `query_*` tools are also gated here today; they will be unlocked + * in R2 once the read-only adapter ships. Use the MCP discovery surface + * directly (e.g. `mcp.toreva.com/mcp tools/list`) to inspect schemas now. + * + * Type exports below remain available for IDE discoverability and downstream + * compile-time integration. Runtime calls throw. + * + * Tracking: https://toreva.com/roadmap + */ + export class PerpsApi { - constructor(private readonly client: TorevaClient) {} - - call(toolName: PerpsToolName, payload: TPayload): Promise> { - return this.client.relay({ - type: PERPS_RELAY_TYPES[toolName], - toolName, - payload - }); + constructor(private readonly _client: TorevaClient) {} + + call( + toolName: TToolName, + _payload: PerpsToolInput + ): Promise>> { + throw new Error( + `DISCOVERY_ONLY: perps tool "${toolName}" cannot be executed via SDK in v0.2.0 (R1). ` + + `Perps execution lands in R3 once Sentinel adversarial review + Risk admission complete. ` + + `Use mcp.toreva.com/mcp tools/list for read-only discovery. See https://toreva.com/roadmap` + ); } } + +/** + * Helper aliases so callers using `import { openLong } from '@toreva/sdk'` + * style get the same DISCOVERY_ONLY error fast. R3 will replace these stubs + * with real implementations. + */ +function discoveryOnly(toolName: string): never { + throw new Error( + `DISCOVERY_ONLY: ${toolName} is not yet operational. ` + + `Perps execution lands in R3. Use toreva_perps_query_* tools for read-only discovery once R2 ships. ` + + `See https://toreva.com/roadmap` + ); +} + +export function openLong(_args?: unknown): never { + return discoveryOnly('openLong'); +} + +export function openShort(_args?: unknown): never { + return discoveryOnly('openShort'); +} + +export function closePosition(_args?: unknown): never { + return discoveryOnly('closePosition'); +} + +export function addMargin(_args?: unknown): never { + return discoveryOnly('addMargin'); +} + +export function removeMargin(_args?: unknown): never { + return discoveryOnly('removeMargin'); +} + +export function cancelOrder(_args?: unknown): never { + return discoveryOnly('cancelOrder'); +} + +// Re-export types for IDE discoverability — type-only, no runtime cost. +export type { + PerpsToolInput, + PerpsToolName, + PerpsToolResult, +} from '@toreva/types'; diff --git a/packages/sdk/src/tokens.ts b/packages/sdk/src/tokens.ts new file mode 100644 index 0000000..bd8bf24 --- /dev/null +++ b/packages/sdk/src/tokens.ts @@ -0,0 +1,135 @@ +/** + * R1 GREEN primitive: read-only SPL token receive scan. + * + * Calls `mcp.toreva.com` JSON-RPC `tools/call` with `toreva_token_receive_scan`. + * Returns recent inbound SPL token transfers for a given wallet (read-only, + * no execution, no signing). + * + * Family: token_ops. Operation: scan. Tier: T2 (read-only operational). + */ + +export interface TokenReceiveParams { + /** Solana wallet address to scan. */ + wallet: string; + /** Max number of recent receives to return (default: 25). */ + limit?: number; +} + +export interface TokenReceiveEntry { + /** Solana transaction signature. */ + signature?: string; + /** SPL token mint address. */ + mint?: string; + /** Token amount (UI units). */ + amount?: number; + /** Token decimals. */ + decimals?: number; + /** Sender wallet address. */ + fromWallet?: string; + /** Block time (ISO). */ + blockTime?: string; + /** Slot number. */ + slot?: number; +} + +export interface TokenReceiveEvidenceRef { + readEvidenceId: string; + venueIntelligenceReceiptId: string; + sentinelReviewReceiptId: string; +} + +export interface TokenReceiveResult { + ok: boolean; + wallet: string; + receives: TokenReceiveEntry[]; + count: number; + source: string; + sourceUrl?: string; + fetchedAt?: string; + evidenceRef: TokenReceiveEvidenceRef; + tool: string; + familyId: string; + primitiveId: string; + operation: string; + latencyMs?: number; +} + +export interface TokenReceiveOptions { + mcpUrl?: string; + apiKey?: string; + fetchImpl?: typeof fetch; +} + +const DEFAULT_MCP_URL = 'https://mcp.toreva.com'; +const PUBLIC_SYNTHETIC_KEY = 'tk_litmus_r1_synthetic_3rd_party_trading_bot_demo_only'; +const TOOL_NAME = 'toreva_token_receive_scan'; + +/** + * Scan a Solana wallet for recent inbound SPL token transfers. + * + * Read-only: never signs, never sends. The MCP tool reads from the Solana + * RPC and returns a structured snapshot with the evidence triple for audit. + * + * @example + * ```ts + * const r = await tokenReceive({ wallet: '5N...abc', limit: 10 }); + * console.log(r.count, r.receives[0]?.signature); + * ``` + */ +export async function tokenReceive( + params: TokenReceiveParams, + options: TokenReceiveOptions = {} +): Promise { + if (!params.wallet || typeof params.wallet !== 'string') { + throw new Error('tokenReceive: wallet (string) is required'); + } + if (params.limit !== undefined && (params.limit < 1 || params.limit > 1000)) { + throw new Error('tokenReceive: limit must be between 1 and 1000'); + } + + const mcpUrl = (options.mcpUrl ?? DEFAULT_MCP_URL).replace(/\/+$/, ''); + const apiKey = options.apiKey ?? process.env.TOREVA_API_KEY ?? PUBLIC_SYNTHETIC_KEY; + const fetchImpl = options.fetchImpl ?? fetch; + + const args: Record = { wallet: params.wallet }; + if (params.limit !== undefined) args.limit = params.limit; + + const response = await fetchImpl(`${mcpUrl}/mcp`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: TOOL_NAME, arguments: args }, + }), + }); + + if (!response.ok) { + throw new Error(`tokenReceive: MCP call failed with HTTP ${response.status}`); + } + + const body = (await response.json()) as { + jsonrpc?: string; + id?: number; + result?: { structuredContent?: TokenReceiveResult; isError?: boolean }; + error?: { code: number; message: string }; + }; + + if (body.error) { + throw new Error(`tokenReceive: MCP error ${body.error.code}: ${body.error.message}`); + } + + const structured = body.result?.structuredContent; + if (!structured) { + throw new Error('tokenReceive: MCP response missing structuredContent'); + } + if (body.result?.isError) { + throw new Error('tokenReceive: MCP tool returned error payload'); + } + + return structured; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index cf9e1b1..fc78ece 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -6,5 +6,9 @@ }, "include": [ "src/**/*.ts" + ], + "exclude": [ + "src/**/__tests__/**", + "src/**/*.test.ts" ] } From c6ddba60f8178da7e1ecb38db1d9311b38921cc5 Mon Sep 17 00:00:00 2001 From: gapview01 <107860548+gapview01@users.noreply.github.com> Date: Sun, 10 May 2026 22:35:00 +1000 Subject: [PATCH 2/4] feat(cli): R1 earn-compare + token-receive commands; align SDK shapes to live MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of plan addendum 2. Adds two new CLI commands wiring the R1 read-only primitives end-to-end against the live mcp.toreva.com server. CLI changes (packages/cli): - New `toreva earn-compare --asset USDC --venue ` command in src/commands/earn-compare.ts. Calls SDK earnCompare(), formats result as a human table including the evidence triple. - New `toreva token-receive --wallet [--limit ]` command in src/commands/token-receive.ts. Calls SDK tokenReceive(), formats receive events with sender/mint/amount/signature columns and the evidence triple. - Wired both commands into src/index.ts dispatch + USAGE help. - Tests in src/__tests__/earn-compare.test.ts + token-receive.test.ts — arg parser cases, formatter renderings, command runner with SDK mocked. Mirrors the existing scan/doctor pattern; uses vi.mock('@toreva/sdk'). - Bumped @toreva/cli 0.1.1 → 0.2.0; description rewritten to truthful R1 framing; keywords trimmed. SDK shape alignment to live MCP (packages/sdk): - earnCompare: apyPct now `number | null` to match marginfi's "live but not-yet-decoded" payload (the live tool returns `apyPct: null` plus an `apyNote` string explaining the IDL deserialization is owned by network-sol). Added marginfi-specific fields (bankAccount, bankOwner, bankLamports, bankDataSha256, slot) to the result type. - tokenReceive: live MCP tool uses `walletAddress` (not `wallet`) as the argument name and returns `events[]` (not `receives[]`) with `inspectedSignatureCount` + `totalSignatureCountReturned` counts. SDK now sends the canonical `walletAddress` to MCP while keeping the ergonomic `wallet` parameter for callers, and surfaces both canonical fields and ergonomic aliases (wallet, count, receives) on the result. - tokenReceive: limit cap lowered 1000 → 25 to match the live MCP tool's documented max. - tokenReceive: error path now propagates the live MCP `isError` payload's upstream message (e.g. "walletAddress (...) is required") instead of a generic "error payload" string. - Bumped @toreva/sdk 0.2.0 → 0.2.1 to ship the shape fix. Verified end-to-end against mcp.toreva.com: $ TOREVA_API_KEY=tk_litmus_r1_synthetic_3rd_party_trading_bot_demo_only \ node packages/cli/dist/index.js earn-compare --asset USDC --venue kamino Venue kamino APY 4.0317% (real DefiLlama snapshot) Source defillama evidence triple: NETSOL-READ-... / VS-... / SENT-REVIEW-... Tests: 115/115 green (pnpm test). 20 new CLI cases. Builds clean. Published @toreva/sdk@0.2.1 → npm registry (latest tag). @toreva/cli is intentionally NOT published until full CLI publishing workflow lands (CLI ships per-monorepo via pnpx/npx today). Flips DoD matrix gates ✅: - sdk_kit_wired_and_tested for earn_lending + token_ops - cli_wired_and_tested for earn_lending + token_ops Combined with the existing T1 admission state, both families should advance T1 → T2 (read-only operational) on the next `pnpm regenerate-dod`. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/package.json | 10 +- .../cli/src/__tests__/earn-compare.test.ts | 114 ++++++++++++++++ .../cli/src/__tests__/token-receive.test.ts | 127 ++++++++++++++++++ packages/cli/src/commands/earn-compare.ts | 90 +++++++++++++ packages/cli/src/commands/token-receive.ts | 82 +++++++++++ packages/cli/src/index.ts | 21 ++- packages/sdk/package.json | 2 +- packages/sdk/src/__tests__/r1.test.ts | 75 ++++++++--- packages/sdk/src/earn.ts | 17 ++- packages/sdk/src/tokens.ts | 75 ++++++++--- 10 files changed, 568 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/__tests__/earn-compare.test.ts create mode 100644 packages/cli/src/__tests__/token-receive.test.ts create mode 100644 packages/cli/src/commands/earn-compare.ts create mode 100644 packages/cli/src/commands/token-receive.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index e0603c0..b24a9cc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,11 @@ { "name": "@toreva/cli", - "version": "0.1.1", - "description": "Non-custodial execution primitives for Solana. Best-execution routing across Jupiter Perps, Pacifica, Drift, and Flash Trade. 1 bps to open. Everything else is free.", + "version": "0.2.0", + "description": "Toreva CLI — MCP-native Solana primitive platform. R1: earn-compare and token-receive read-only commands callable against mcp.toreva.com. See toreva.com/operational-matrix.", "keywords": [ - "solana", "defi", "perps", "perpetual-futures", "yield", - "non-custodial", "jupiter", "drift", "execution-primitives", - "cli" + "solana", "defi", "kamino", "marginfi", "spl-token", + "non-custodial", "mcp", "agent-execution", "agent-skills", + "earn", "lending", "apy", "cli" ], "repository": { "type": "git", diff --git a/packages/cli/src/__tests__/earn-compare.test.ts b/packages/cli/src/__tests__/earn-compare.test.ts new file mode 100644 index 0000000..868bc90 --- /dev/null +++ b/packages/cli/src/__tests__/earn-compare.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + parseEarnCompareArgs, + formatEarnCompare, + runEarnCompareCommand, +} from '../commands/earn-compare.js'; +import type { EarnCompareResult } from '@toreva/sdk'; + +const baseResult: EarnCompareResult = { + ok: true, + venue: 'kamino', + asset: 'USDC', + apyPct: 4.3626, + apyBasePct: 4.3626, + apyRewardPct: null, + tvlUsd: 7_799_705, + underlyingTokens: ['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + chain: 'Solana', + project: 'kamino-lend', + poolId: 'd2141a59-c199-4be7-8d4b-c8223954836b', + source: 'defillama', + sourceUrl: 'https://yields.llama.fi/pools', + fetchedAt: '2026-05-10T12:20:09.082Z', + evidenceRef: { + readEvidenceId: 'NETSOL-READ-kamino-earn-compare-2026-05-08-slot-418488624', + venueIntelligenceReceiptId: 'VS-kamino-earn-compare-2026-05-08', + sentinelReviewReceiptId: 'SENT-REVIEW-kamino-earn-compare-2026-05-08', + }, + tool: 'toreva_earn_compare_kamino', + familyId: 'earn_lending', + primitiveId: 'exec.earn_compare', + operation: 'compare', + latencyMs: 1263, +}; + +vi.mock('@toreva/sdk', async () => { + const actual = await vi.importActual('@toreva/sdk'); + return { + ...actual, + earnCompare: vi.fn(async () => baseResult), + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('parseEarnCompareArgs', () => { + it('accepts --asset=USDC --venue=kamino', () => { + const r = parseEarnCompareArgs(['--asset=USDC', '--venue=kamino']); + expect(r).toEqual({ asset: 'USDC', venue: 'kamino' }); + }); + + it('accepts space-separated --asset USDC --venue marginfi', () => { + const r = parseEarnCompareArgs(['--asset', 'USDC', '--venue', 'marginfi']); + expect(r).toEqual({ asset: 'USDC', venue: 'marginfi' }); + }); + + it('throws on missing --asset', () => { + expect(() => parseEarnCompareArgs(['--venue=kamino'])).toThrow(/asset/); + }); + + it('throws on missing --venue', () => { + expect(() => parseEarnCompareArgs(['--asset=USDC'])).toThrow(/venue/); + }); + + it('rejects unsupported asset', () => { + expect(() => parseEarnCompareArgs(['--asset=SOL', '--venue=kamino'])).toThrow(/SOL/); + }); + + it('rejects unsupported venue', () => { + expect(() => parseEarnCompareArgs(['--asset=USDC', '--venue=save'])).toThrow(/save/); + }); +}); + +describe('formatEarnCompare', () => { + it('renders the canonical fields and the evidence triple', () => { + const out = formatEarnCompare(baseResult); + expect(out).toMatch(/Venue\s+kamino/); + expect(out).toMatch(/Asset\s+USDC/); + expect(out).toMatch(/APY\s+4\.3626%/); + expect(out).toMatch(/Source\s+defillama/); + expect(out).toMatch(/Pool ID\s+d2141a59/); + expect(out).toMatch(/sentinel\s+SENT-REVIEW/); + expect(out).toMatch(/venue intel\s+VS-/); + expect(out).toMatch(/read\s+NETSOL-READ/); + }); + + it('omits null reward APY line', () => { + const out = formatEarnCompare(baseResult); + expect(out).not.toMatch(/APY \(reward\)/); + }); + + it('renders TVL with thousands separator', () => { + const out = formatEarnCompare(baseResult); + expect(out).toMatch(/TVL \(USD\)\s+\$7,799,705/); + }); +}); + +describe('runEarnCompareCommand', () => { + it('calls earnCompare with parsed args and prints the table', async () => { + const sdk = await import('@toreva/sdk'); + const mockedEarnCompare = vi.mocked(sdk.earnCompare); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await runEarnCompareCommand(['--asset=USDC', '--venue=kamino']); + + expect(mockedEarnCompare).toHaveBeenCalledWith({ asset: 'USDC', venue: 'kamino' }); + const printed = logSpy.mock.calls.flat().join('\n'); + expect(printed).toMatch(/kamino/); + expect(printed).toMatch(/SENT-REVIEW/); + logSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/__tests__/token-receive.test.ts b/packages/cli/src/__tests__/token-receive.test.ts new file mode 100644 index 0000000..d704952 --- /dev/null +++ b/packages/cli/src/__tests__/token-receive.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + parseTokenReceiveArgs, + formatTokenReceive, + runTokenReceiveCommand, +} from '../commands/token-receive.js'; +import type { TokenReceiveResult } from '@toreva/sdk'; + +const event = { + signature: '4xF2abcd9876sig', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: 100, + decimals: 6, + fromWallet: 'Sender11111111111111111111111111111111111111', + blockTime: '2026-05-10T11:50:00.000Z', + slot: 418_488_700, +}; +const baseResult: TokenReceiveResult = { + ok: true, + walletAddress: 'TestWallet1111111111111111111111111111111111', + wallet: 'TestWallet1111111111111111111111111111111111', + inspectedSignatureCount: 1, + totalSignatureCountReturned: 1, + count: 1, + events: [event], + receives: [event], + source: 'solana-rpc', + fetchedAt: '2026-05-10T12:20:09.082Z', + evidenceRef: { + readEvidenceId: 'NETSOL-READ-spl-token-receive-2026-05-08', + venueIntelligenceReceiptId: 'VS-spl-token-receive-2026-05-08', + sentinelReviewReceiptId: 'SENT-REVIEW-spl-token-receive-2026-05-08', + }, + tool: 'toreva_token_receive_scan', + familyId: 'token_ops', + primitiveId: 'exec.receive', + operation: 'scan', + latencyMs: 600, +}; + +vi.mock('@toreva/sdk', async () => { + const actual = await vi.importActual('@toreva/sdk'); + return { + ...actual, + tokenReceive: vi.fn(async () => baseResult), + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('parseTokenReceiveArgs', () => { + it('accepts --wallet=', () => { + const r = parseTokenReceiveArgs(['--wallet=ABC123']); + expect(r).toEqual({ wallet: 'ABC123' }); + }); + + it('accepts --wallet --limit 25', () => { + const r = parseTokenReceiveArgs(['--wallet', 'ABC123', '--limit', '25']); + expect(r).toEqual({ wallet: 'ABC123', limit: 25 }); + }); + + it('throws on missing --wallet', () => { + expect(() => parseTokenReceiveArgs([])).toThrow(/wallet/); + }); + + it('rejects out-of-range limit (low)', () => { + expect(() => parseTokenReceiveArgs(['--wallet=ABC', '--limit=0'])).toThrow(/limit/); + }); + + it('rejects out-of-range limit (high — capped at 25 by live MCP)', () => { + expect(() => parseTokenReceiveArgs(['--wallet=ABC', '--limit=9999'])).toThrow(/limit/); + }); + + it('rejects non-numeric limit', () => { + expect(() => parseTokenReceiveArgs(['--wallet=ABC', '--limit=abc'])).toThrow(/limit/); + }); +}); + +describe('formatTokenReceive', () => { + it('renders the wallet, count, source, and evidence triple', () => { + const out = formatTokenReceive(baseResult); + expect(out).toMatch(/Wallet\s+TestWallet/); + expect(out).toMatch(/Receives\s+1/); + expect(out).toMatch(/Source\s+solana-rpc/); + expect(out).toMatch(/sentinel\s+SENT-REVIEW/); + expect(out).toMatch(/venue intel\s+VS-/); + expect(out).toMatch(/read\s+NETSOL-READ/); + }); + + it('renders a row per receive entry with truncated mint and signature', () => { + const out = formatTokenReceive(baseResult); + expect(out).toMatch(/2026-05-10T11:50:00\.000Z/); + expect(out).toMatch(/amount=100/); + expect(out).toMatch(/mint=EPjFWdd5/); + expect(out).toMatch(/sig=4xF2abcd/); + }); + + it('renders a friendly empty-state message when no receives', () => { + const empty: TokenReceiveResult = { + ...baseResult, + receives: [], + events: [], + count: 0, + totalSignatureCountReturned: 0, + }; + const out = formatTokenReceive(empty); + expect(out).toMatch(/no recent inbound SPL token transfers/); + }); +}); + +describe('runTokenReceiveCommand', () => { + it('calls tokenReceive with parsed args and prints the table', async () => { + const sdk = await import('@toreva/sdk'); + const mockedTokenReceive = vi.mocked(sdk.tokenReceive); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await runTokenReceiveCommand(['--wallet=ABC123', '--limit=10']); + + expect(mockedTokenReceive).toHaveBeenCalledWith({ wallet: 'ABC123', limit: 10 }); + const printed = logSpy.mock.calls.flat().join('\n'); + expect(printed).toMatch(/TestWallet/); + expect(printed).toMatch(/SENT-REVIEW/); + logSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/earn-compare.ts b/packages/cli/src/commands/earn-compare.ts new file mode 100644 index 0000000..4a0ff14 --- /dev/null +++ b/packages/cli/src/commands/earn-compare.ts @@ -0,0 +1,90 @@ +import { earnCompare, type EarnCompareVenue } from '@toreva/sdk'; + +export interface EarnCompareCliArgs { + asset: 'USDC'; + venue: EarnCompareVenue; +} + +/** + * Parse `toreva earn-compare --asset --venue `. + * + * Throws on missing or invalid args. Mirrors `init.ts` parser style. + */ +export function parseEarnCompareArgs(args: string[]): EarnCompareCliArgs { + let asset: string | undefined; + let venue: string | undefined; + + for (const arg of args) { + if (arg.startsWith('--asset=')) { + asset = arg.slice('--asset='.length); + } else if (arg === '--asset') { + const idx = args.indexOf('--asset'); + asset = args[idx + 1]; + } else if (arg.startsWith('--venue=')) { + venue = arg.slice('--venue='.length); + } else if (arg === '--venue') { + const idx = args.indexOf('--venue'); + venue = args[idx + 1]; + } + } + + if (!asset) { + throw new Error('Missing required --asset flag (only USDC supported in R1)'); + } + if (asset !== 'USDC') { + throw new Error(`Unsupported asset: ${asset}. R1 supports USDC only.`); + } + if (!venue) { + throw new Error('Missing required --venue flag (kamino|marginfi)'); + } + if (venue !== 'kamino' && venue !== 'marginfi') { + throw new Error(`Unsupported venue: ${venue}. R1 supports: kamino, marginfi.`); + } + + return { asset: 'USDC', venue: venue as EarnCompareVenue }; +} + +/** + * Format the earn-compare result as a human-readable table. + */ +export function formatEarnCompare(r: Awaited>): string { + const lines: string[] = []; + lines.push(''); + lines.push(` Venue ${r.venue}`); + lines.push(` Asset ${r.asset}`); + if (r.apyPct !== null && r.apyPct !== undefined) { + lines.push(` APY ${r.apyPct.toFixed(4)}%`); + } else { + lines.push(` APY (unavailable)`); + if (r.apyNote) lines.push(` Note ${r.apyNote}`); + } + if (r.apyBasePct !== undefined && r.apyBasePct !== null) { + lines.push(` APY (base) ${r.apyBasePct.toFixed(4)}%`); + } + if (r.apyRewardPct !== undefined && r.apyRewardPct !== null) { + lines.push(` APY (reward) ${r.apyRewardPct.toFixed(4)}%`); + } + if (r.tvlUsd !== undefined && r.tvlUsd !== null) { + lines.push(` TVL (USD) $${r.tvlUsd.toLocaleString('en-US')}`); + } + if (r.project) lines.push(` Project ${r.project}`); + if (r.poolId) lines.push(` Pool ID ${r.poolId}`); + lines.push(` Source ${r.source}`); + if (r.fetchedAt) lines.push(` Fetched at ${r.fetchedAt}`); + lines.push(''); + lines.push(' Evidence:'); + lines.push(` read ${r.evidenceRef.readEvidenceId}`); + lines.push(` venue intel ${r.evidenceRef.venueIntelligenceReceiptId}`); + lines.push(` sentinel ${r.evidenceRef.sentinelReviewReceiptId}`); + lines.push(''); + return lines.join('\n'); +} + +/** + * Run the CLI earn-compare command. + */ +export async function runEarnCompareCommand(args: string[]): Promise { + const parsed = parseEarnCompareArgs(args); + const result = await earnCompare(parsed); + console.log(formatEarnCompare(result)); +} diff --git a/packages/cli/src/commands/token-receive.ts b/packages/cli/src/commands/token-receive.ts new file mode 100644 index 0000000..3b38fee --- /dev/null +++ b/packages/cli/src/commands/token-receive.ts @@ -0,0 +1,82 @@ +import { tokenReceive } from '@toreva/sdk'; + +export interface TokenReceiveCliArgs { + wallet: string; + limit?: number; +} + +/** + * Parse `toreva token-receive --wallet
[--limit ]`. + */ +export function parseTokenReceiveArgs(args: string[]): TokenReceiveCliArgs { + let wallet: string | undefined; + let limitRaw: string | undefined; + + for (const arg of args) { + if (arg.startsWith('--wallet=')) { + wallet = arg.slice('--wallet='.length); + } else if (arg === '--wallet') { + const idx = args.indexOf('--wallet'); + wallet = args[idx + 1]; + } else if (arg.startsWith('--limit=')) { + limitRaw = arg.slice('--limit='.length); + } else if (arg === '--limit') { + const idx = args.indexOf('--limit'); + limitRaw = args[idx + 1]; + } + } + + if (!wallet) { + throw new Error('Missing required --wallet flag'); + } + let limit: number | undefined; + if (limitRaw !== undefined) { + const parsed = Number(limitRaw); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 25) { + throw new Error(`Invalid --limit: ${limitRaw} (must be 1..25, capped by mcp.toreva.com)`); + } + limit = parsed; + } + return { wallet, ...(limit !== undefined ? { limit } : {}) }; +} + +/** + * Format the token-receive result as a human-readable table. + */ +export function formatTokenReceive(r: Awaited>): string { + const lines: string[] = []; + lines.push(''); + lines.push(` Wallet ${r.wallet}`); + lines.push(` Receives ${r.count}`); + lines.push(` Source ${r.source}`); + if (r.fetchedAt) lines.push(` Fetched at ${r.fetchedAt}`); + lines.push(''); + if (r.receives.length === 0) { + lines.push(' (no recent inbound SPL token transfers)'); + } else { + lines.push(' Recent receives:'); + for (const e of r.receives) { + const amt = e.amount !== undefined ? e.amount.toString() : '?'; + const mint = e.mint ? `${e.mint.slice(0, 8)}…` : '?'; + const sig = e.signature ? `${e.signature.slice(0, 8)}…` : '?'; + const when = e.blockTime ?? '?'; + lines.push(` ${when} amount=${amt} mint=${mint} sig=${sig}`); + } + } + lines.push(''); + lines.push(' Evidence:'); + lines.push(` read ${r.evidenceRef.readEvidenceId}`); + lines.push(` venue intel ${r.evidenceRef.venueIntelligenceReceiptId}`); + lines.push(` sentinel ${r.evidenceRef.sentinelReviewReceiptId}`); + lines.push(''); + return lines.join('\n'); +} + +/** + * Run the CLI token-receive command. + */ +export async function runTokenReceiveCommand(args: string[]): Promise { + const parsed = parseTokenReceiveArgs(args); + const result = await tokenReceive(parsed); + console.log(formatTokenReceive(result)); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4b42f40..eaf1a7c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,8 @@ import { runScanCommand } from './commands/scan.js'; import { parseInitArgs, runInit } from './commands/init.js'; import { runLogin } from './commands/login.js'; import { formatReport, runDoctor } from './commands/doctor.js'; +import { runEarnCompareCommand } from './commands/earn-compare.js'; +import { runTokenReceiveCommand } from './commands/token-receive.js'; import { SUPPORTED_CLIENTS } from './clients.js'; const USAGE = `Usage: toreva [args] @@ -14,13 +16,20 @@ Setup commands: toreva login Authenticate via the Toreva gateway (device-code flow) toreva doctor Verify install + token + first MCP call +R1 read-only primitives (no auth required, calls mcp.toreva.com): + toreva earn-compare --asset USDC --venue + Compare current USDC lending APY at an admitted venue + toreva token-receive --wallet
[--limit ] + Scan a wallet for recent inbound SPL token transfers + Power-user commands: toreva scan [prompt] - toreva perps [jsonPayload] + toreva perps [jsonPayload] (DISCOVERY_ONLY — execution lands in R3) Environment: TOREVA_MCP_URL Override gateway URL (default: https://mcp.toreva.com) TOREVA_AUTH_TOKEN Skip device-code flow and persist this token directly + TOREVA_API_KEY Bearer token used for R1 read-only primitives TOREVA_CONFIG_DIR Override the on-disk config directory `; @@ -62,6 +71,16 @@ async function main(): Promise { return; } + case 'earn-compare': { + await runEarnCompareCommand(args); + return; + } + + case 'token-receive': { + await runTokenReceiveCommand(args); + return; + } + case 'scan': { const [wallet = '', prompt = 'scan wallet'] = args; await runScanCommand(wallet, prompt); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ffffac9..3d54e1e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@toreva/sdk", - "version": "0.2.0", + "version": "0.2.1", "description": "MCP-native Solana primitive platform. R1 (today): read-only Kamino + Marginfi APY compare and SPL token receive detection. R2-R5: family-by-family rollout. See toreva.com/operational-matrix for current state.", "keywords": [ "solana", "defi", "kamino", "marginfi", "spl-token", diff --git a/packages/sdk/src/__tests__/r1.test.ts b/packages/sdk/src/__tests__/r1.test.ts index 0a77bb9..e2ddfe6 100644 --- a/packages/sdk/src/__tests__/r1.test.ts +++ b/packages/sdk/src/__tests__/r1.test.ts @@ -166,12 +166,17 @@ describe('earnCompare', () => { // --------------------------------------------------------------------------- describe('tokenReceive', () => { - const baseResult: TokenReceiveResult = { + // The MCP structured payload uses the canonical names (walletAddress, + // events, totalSignatureCountReturned). The SDK normalises to ergonomic + // aliases (wallet, receives, count) on top. + const mcpStructuredPayload = { ok: true, - wallet: 'TestWallet1111111111111111111111111111111111', - receives: [ + walletAddress: 'TestWallet1111111111111111111111111111111111', + inspectedSignatureCount: 1, + totalSignatureCountReturned: 1, + events: [ { - signature: '4xF2...sig', + signature: '4xF2sig', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', amount: 100, decimals: 6, @@ -180,7 +185,7 @@ describe('tokenReceive', () => { slot: 418_488_700, }, ], - count: 1, + splTokenProgramId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', source: 'solana-rpc', fetchedAt: '2026-05-10T12:20:09.082Z', evidenceRef: { @@ -194,16 +199,26 @@ describe('tokenReceive', () => { operation: 'scan', latencyMs: 600, }; + const baseResult = mcpStructuredPayload as unknown as TokenReceiveResult; - it('returns the canonical TokenReceiveResult shape', async () => { - const fetchImpl = makeMockFetch(baseResult); + it('returns the canonical TokenReceiveResult shape with ergonomic aliases', async () => { + const fetchImpl = makeMockFetch(mcpStructuredPayload); const r = await tokenReceive( - { wallet: baseResult.wallet, limit: 10 }, + { wallet: mcpStructuredPayload.walletAddress, limit: 10 }, { fetchImpl: fetchImpl as unknown as typeof fetch } ); expect(r.ok).toBe(true); - expect(r.wallet).toBe(baseResult.wallet); + // Both canonical + alias surfaces resolve to the same wallet. + expect(r.wallet).toBe(mcpStructuredPayload.walletAddress); + expect(r.walletAddress).toBe(mcpStructuredPayload.walletAddress); + // events / receives are aliases of the same array. + expect(Array.isArray(r.events)).toBe(true); expect(Array.isArray(r.receives)).toBe(true); + expect(r.events).toEqual(r.receives); + // count alias resolves to totalSignatureCountReturned. + expect(r.count).toBe(1); + expect(r.totalSignatureCountReturned).toBe(1); + expect(r.inspectedSignatureCount).toBe(1); expect(r.evidenceRef.sentinelReviewReceiptId).toMatch(/SENT-REVIEW/); expect(r.familyId).toBe('token_ops'); expect(r.tool).toBe('toreva_token_receive_scan'); @@ -219,26 +234,54 @@ describe('tokenReceive', () => { ).rejects.toThrow(/wallet/); }); - it('rejects out-of-range limit', async () => { - const fetchImpl = makeMockFetch(baseResult); + it('rejects out-of-range limit (> 25)', async () => { + const fetchImpl = makeMockFetch(mcpStructuredPayload); await expect( tokenReceive( - { wallet: baseResult.wallet, limit: 9999 }, + { wallet: mcpStructuredPayload.walletAddress, limit: 9999 }, { fetchImpl: fetchImpl as unknown as typeof fetch } ) ).rejects.toThrow(/limit/); }); - it('passes wallet + limit through to MCP arguments', async () => { - const fetchImpl = makeMockFetch(baseResult); + it('passes walletAddress (live MCP arg name) + limit through to MCP arguments', async () => { + const fetchImpl = makeMockFetch(mcpStructuredPayload); await tokenReceive( - { wallet: baseResult.wallet, limit: 5 }, + { wallet: mcpStructuredPayload.walletAddress, limit: 5 }, { fetchImpl: fetchImpl as unknown as typeof fetch } ); const call = (fetchImpl as ReturnType).mock.calls[0]; const body = JSON.parse((call?.[1] as RequestInit)?.body as string); expect(body.params.name).toBe('toreva_token_receive_scan'); - expect(body.params.arguments).toEqual({ wallet: baseResult.wallet, limit: 5 }); + // The SDK accepts ergonomic `wallet` from the caller but sends the + // canonical `walletAddress` to the live MCP tool. + expect(body.params.arguments).toEqual({ + walletAddress: mcpStructuredPayload.walletAddress, + limit: 5, + }); + }); + + it('surfaces MCP isError payloads with the upstream error message', async () => { + const errPayload = { + error: 'walletAddress (string, base58 Solana pubkey) is required.', + code: 'INVALID_INPUT', + field: 'walletAddress', + }; + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + result: { structuredContent: errPayload, isError: true }, + }), + } as unknown as Response); + await expect( + tokenReceive( + { wallet: 'irrelevant' }, + { fetchImpl: fetchImpl as unknown as typeof fetch } + ) + ).rejects.toThrow(/walletAddress.*required/); }); }); diff --git a/packages/sdk/src/earn.ts b/packages/sdk/src/earn.ts index 74f96c3..159f7d3 100644 --- a/packages/sdk/src/earn.ts +++ b/packages/sdk/src/earn.ts @@ -32,9 +32,15 @@ export interface EarnCompareResult { ok: boolean; venue: EarnCompareVenue; asset: EarnCompareAsset; - apyPct: number; + /** + * Annualised yield percentage. May be `null` for venues whose on-chain + * APY decoding is pending — see `apyNote`. Caller should handle `null` + * before formatting. + */ + apyPct: number | null; apyBasePct?: number | null; apyRewardPct?: number | null; + apyNote?: string; tvlUsd?: number | null; underlyingTokens?: string[]; chain?: string; @@ -49,6 +55,15 @@ export interface EarnCompareResult { primitiveId: string; operation: string; latencyMs?: number; + // Marginfi-specific fields (optional): + bankAccount?: string; + bankOwner?: string; + bankExecutable?: boolean; + bankLamports?: number; + bankRentEpoch?: number; + bankDataByteLen?: number; + bankDataSha256?: string; + slot?: number; } export interface EarnCompareOptions { diff --git a/packages/sdk/src/tokens.ts b/packages/sdk/src/tokens.ts index bd8bf24..14e5959 100644 --- a/packages/sdk/src/tokens.ts +++ b/packages/sdk/src/tokens.ts @@ -2,34 +2,29 @@ * R1 GREEN primitive: read-only SPL token receive scan. * * Calls `mcp.toreva.com` JSON-RPC `tools/call` with `toreva_token_receive_scan`. - * Returns recent inbound SPL token transfers for a given wallet (read-only, - * no execution, no signing). + * Returns recent inbound SPL Token + System Program receive events for a + * given wallet (read-only, no execution, no signing). * * Family: token_ops. Operation: scan. Tier: T2 (read-only operational). */ export interface TokenReceiveParams { - /** Solana wallet address to scan. */ + /** Solana wallet base58 address to scan. */ wallet: string; - /** Max number of recent receives to return (default: 25). */ + /** Max number of recent signatures to inspect. Default 10. Capped at 25. */ limit?: number; } -export interface TokenReceiveEntry { - /** Solana transaction signature. */ +export interface TokenReceiveEvent { signature?: string; - /** SPL token mint address. */ mint?: string; - /** Token amount (UI units). */ amount?: number; - /** Token decimals. */ decimals?: number; - /** Sender wallet address. */ fromWallet?: string; - /** Block time (ISO). */ blockTime?: string; - /** Slot number. */ slot?: number; + /** Tool may attach additional fields per event type — pass through as unknown. */ + [extra: string]: unknown; } export interface TokenReceiveEvidenceRef { @@ -38,11 +33,31 @@ export interface TokenReceiveEvidenceRef { sentinelReviewReceiptId: string; } +/** + * Canonical response shape returned by `toreva_token_receive_scan`. + * + * Note the live tool reports inspection counts via `inspectedSignatureCount` + * and `totalSignatureCountReturned`. The events array is `events`. We expose + * `count` as a derived alias of `totalSignatureCountReturned` for ergonomics. + */ export interface TokenReceiveResult { ok: boolean; + /** Echo of the scanned wallet address (live tool uses `walletAddress`). */ + walletAddress: string; + /** Convenience alias for `walletAddress`. */ wallet: string; - receives: TokenReceiveEntry[]; + /** Number of signatures the tool inspected (`inspectedSignatureCount`). */ + inspectedSignatureCount: number; + /** Number of receive events returned (`totalSignatureCountReturned`). */ + totalSignatureCountReturned: number; + /** Convenience alias for `totalSignatureCountReturned`. */ count: number; + events: TokenReceiveEvent[]; + /** Convenience alias for `events`. */ + receives: TokenReceiveEvent[]; + splTokenProgramId?: string; + splToken2022ProgramId?: string; + systemProgramId?: string; source: string; sourceUrl?: string; fetchedAt?: string; @@ -50,6 +65,7 @@ export interface TokenReceiveResult { tool: string; familyId: string; primitiveId: string; + venue?: string; operation: string; latencyMs?: number; } @@ -63,9 +79,11 @@ export interface TokenReceiveOptions { const DEFAULT_MCP_URL = 'https://mcp.toreva.com'; const PUBLIC_SYNTHETIC_KEY = 'tk_litmus_r1_synthetic_3rd_party_trading_bot_demo_only'; const TOOL_NAME = 'toreva_token_receive_scan'; +const MAX_LIMIT = 25; /** - * Scan a Solana wallet for recent inbound SPL token transfers. + * Scan a Solana wallet for recent inbound SPL Token + System Program + * receive events. * * Read-only: never signs, never sends. The MCP tool reads from the Solana * RPC and returns a structured snapshot with the evidence triple for audit. @@ -73,7 +91,7 @@ const TOOL_NAME = 'toreva_token_receive_scan'; * @example * ```ts * const r = await tokenReceive({ wallet: '5N...abc', limit: 10 }); - * console.log(r.count, r.receives[0]?.signature); + * console.log(r.count, r.events[0]?.signature); * ``` */ export async function tokenReceive( @@ -83,15 +101,15 @@ export async function tokenReceive( if (!params.wallet || typeof params.wallet !== 'string') { throw new Error('tokenReceive: wallet (string) is required'); } - if (params.limit !== undefined && (params.limit < 1 || params.limit > 1000)) { - throw new Error('tokenReceive: limit must be between 1 and 1000'); + if (params.limit !== undefined && (params.limit < 1 || params.limit > MAX_LIMIT)) { + throw new Error(`tokenReceive: limit must be between 1 and ${MAX_LIMIT}`); } const mcpUrl = (options.mcpUrl ?? DEFAULT_MCP_URL).replace(/\/+$/, ''); const apiKey = options.apiKey ?? process.env.TOREVA_API_KEY ?? PUBLIC_SYNTHETIC_KEY; const fetchImpl = options.fetchImpl ?? fetch; - const args: Record = { wallet: params.wallet }; + const args: Record = { walletAddress: params.wallet }; if (params.limit !== undefined) args.limit = params.limit; const response = await fetchImpl(`${mcpUrl}/mcp`, { @@ -115,7 +133,10 @@ export async function tokenReceive( const body = (await response.json()) as { jsonrpc?: string; id?: number; - result?: { structuredContent?: TokenReceiveResult; isError?: boolean }; + result?: { + structuredContent?: Partial & { error?: string; code?: string }; + isError?: boolean; + }; error?: { code: number; message: string }; }; @@ -128,8 +149,20 @@ export async function tokenReceive( throw new Error('tokenReceive: MCP response missing structuredContent'); } if (body.result?.isError) { - throw new Error('tokenReceive: MCP tool returned error payload'); + const errMsg = structured.error || 'tool returned error payload'; + throw new Error(`tokenReceive: ${errMsg}`); } - return structured; + // Normalise the shape: surface ergonomic aliases (`wallet`, `count`, + // `receives`) alongside the canonical fields. + return { + ...structured, + wallet: structured.walletAddress ?? params.wallet, + walletAddress: structured.walletAddress ?? params.wallet, + count: structured.totalSignatureCountReturned ?? 0, + inspectedSignatureCount: structured.inspectedSignatureCount ?? 0, + totalSignatureCountReturned: structured.totalSignatureCountReturned ?? 0, + events: structured.events ?? [], + receives: structured.events ?? [], + } as TokenReceiveResult; } From d71d1af57f3e9bdef69b582406ffbe59db9e10f2 Mon Sep 17 00:00:00 2001 From: gapview01 <107860548+gapview01@users.noreply.github.com> Date: Mon, 11 May 2026 01:18:38 +1000 Subject: [PATCH 3/4] fix(types): add missing PerpsToolInput + PerpsToolResult exports PR #13's SDK perps DISCOVERY_ONLY guards imported these type names but they were never exported from @toreva/types. Build fails with TS2305 in packages/sdk/src/perps.ts. Adding generic-but-permissive shapes since the DISCOVERY_ONLY guards throw before either type is exercised. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/types/src/perps.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/types/src/perps.ts b/packages/types/src/perps.ts index 23f1bf2..a04f68a 100644 --- a/packages/types/src/perps.ts +++ b/packages/types/src/perps.ts @@ -79,3 +79,16 @@ export const PERPS_RELAY_TYPES = { export type PerpsToolName = keyof typeof perpsToolSchemas; export type PerpsRelayType = (typeof PERPS_RELAY_TYPES)[PerpsToolName]; + +// Generic input/result types for SDK consumers. The DISCOVERY_ONLY guards in +// the SDK throw before these shapes are exercised — until perps execution +// lands in R3, these are intentionally permissive. +export type PerpsToolInput = z.input; +export type PerpsToolResult = { + ok: boolean; + txSignature?: string; + positionId?: string; + evidenceRef?: { readEvidenceId?: string; venueIntelligenceReceiptId?: string; sentinelReviewReceiptId?: string }; + error?: { code: string; message: string }; + [key: string]: unknown; +}; From 1535e25028725354988e39e5ecc449b0a3c323bd Mon Sep 17 00:00:00 2001 From: gapview01 <107860548+gapview01@users.noreply.github.com> Date: Mon, 11 May 2026 11:48:51 +1000 Subject: [PATCH 4/4] fix(types): make PerpsToolResult generic to match SDK usage SDK perps.ts uses PerpsToolResult (generic). Previous fix defined it non-generic causing TS2315. Adding tool-name type parameter (currently unused but reserved for per-tool result discrimination in R3 when execution lands). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/types/src/perps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/perps.ts b/packages/types/src/perps.ts index a04f68a..271c43b 100644 --- a/packages/types/src/perps.ts +++ b/packages/types/src/perps.ts @@ -84,7 +84,7 @@ export type PerpsRelayType = (typeof PERPS_RELAY_TYPES)[PerpsToolName]; // the SDK throw before these shapes are exercised — until perps execution // lands in R3, these are intentionally permissive. export type PerpsToolInput = z.input; -export type PerpsToolResult = { +export type PerpsToolResult<_T extends PerpsToolName = PerpsToolName> = { ok: boolean; txSignature?: string; positionId?: string;