diff --git a/typescript/.changeset/assetfare-quote-action-provider.md b/typescript/.changeset/assetfare-quote-action-provider.md new file mode 100644 index 000000000..575582206 --- /dev/null +++ b/typescript/.changeset/assetfare-quote-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added an AssetFare action provider with read-only non-custodial cross-chain bridge and swap route quotes (capabilities and quote actions) across six chains. Quotes fail closed on the REST 2.4 continuation_v3 binding and expose only a sanitized unranked descriptor; the provider never creates approval_v3, collects wallets, prepares, opens a session, signs, or submits. diff --git a/typescript/agentkit/src/action-providers/assetfare/README.md b/typescript/agentkit/src/action-providers/assetfare/README.md new file mode 100644 index 000000000..07f31fa1d --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/README.md @@ -0,0 +1,171 @@ +# AssetFare Action Provider + +This directory contains the AssetFare action provider implementation, which provides read-only +actions for non-custodial cross-chain bridge and swap route quotes from the AssetFare public v2 API. + +AssetFare returns quotes and, only on an explicit caller-approved call outside this provider, +unsigned actions. It never receives a private key and never signs or submits a transaction. + +## Directory Structure + +``` +assetfare/ +├── assetfareActionProvider.test.ts # Tests for the provider +├── assetfareActionProvider.ts # Main provider with AssetFare API functionality +├── continuation.ts # REST 2.4 continuation_v3 validator + sanitized projection +├── index.ts # Main exports +├── README.md # Documentation +└── schemas.ts # AssetFare action schemas +``` + +## Actions + +- `get_capabilities`: Read the live route surface + + - Returns supported chains and asset endpoints, implemented directed routes, and how many are + currently ready to prepare + - Returns the validated `directRouteSummary` capability contract, which declares that every v2 + quote must carry an ordered, normalized, amount-bounded provider path + - Availability is live, not static: read it before treating a route as usable + - Fails closed if the service does not report the mandatory summary contract or + `server_signing: false` and `server_submission: false` + +- `get_quote`: Request one fresh route quote + - Inputs: source chain and asset, destination chain and asset, finite USD notional of at least 1 with no business maximum (live liquidity/capacity still apply) + - Returns expected and minimum receive, the full `cost_summary` (AssetFare service fee of 1bp plus + separate Circle, provider, and network fee components), unpriced costs, ETA, TTL, and non-atomic risk + - Returns `directRouteSummary`, validated from the API's `direct_route_summary`: the exact ordered + providers and `chain:asset` endpoints, expected/minimum base-unit bounds as decimal strings, and + the one step that collects the AssetFare 1bp fee + - Fails closed if the summary is missing, has extra or malformed fields, breaks path or amount + continuity, misstates a provider/action/fee, or disagrees with the requested intent or the + quote's route, risk, fee, or raw provider steps + - Validates the complete REST 2.4 `continuation_v3` fingerprint, payload/route hashes, expiry, + exact bounds, required wallet chains/event signer, and allowed mode, then returns only a + sanitized `continuationDescriptor` (quote ID/fingerprint, expiry, unranked status, + wallet/signer requirements, allowed/recommended mode, full OpenAPI URL, legacy advisory) + - Recomputes the portable payload hash after replacing duplicated raw base-unit numbers with + exact `direct_route_summary` strings, then using typed-canonical-v1 bytes that preserve JSON + types and negative zero, encode finite numbers as IEEE-754 binary64, and reject unsafe + non-substituted integral numbers and lone Unicode surrogates while supporting substituted raw + amounts above JavaScript's `2^53` safe limit + - Never creates `approval_v3`, selects a candidate, collects wallets, or calls prepare/session. + `caller_approved: true` alone is not proof of human approval. Multi-step routes are session-only + and a caller must choose exactly one continuation path outside this provider + - The 1bp service fee is not the total cost; judge the route by the total token-path cost + - Read-only: no wallet authentication, no unsigned action preparation, no signing, no submission + +## Usage + +```typescript +import { AgentKit } from "@coinbase/agentkit"; +import { assetfareActionProvider } from "@coinbase/agentkit"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [assetfareActionProvider()], +}); +``` + +## Examples + +The API minimum is USD 1, but USD 1 is only for reachability smoke testing, not economic evaluation. +USD 50 was an observed competitive bucket only for dated 2026-09-23 Solana USDC to Base USDC +evidence; no threshold is claimed for another corridor. Use USD 1,000 as the primary representative +amount, including for SOL-input routes whose extra source swap must be assessed separately. These +advisory values are not minimums or a guarantee that AssetFare is best. Always compare fresh quotes +at the actual intended amount. + +### Reading capabilities + +```bash +Prompt: Which cross-chain routes can AssetFare quote right now? + +------------------- +{ + "success": true, + "chains": ["arbitrum", "base", "optimism", "polygon", "robinhood", "solana"], + "implementedRoutes": 76, + "currentlyPrepareReadyRoutes": 76, + "temporarilyUnavailableRoutes": [], + "serverSigning": false, + "serverSubmission": false, + "directRouteSummary": { + "version": "assetfare-direct-route-summary-v1", + "required_on_every_quote": true, + "route_count": 76, + "ordered_provider_path": true, + "normalized_chain_asset_endpoints": true, + "assetfare_fee_step_bound": true, + "classification_values": ["direct_protocol_only", "external_intent"], + "route_aggregator_used_scope": "assetfare_engine_only" + }, + "feeNote": "AssetFare service fee is 1bp; Circle, provider, and network fees are additional. Each quote exposes the total token-path cost and live availability." +} +------------------- +``` + +### Quoting a route + +```bash +Prompt: Quote moving $1,000 of Solana native USDC to Base native USDC. +``` + +The live response is intentionally not reproduced here: quote amounts, availability, and TTL change. +Inspect its full `costSummary`, unpriced costs, risk fields, and `directRouteSummary.steps`, then +compare it with other fresh executable quotes for the intended amount. The summary's +`direct_protocol_only` classification means every listed step uses a disclosed direct protocol. +`external_intent` means the path includes Across for Robinhood ingress; in that case +`provider_internal_dex_aggregation_possible` is true because Across may source or aggregate +liquidity internally. `route_aggregator_used: false` is limited to AssetFare's own route engine and +must not be presented as a claim about a provider's internal routing. + +The returned `continuationDescriptor` is deliberately non-executable and remains +`selection_status: unranked_candidate`. The safe sequence is: compare fresh candidates → make an +explicit local selection → copy the exact v3 bounds and one allowed mode in a separate reviewed +execution integration. This provider performs none of those execution steps. + +The response also includes `agentGuidance.callerOwnedContinuation`, a structured two-command +handoff pinned to `assetfare-mcp@1.7.1`. Because this provider deliberately does not expose the raw +quote, the first command obtains and writes one new exact validated quote to a mode-0600 file. Only +after comparison and explicit caller approval, the second command creates strict quote-bound +approval locally and requests one verified unsigned session action plus a caller-wallet handoff file +containing EIP-1193 templates or Solana Wallet Standard construction inputs together with the exact +verified bundle, safety receipt, verification results, and a canonical handoff hash. The commands are +returned as an executable plus argument array rather than a shell string. They contain public-address +placeholders only; this provider still never collects a wallet, prepares an action, signs, or submits. +The 1.7.1 session capability preserves the strict verification context so every later session action +receives the same semantic verification and a new self-verifying wallet handoff. +Immediately before wallet use, run the returned `walletReadyCommandTemplate`. Quote selection stays +at 60 seconds, but a selected action bundle lasts 180 seconds with a 240-second EVM deadline; +`wallet-ready` requires at least 120 seconds remaining or refreshes only an expired, unsubmitted step. + +A caller-owned wallet agent can use the returned `callerOwnedRunnerCommandTemplate` after it has +created a separate local policy and local wallet adapter. This read-only provider neither creates +that policy nor receives a key. The runner is caller-process-only, has no remote MCP execution tool, +and reports `keyLocation: caller_wallet_adapter_only` with AssetFare server key access/signing/ +submission all false. + +For agent-wallet funding, use that path for an aggregate refill or material transfer. Do not invoke +it automatically for each failed x402 micropayment. For the evidenced Solana USDC to Base USDC +corridor, needs below the dated USD 50 observed bucket should be aggregated before comparison; +other corridors have no claimed threshold. A wallet with no spendable asset on any supported source +chain is not an AssetFare use case. + +## Notes + +- Quotes are short-lived; request a fresh one before acting. +- Cross-chain routes are non-atomic: an early step can succeed while a later one fails. +- Treat a quote rejected by the summary validator as unusable; never present partial fields or + reconstruct a missing path. +- Source-chain gas is not priced into the returned totals, so the cost summary is not an all-in + ranking figure. Compare fresh quotes from several providers before choosing a route. +- Network configuration is not required: the provider reads a public API and is network-agnostic. + +## Adding New Actions + +To add new AssetFare actions: + +1. Define your schema in `schemas.ts` +2. Implement your action in `assetfareActionProvider.ts` +3. Add corresponding tests in `assetfareActionProvider.test.ts` diff --git a/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts new file mode 100644 index 000000000..77a851086 --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.test.ts @@ -0,0 +1,877 @@ +import { assetfareActionProvider } from "./assetfareActionProvider"; +import { AssetFareDirectRouteSummarySchema, GetQuoteSchema } from "./schemas"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const canonical = (value: unknown): string => { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map(key => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +}; +const sha256 = (value: unknown) => + createHash("sha256").update(canonical(value), "utf8").digest("hex"); +const QUOTE_PAYLOAD_SHA256_SPEC = + "sha256(AssetFare typed-canonical-v1 bytes of the quote without continuation_v3 after exact base-unit substitution: n=null; t/f=boolean; d= for each finite JSON number; s=:; a=:[items]; o=:{UTF-8-byte-sorted string-key/value pairs}; every non-substituted integral JSON number must be within +/-9007199254740991; substituted paths are intent.estimated_input_base, route.input_base, route.expected_output_base, route.minimum_output_base, and every route.steps[i].expected_input_base/floor_input_base/expected_output_base/minimum_output_base from direct_route_summary exact decimal strings)"; + +const decimalString = (value: number): string => { + const match = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(String(value)); + if (!match) throw new Error("invalid fixture number"); + const negative = match[1] === "-"; + const whole = match[2]; + const fraction = match[3] || ""; + const exponent = Number(match[4] || 0); + let digits = whole + fraction; + let point = whole.length + exponent; + if (point <= 0) { + digits = "0".repeat(-point) + digits; + point = 0; + } + if (point >= digits.length) digits += "0".repeat(point - digits.length); + let rendered = + point === 0 + ? `0.${digits}` + : point === digits.length + ? digits + : `${digits.slice(0, point)}.${digits.slice(point)}`; + if (rendered.includes(".")) rendered = rendered.replace(/0+$/, "").replace(/\.$/, ""); + rendered = rendered.replace(/^0+(?=\d)/, "") || "0"; + if (rendered.startsWith(".")) rendered = `0${rendered}`; + if (/^0(?:\.0*)?$/.test(rendered)) return "0"; + return negative ? `-${rendered}` : rendered; +}; + +const hasLoneSurrogate = (value: string): boolean => { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) return true; + } + return false; +}; + +const typedCanonical = (value: unknown): Buffer => { + if (value === null) return Buffer.from("n", "ascii"); + if (value === true) return Buffer.from("t", "ascii"); + if (value === false) return Buffer.from("f", "ascii"); + if (typeof value === "number") { + if ( + !Number.isFinite(value) || + (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) + ) + throw new Error("unsafe number"); + const bytes = Buffer.allocUnsafe(8); + bytes.writeDoubleBE(value); + return Buffer.from(`d${bytes.toString("hex")}`, "ascii"); + } + if (typeof value === "string") { + if (hasLoneSurrogate(value)) throw new Error("invalid unicode"); + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from(`s${bytes.length}:`, "ascii"), bytes]); + } + if (Array.isArray(value)) + return Buffer.concat([ + Buffer.from(`a${value.length}:[`, "ascii"), + ...value.map(typedCanonical), + Buffer.from("]", "ascii"), + ]); + const entries = Object.entries(value as Record).sort(([left], [right]) => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")), + ); + return Buffer.concat([ + Buffer.from(`o${entries.length}:{`, "ascii"), + ...entries.flatMap(([key, item]) => [typedCanonical(key), typedCanonical(item)]), + Buffer.from("}", "ascii"), + ]); +}; + +const quotePayloadProjection = (value: Record) => { + const payload = structuredClone(value); + delete payload.continuation_v3; + const intent = payload.intent as Record; + const route = payload.route as Record; + const summary = payload.direct_route_summary as Record; + const steps = summary.steps as Array>; + const rawSteps = route.steps as Array>; + intent.estimated_input_base = steps[0].expected_input_base; + route.input_base = steps[0].expected_input_base; + route.minimum_output_base = steps[steps.length - 1].minimum_output_base; + route.expected_output_base = steps[steps.length - 1].expected_output_base; + rawSteps.forEach((raw, index) => { + const exact = steps[index]; + raw.expected_input_base = exact.expected_input_base; + raw.floor_input_base = exact.minimum_input_base; + raw.expected_output_base = exact.expected_output_base; + raw.minimum_output_base = exact.minimum_output_base; + }); + return payload; +}; + +const quotePayloadSha256 = (value: Record) => + createHash("sha256") + .update(typedCanonical(quotePayloadProjection(value))) + .digest("hex"); + +describe("AssetFareActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + const provider = assetfareActionProvider(); + + const capabilities = { + chains: ["arbitrum", "base", "optimism", "polygon", "robinhood", "solana"], + asset_endpoints: [{ chain: "solana", token: "USDC" }], + source_only_asset_endpoints: [{ chain: "polygon", token: "USDC" }], + execution_implemented_routes: 76, + currently_prepare_ready_routes: 76, + temporarily_unavailable_routes: [], + execution_availability: { status: "available", guarantees_future_availability: false }, + server_signing: false, + server_submission: false, + direct_route_summary: { + version: "assetfare-direct-route-summary-v1", + required_on_every_quote: true, + route_count: 76, + step_count: 168, + ordered_provider_path: true, + normalized_chain_asset_endpoints: true, + base_unit_amounts_are_decimal_strings: true, + assetfare_fee_step_bound: true, + classification_values: ["direct_protocol_only", "external_intent"], + route_aggregator_used_scope: "assetfare_engine_only", + external_intent: + "Across only for Robinhood ingress; provider-internal liquidity sourcing or aggregation remains possible", + server_signing: false, + server_submission: false, + }, + }; + + const directRouteSummary = { + version: "assetfare-direct-route-summary-v1", + route: "solana:USDC->base:USDC", + from: "solana:USDC", + to: "base:USDC", + classification: "direct_protocol_only", + mode: "cctp_direct_composition", + route_aggregator_used: false, + external_intent_protocol_used: false, + provider_internal_dex_aggregation_possible: false, + assetfare_fee_bps: 1, + fee_collection_step_index: 0, + server_signing: false, + server_submission: false, + step_count: 1, + steps: [ + { + index: 0, + action: "bridge", + provider: "circle_cctp", + from: "solana:USDC", + to: "base:USDC", + expected_input_base: "250000000", + minimum_input_base: "250000000", + expected_output_base: "249895639", + minimum_output_base: "249895318", + assetfare_fee_bps: 1, + direct_protocol: true, + external_intent_protocol: false, + aggregator_api_used: false, + }, + ], + }; + + const quote = { + quote_id: "00000000-0000-4000-8000-000000000001", + as_of: "2026-01-01T00:00:00Z", + ttl_seconds: 60, + intent: { + from: "solana:USDC", + to: "base:USDC", + amount_usd: 250, + estimated_input_base: 250000000, + }, + offer: { + expected_receive_usd: 249.895639, + estimated_min_receive_usd: 249.895318, + assetfare_fee_bps: 1, + fee_collection_steps: [0], + }, + cost_summary: { + scope: "token_path_only_network_gas_excluded", + input_value_usd: 250, + expected_total_cost_usd: 0.104361, + expected_total_cost_percent: 0.0417, + assetfare_service_fee: { bps: 1, estimated_usd: 0.025 }, + unpriced_costs: ["source_chain_network_fee"], + rankable_all_in: false, + }, + eta: { estimated_time_range_seconds: [8, 20] }, + route: { + route: "solana:USDC->base:USDC", + mode: "cctp_direct_composition", + input_base: 250000000, + expected_output_base: 249895639, + minimum_output_base: 249895318, + steps: [ + { + index: 0, + kind: "direct_bridge", + provider: "circle_cctp", + route_fee_bps: 1, + expected_input_base: 250000000, + floor_input_base: 250000000, + expected_output_base: 249895639, + minimum_output_base: 249895318, + }, + ], + aggregator_api_used: false, + external_intent_protocol_used: false, + server_signing: false, + server_submission: false, + }, + direct_route_summary: directRouteSummary, + risk: { + non_atomic: true, + external_intent_protocol_used: false, + provider_internal_dex_aggregation_possible: false, + server_signing: false, + server_submission: false, + }, + execution: { supported: true, current_prepare_readiness: "available" }, + }; + + const addContinuation = (input: Record) => { + delete input.continuation_v3; + const value = input as typeof quote; + const summary = value.direct_route_summary; + const wallets = [ + ...new Set( + summary.steps.flatMap(step => [step.from.split(":", 1)[0], step.to.split(":", 1)[0]]), + ), + ].sort(); + const signer = summary.steps.some( + step => step.provider === "circle_cctp" && step.from.startsWith("solana:"), + ); + const modes = summary.step_count > 1 ? ["session"] : ["one_shot", "session"]; + const bounds = { + minimum: value.direct_route_summary.steps[0].expected_input_base, + maximum: value.direct_route_summary.steps[0].expected_input_base, + }; + const summaryHash = sha256(summary); + const payloadHash = quotePayloadSha256(value); + const issued = new Date(); + const expires = new Date(issued.getTime() + 60_000); + const claim = { + version: "assetfare-quote-bound-continuation-v3", + quote_id: value.quote_id, + issued_at: issued.toISOString(), + expires_at: expires.toISOString(), + ttl_seconds: "60", + intent: { + from: value.intent.from, + to: value.intent.to, + amount_usd_decimal: decimalString(value.intent.amount_usd), + estimated_input_base: bounds.minimum, + }, + direct_route_summary_sha256: summaryHash, + quote_payload_sha256: payloadHash, + quote_payload_sha256_spec: QUOTE_PAYLOAD_SHA256_SPEC, + input_base_bounds: bounds, + minimum_output_base: + value.direct_route_summary.steps[value.direct_route_summary.steps.length - 1] + .minimum_output_base, + required_wallet_chains: wallets, + event_signer_public_required: signer, + step_count: String(summary.step_count), + allowed_modes: modes, + server_signing: false, + server_submission: false, + }; + input.continuation_v3 = { + version: "assetfare-quote-bound-continuation-v3", + enforcement: "server_enforced_quote_binding", + selection_status: "unranked_candidate", + automatic_selection_forbidden: true, + caller_approved_boolean_is_not_human_proof: true, + quote_id: value.quote_id, + quote_fingerprint: sha256(claim), + quote_fingerprint_spec: + "sha256(UTF-8 sorted-key compact JSON of quote_fingerprint_claim; every numeric claim is a non-exponent decimal string)", + quote_fingerprint_claim: claim, + issued_at: claim.issued_at, + expires_at: claim.expires_at, + ttl_seconds: 60, + intent: structuredClone(value.intent), + direct_route_summary_sha256: summaryHash, + quote_payload_sha256: payloadHash, + quote_payload_sha256_spec: QUOTE_PAYLOAD_SHA256_SPEC, + input_base_bounds: bounds, + minimum_output_base: claim.minimum_output_base, + required_wallet_chains: wallets, + event_signer_public_required: signer, + step_count: summary.step_count, + recommended_mode: summary.step_count > 1 ? "session" : "one_shot_or_session", + allowed_modes: modes, + session_header: { + name: "X-AssetFare-Session-Token", + required_for: "session", + caller_generated: true, + minimum_entropy_bits: 256, + server_returns_raw_value: false, + }, + idempotency: { + required: true, + field: "idempotency_key", + pattern: "^[A-Za-z0-9._:-]{8,128}$", + scope: "quote_and_selected_mode", + }, + approval_v3_required_fields: [ + "direct_route_summary_sha256", + "idempotency_key", + "maximum_input_base", + "minimum_output_base", + "quote_fingerprint", + "quote_id", + "selected_mode", + "selection_status", + "version", + ], + legacy_handoff_enforcement: "legacy_advisory", + server_signing: false, + server_submission: false, + }; + return value; + }; + + const asRecord = (value: unknown): Record => value as Record; + const continuationOf = (value: Record): Record => + asRecord(value.continuation_v3); + + addContinuation(quote); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("getCapabilities", () => { + it("should return the live route surface when the API call succeeds", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => capabilities }); + + const result = await provider.getCapabilities({}); + const parsed = JSON.parse(result); + + expect(parsed.success).toBe(true); + expect(parsed.implementedRoutes).toEqual(76); + expect(parsed.currentlyPrepareReadyRoutes).toEqual(76); + expect(parsed.serverSigning).toBe(false); + expect(parsed.serverSubmission).toBe(false); + expect(parsed.directRouteSummary).toMatchObject({ + version: "assetfare-direct-route-summary-v1", + required_on_every_quote: true, + route_count: 76, + ordered_provider_path: true, + }); + expect(parsed.evaluationGuidance).toMatchObject({ + routeMinimumUsd: 1, + reachabilitySmokeUsd: 1, + reachabilitySmokeOnly: true, + observedCompetitiveBucketUsd: 50, + observedEvidenceRoute: "solana:USDC->base:USDC", + thresholdClaimedForOtherCorridors: false, + representativeEconomicEvaluationUsd: 1000, + evidenceAsOf: "2026-09-23", + notAMinimum: true, + notGuaranteedBest: true, + alwaysCompareFreshAtIntendedAmount: true, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.assetfare.dev/v2/capabilities", + expect.objectContaining({ + headers: expect.objectContaining({ accept: "application/json" }), + }), + ); + }); + + it("should fail closed when the service does not report a no-sign boundary", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ ...capabilities, server_signing: true }), + }); + + const result = await provider.getCapabilities({}); + + expect(result).toContain("no-sign, no-submit boundary"); + }); + + it("should fail closed when the mandatory summary contract is missing or incomplete", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ ...capabilities, direct_route_summary: undefined }), + }); + + const result = await provider.getCapabilities({}); + + expect(result).toContain("mandatory direct_route_summary contract"); + }); + + it("should handle API errors gracefully", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 503, json: async () => ({}) }); + + const result = await provider.getCapabilities({}); + + expect(result).toContain("Error reading AssetFare capabilities"); + expect(result).toContain("HTTP 503"); + }); + + it("should handle network errors", async () => { + fetchMock.mockRejectedValue(new Error("Network error")); + + const result = await provider.getCapabilities({}); + + expect(result).toContain("Error reading AssetFare capabilities"); + expect(result).toContain("Network error"); + }); + }); + + describe("getQuote", () => { + const args = { + fromChain: "solana" as const, + fromToken: "USDC" as const, + toChain: "base" as const, + toToken: "USDC" as const, + amountUsd: 250, + }; + + it("should reject unsupported token/chain and source-only directions before network work", () => { + expect( + GetQuoteSchema.safeParse({ ...args, fromChain: "base", fromToken: "SOL" }).success, + ).toBe(false); + expect(GetQuoteSchema.safeParse({ ...args, amountUsd: 1000.01 }).success).toBe(true); + expect(GetQuoteSchema.safeParse({ ...args, amountUsd: 1_000_000 }).success).toBe(true); + expect( + GetQuoteSchema.safeParse({ ...args, amountUsd: Number.POSITIVE_INFINITY }).success, + ).toBe(false); + expect( + GetQuoteSchema.safeParse({ + ...args, + fromChain: "optimism", + fromToken: "USDC", + toChain: "base", + toToken: "USDC", + }).success, + ).toBe(true); + expect( + GetQuoteSchema.safeParse({ + ...args, + fromChain: "base", + fromToken: "USDC", + toChain: "polygon", + toToken: "USDC", + }).success, + ).toBe(false); + }); + + it("should return the quote with its full cost summary", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => quote }); + + const result = await provider.getQuote(args); + const parsed = JSON.parse(result); + + expect(parsed.success).toBe(true); + expect(parsed.quoteId).toEqual(quote.quote_id); + expect(parsed.costSummary.expected_total_cost_usd).toEqual(0.104361); + expect(parsed.costSummary.rankable_all_in).toBe(false); + expect(parsed.ttlSeconds).toEqual(60); + expect(parsed.directRouteSummary).toEqual(directRouteSummary); + expect(parsed.directRouteSummary.steps[0]).toMatchObject({ + provider: "circle_cctp", + from: "solana:USDC", + to: "base:USDC", + assetfare_fee_bps: 1, + }); + expect(parsed.agentGuidance.evaluationGuidance).toMatchObject({ + routeMinimumUsd: 1, + reachabilitySmokeOnly: true, + observedCompetitiveBucketUsd: 50, + observedEvidenceRoute: "solana:USDC->base:USDC", + thresholdClaimedForOtherCorridors: false, + representativeEconomicEvaluationUsd: 1000, + evidenceAsOf: "2026-09-23", + notAMinimum: true, + notGuaranteedBest: true, + alwaysCompareFreshAtIntendedAmount: true, + }); + expect(parsed.agentGuidance.transactionSigned).toBe(false); + expect(parsed.agentGuidance.transactionSubmitted).toBe(false); + expect(parsed.agentGuidance.continuationV3Verified).toBe(true); + expect(parsed.agentGuidance.automaticSelectionForbidden).toBe(true); + expect(parsed.agentGuidance.approvalV3Generated).toBe(false); + expect(parsed.agentGuidance.walletCollectionPerformed).toBe(false); + expect(parsed.agentGuidance.prepareCalls).toBe(0); + expect(parsed.agentGuidance.sessionCalls).toBe(0); + expect(parsed.agentGuidance.callerOwnedContinuation).toMatchObject({ + packageVersion: "1.7.1", + requiresFreshRequote: true, + requiresExplicitCallerApprovalBeforePlan: true, + providerReturnsRawQuote: false, + providerRemainsReadOnly: true, + outcome: "verified_unsigned_plan_only", + walletSignsAndSubmits: true, + assetFareServerSignsOrSubmits: false, + }); + expect(parsed.agentGuidance.callerOwnedContinuation.quoteCommand.args).toEqual([ + "--yes", + "--package=assetfare-mcp@1.7.1", + "assetfare-route-eval", + "--amount", + "250", + "--from-chain", + "solana", + "--from-token", + "USDC", + "--to-chain", + "base", + "--to-token", + "USDC", + "--quote-output", + "quote.json", + ]); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain("--select-exact-quote-bounds"); + expect(parsed.agentGuidance.callerOwnedContinuation.actionLifetime).toEqual({ + quoteTtlSeconds: 60, + actionBundleTtlSeconds: 180, + evmOnchainDeadlineSeconds: 240, + walletReadyMinimumRemainingSeconds: 120, + }); + expect( + parsed.agentGuidance.callerOwnedContinuation.walletReadyCommandTemplate.args, + ).toContain("wallet-ready"); + expect( + parsed.agentGuidance.callerOwnedContinuation.callerOwnedRunnerCommandTemplate.args, + ).toContain("assetfare-agent-runner"); + expect(parsed.agentGuidance.callerOwnedContinuation.callerOwnedRunner).toEqual({ + policySchema: "https://assetfare.dev/schemas/caller-owned-execution-policy-v1.json", + keyLocation: "caller_wallet_adapter_only", + remoteMcpExecutionTool: false, + assetFareServerKeyAccess: false, + assetFareServerSignsOrSubmits: false, + }); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain("solana="); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain("base="); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain(""); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain("--wallet-handoff-output"); + expect( + parsed.agentGuidance.callerOwnedContinuation.unsignedPlanCommandTemplate.args, + ).toContain("./caller-wallet-handoff.json"); + expect(JSON.stringify(parsed.agentGuidance.callerOwnedContinuation)).not.toMatch( + /private_key|seed_phrase|signed_transaction|session_token/i, + ); + expect(parsed.continuationDescriptor).toMatchObject({ + version: "assetfare-quote-bound-continuation-v3", + selection_status: "unranked_candidate", + required_wallet_chains: ["base", "solana"], + event_signer_public_required: true, + allowed_modes: ["one_shot", "session"], + recommended_mode: "one_shot_or_session", + openapi_url: "https://api.assetfare.dev/v2/openapi", + legacy_handoff_enforcement: "legacy_advisory", + approval_v3_generated: false, + }); + expect(parsed.continuationDescriptor.quote_fingerprint_claim).toBeUndefined(); + expect(parsed.continuationDescriptor.input_base_bounds).toBeUndefined(); + }); + + it("should fail closed on malformed, tampered, or auto-selected continuation_v3", async () => { + const mutators = [ + (value: Record) => { + delete value.continuation_v3; + }, + (value: Record) => { + continuationOf(value).extra = true; + }, + (value: Record) => { + continuationOf(value).selection_status = "selected"; + }, + (value: Record) => { + continuationOf(value).automatic_selection_forbidden = false; + }, + (value: Record) => { + continuationOf(value).caller_approved_boolean_is_not_human_proof = false; + }, + (value: Record) => { + continuationOf(value).quote_fingerprint = "0".repeat(64); + }, + (value: Record) => { + continuationOf(value).quote_payload_sha256_spec = "forbidden"; + }, + (value: Record) => { + continuationOf(value).required_wallet_chains = ["base"]; + }, + (value: Record) => { + continuationOf(value).allowed_modes = ["session"]; + }, + (value: Record) => { + asRecord(continuationOf(value).input_base_bounds).maximum = "250000001"; + }, + (value: Record) => { + asRecord(continuationOf(value).session_header).server_returns_raw_value = true; + }, + (value: Record) => { + asRecord(continuationOf(value).quote_fingerprint_claim).step_count = "2"; + }, + (value: Record) => { + asRecord(continuationOf(value).quote_fingerprint_claim).quote_payload_sha256_spec = + "forbidden"; + }, + ]; + for (const mutate of mutators) { + const hostile = structuredClone(quote); + mutate(hostile); + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => hostile }); + const result = await provider.getQuote(args); + expect(result).toContain("intent-bound direct_route_summary"); + } + }); + + it("should accept the portable payload hash for integral USD and raw base units above 2^53", async () => { + const portableQuote = structuredClone(quote); + const exactInput = "9007199254740993"; + const exactOutput = "9007199254740893"; + const exactMinimum = "9007199254740793"; + portableQuote.intent.amount_usd = 1000; + portableQuote.intent.estimated_input_base = Number(exactInput); + (portableQuote.route as Record).input_base = Number(exactInput); + (portableQuote.route as Record).expected_output_base = Number(exactOutput); + portableQuote.route.minimum_output_base = Number(exactMinimum); + const raw = portableQuote.route.steps[0] as Record; + raw.expected_input_base = Number(exactInput); + raw.floor_input_base = Number(exactInput); + raw.expected_output_base = Number(exactOutput); + raw.minimum_output_base = Number(exactMinimum); + Object.assign(portableQuote.direct_route_summary.steps[0], { + expected_input_base: exactInput, + minimum_input_base: exactInput, + expected_output_base: exactOutput, + minimum_output_base: exactMinimum, + }); + addContinuation(portableQuote); + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => portableQuote }); + const rawResult = await provider.getQuote({ ...args, amountUsd: 1000 }); + expect(rawResult).not.toContain("Error requesting AssetFare quote"); + const result = JSON.parse(rawResult); + expect(result.success).toBe(true); + expect(result.continuationDescriptor.quote_fingerprint).toBe( + asRecord(asRecord(portableQuote).continuation_v3).quote_fingerprint, + ); + }); + + it("should preserve JSON types and negative zero and reject unsafe evidence integers", () => { + const numeric = structuredClone(quote); + const string = structuredClone(quote); + const negativeZero = structuredClone(quote); + const positiveZero = structuredClone(quote); + (numeric.route.steps[0] as Record).semantic = 1; + (string.route.steps[0] as Record).semantic = "1"; + (negativeZero.route.steps[0] as Record).semantic = -0; + (positiveZero.route.steps[0] as Record).semantic = 0; + expect(quotePayloadSha256(numeric)).not.toBe(quotePayloadSha256(string)); + expect(quotePayloadSha256(negativeZero)).not.toBe(quotePayloadSha256(positiveZero)); + const unsafe = structuredClone(quote); + (unsafe.route.steps[0] as Record).semantic = 500000000000000000; + expect(() => quotePayloadSha256(unsafe)).toThrow("unsafe number"); + const invalidUnicode = structuredClone(quote); + (invalidUnicode.route.steps[0] as Record).semantic = "\ud800"; + expect(() => quotePayloadSha256(invalidUnicode)).toThrow("invalid unicode"); + }); + + it("should accept the exact Core 2.4.1 typed-canonical fixture", async () => { + const fixtureText = readFileSync( + join(__dirname, "fixtures", "core-241-unsafe-integer-quote.json"), + "utf8", + ); + expect(fixtureText).toContain('"amount_usd":1000.0'); + expect(fixtureText).toContain('"estimated_input_base":9007199254740993'); + const fixture = JSON.parse(fixtureText) as Record; + const fixtureIntent = asRecord(fixture.intent); + const fixtureSummary = asRecord(fixture.direct_route_summary); + const fixtureSummarySteps = fixtureSummary.steps as Array>; + expect(fixtureIntent.estimated_input_base).toBe(9007199254740992); + expect(fixtureSummarySteps[0].expected_input_base).toBe("9007199254740993"); + expect(quotePayloadSha256(fixture)).toBe( + "f071dead7a91a993e72ec086ac7948e801880bf24cda916ad0761e962249f17c", + ); + const now = jest.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-24T14:08:00Z")); + try { + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => fixture }); + const result = JSON.parse( + await provider.getQuote({ + fromChain: "base", + fromToken: "USDC", + toChain: "arbitrum", + toToken: "USDC", + amountUsd: 1000, + }), + ); + expect(result.success).toBe(true); + } finally { + now.mockRestore(); + } + }); + + it("should post the route intent in the API's snake_case shape", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => quote }); + + await provider.getQuote(args); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toEqual("https://api.assetfare.dev/v2/quote"); + expect(init.method).toEqual("POST"); + expect(JSON.parse(init.body)).toEqual({ + from_chain: "solana", + from_token: "USDC", + to_chain: "base", + to_token: "USDC", + amount_usd: 250, + }); + }); + + it("should fail closed when the quote does not report a no-submit boundary", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ ...quote, risk: { ...quote.risk, server_submission: true } }), + }); + + const result = await provider.getQuote(args); + + expect(result).toContain("intent-bound direct_route_summary"); + }); + + it("should fail closed when direct_route_summary is missing", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ ...quote, direct_route_summary: undefined }), + }); + + const result = await provider.getQuote(args); + + expect(result).toContain("intent-bound direct_route_summary"); + }); + + it("should fail closed when direct_route_summary is not bound to the requested intent", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + ...quote, + direct_route_summary: { + ...directRouteSummary, + from: "solana:SOL", + route: "solana:SOL->base:USDC", + steps: [ + { + ...directRouteSummary.steps[0], + from: "solana:SOL", + }, + ], + }, + }), + }); + + const result = await provider.getQuote(args); + + expect(result).toContain("intent-bound direct_route_summary"); + }); + + it("should fail closed on summary extras, fee mismatches, and raw-provider mismatches", async () => { + const hostileQuotes = [ + { + ...quote, + direct_route_summary: { ...directRouteSummary, private_key: "forbidden" }, + }, + { + ...quote, + direct_route_summary: { ...directRouteSummary, fee_collection_step_index: 1 }, + }, + { + ...quote, + route: { + ...quote.route, + steps: [{ ...quote.route.steps[0], provider: "across_intent_bridge" }], + }, + }, + ]; + + for (const hostileQuote of hostileQuotes) { + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => hostileQuote }); + const result = await provider.getQuote(args); + expect(result).toContain("intent-bound direct_route_summary"); + } + }); + + it("should require the Across caveat exactly when an external-intent step is present", () => { + const externalSummary = { + ...directRouteSummary, + route: "base:USDC->robinhood:USDG", + from: "base:USDC", + to: "robinhood:USDG", + classification: "external_intent", + mode: "robinhood_across_ingress_composition", + external_intent_protocol_used: true, + provider_internal_dex_aggregation_possible: true, + steps: [ + { + ...directRouteSummary.steps[0], + provider: "across_intent_bridge", + from: "base:USDC", + to: "robinhood:USDG", + direct_protocol: false, + external_intent_protocol: true, + }, + ], + }; + + expect(AssetFareDirectRouteSummarySchema.safeParse(externalSummary).success).toBe(true); + expect( + AssetFareDirectRouteSummarySchema.safeParse({ + ...externalSummary, + provider_internal_dex_aggregation_possible: false, + }).success, + ).toBe(false); + }); + + it("should surface API error messages", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: "route_not_supported" }), + }); + + const result = await provider.getQuote(args); + + expect(result).toContain("Error requesting AssetFare quote"); + expect(result).toContain("route_not_supported"); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for any network", () => { + expect(provider.supportsNetwork()).toBe(true); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts new file mode 100644 index 000000000..967fba9ae --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/assetfareActionProvider.ts @@ -0,0 +1,451 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { + ASSETFARE_EVALUATION_GUIDANCE, + AssetFareDirectRouteSummaryCapabilitiesSchema, + AssetFareDirectRouteSummarySchema, + GetCapabilitiesSchema, + GetQuoteSchema, +} from "./schemas"; +import { validatedContinuationDescriptor } from "./continuation"; + +/** + * Base URL for the public AssetFare v2 API. + */ +export const ASSETFARE_BASE_URL = "https://api.assetfare.dev"; + +/** + * Request timeout for AssetFare calls, in milliseconds. + */ +const REQUEST_TIMEOUT_MS = 45_000; + +type JsonRecord = Record; + +const isJsonRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +const callerOwnedContinuation = ( + args: z.infer, + descriptor: { required_wallet_chains: string[]; event_signer_public_required: boolean }, +) => ({ + packageVersion: "1.7.1", + requiresFreshRequote: true, + requiresExplicitCallerApprovalBeforePlan: true, + providerReturnsRawQuote: false, + providerRemainsReadOnly: true, + quoteCommand: { + executable: "npx", + args: [ + "--yes", + "--package=assetfare-mcp@1.7.1", + "assetfare-route-eval", + "--amount", + String(args.amountUsd), + "--from-chain", + args.fromChain, + "--from-token", + args.fromToken, + "--to-chain", + args.toChain, + "--to-token", + args.toToken, + "--quote-output", + "quote.json", + ], + }, + unsignedPlanCommandTemplate: { + executable: "npx", + args: [ + "--yes", + "--package=assetfare-mcp@1.7.1", + "assetfare-plan", + "--caller-approved", + "--mode", + "session", + "--quote", + "quote.json", + "--select-exact-quote-bounds", + ...descriptor.required_wallet_chains.flatMap(chain => [ + "--wallet", + `${chain}=`, + ]), + ...(descriptor.event_signer_public_required + ? ["--event-signer-public", ""] + : []), + "--session-token-output", + "./session-capability.json", + "--wallet-handoff-output", + "./caller-wallet-handoff.json", + ], + }, + actionLifetime: { + quoteTtlSeconds: 60, + actionBundleTtlSeconds: 180, + evmOnchainDeadlineSeconds: 240, + walletReadyMinimumRemainingSeconds: 120, + }, + walletReadyCommandTemplate: { + executable: "npx", + args: [ + "--yes", + "--package=assetfare-mcp@1.7.1", + "assetfare-session", + "--operation", + "wallet-ready", + "--capability-file", + "./session-capability.json", + "--idempotency-key", + "", + "--wallet-handoff-output", + "./wallet-ready-handoff.json", + ], + }, + callerOwnedRunnerCommandTemplate: { + executable: "npx", + args: [ + "--yes", + "--package=assetfare-mcp@1.7.1", + "assetfare-agent-runner", + "--preflight", + "--capability-file", + "./session-capability.json", + "--policy-file", + "./caller-execution-policy.json", + "--wallet-adapter", + "./my-local-wallet-adapter.mjs", + ], + }, + callerOwnedRunner: { + policySchema: "https://assetfare.dev/schemas/caller-owned-execution-policy-v1.json", + keyLocation: "caller_wallet_adapter_only", + remoteMcpExecutionTool: false, + assetFareServerKeyAccess: false, + assetFareServerSignsOrSubmits: false, + }, + outcome: "verified_unsigned_plan_only", + walletSignsAndSubmits: true, + assetFareServerSignsOrSubmits: false, +}); + +/** + * Validates that a direct-route summary is bound to the requested intent and to the + * duplicate route, risk, fee, and raw-provider fields in the quote response. + * + * @param quote - Raw AssetFare quote response + * @param args - Caller-requested quote intent + * @returns The validated direct-route summary, or undefined on any mismatch + */ +const validatedDirectRouteSummary = ( + quote: JsonRecord, + args: z.infer, +): z.infer | undefined => { + const parsed = AssetFareDirectRouteSummarySchema.safeParse(quote.direct_route_summary); + if (!parsed.success) return undefined; + + const summary = parsed.data; + const intent = quote.intent; + const route = quote.route; + const risk = quote.risk; + const offer = quote.offer; + const expectedFrom = `${args.fromChain}:${args.fromToken}`; + const expectedTo = `${args.toChain}:${args.toToken}`; + const expectedRoute = `${expectedFrom}->${expectedTo}`; + + if ( + !isJsonRecord(intent) || + intent.from !== expectedFrom || + intent.to !== expectedTo || + intent.amount_usd !== args.amountUsd || + summary.from !== expectedFrom || + summary.to !== expectedTo || + summary.route !== expectedRoute || + !isJsonRecord(route) || + route.route !== summary.route || + route.mode !== summary.mode || + route.aggregator_api_used !== summary.route_aggregator_used || + route.external_intent_protocol_used !== summary.external_intent_protocol_used || + route.server_signing !== false || + route.server_submission !== false || + !isJsonRecord(risk) || + risk.external_intent_protocol_used !== summary.external_intent_protocol_used || + risk.provider_internal_dex_aggregation_possible !== + summary.provider_internal_dex_aggregation_possible || + risk.server_signing !== false || + risk.server_submission !== false || + !isJsonRecord(offer) || + offer.assetfare_fee_bps !== summary.assetfare_fee_bps + ) { + return undefined; + } + + const rawSteps = route.steps; + const feeCollectionSteps = offer.fee_collection_steps; + if ( + !Array.isArray(rawSteps) || + rawSteps.length !== summary.step_count || + !Array.isArray(feeCollectionSteps) || + feeCollectionSteps.length !== 1 || + feeCollectionSteps[0] !== summary.fee_collection_step_index + ) { + return undefined; + } + + const rawStepsMatch = rawSteps.every((rawStep, index) => { + if (!isJsonRecord(rawStep)) return false; + const summaryStep = summary.steps[index]; + return ( + rawStep.index === index && + rawStep.provider === summaryStep.provider && + rawStep.route_fee_bps === summaryStep.assetfare_fee_bps && + rawStep.kind === (summaryStep.action === "swap" ? "direct_swap" : "direct_bridge") + ); + }); + + return rawStepsMatch ? summary : undefined; +}; + +/** + * Configuration options for the AssetFare action provider. + */ +export interface AssetFareActionProviderConfig { + /** + * Base URL of the AssetFare API. Defaults to the public endpoint. + */ + apiBaseUrl?: string; +} + +/** + * AssetFareActionProvider provides read-only cross-chain bridge and swap route quotes + * from the AssetFare public v2 API. It never holds keys, authenticates a wallet, prepares + * an unsigned action, signs, or submits a transaction. + */ +export class AssetFareActionProvider extends ActionProvider { + private readonly apiBaseUrl: string; + + /** + * Constructor for the AssetFareActionProvider class. + * + * @param config - Optional configuration, such as an alternate API base URL + */ + constructor(config: AssetFareActionProviderConfig = {}) { + super("assetfare", []); + this.apiBaseUrl = (config.apiBaseUrl ?? ASSETFARE_BASE_URL).replace(/\/+$/, ""); + } + + /** + * Reads the AssetFare capability surface and live route readiness. + * Availability is dynamic, so this should be read before relying on a route. + * + * @param _args - Empty input + * @returns A JSON string containing the supported routes and live readiness, or an error message + */ + @CreateAction({ + name: "get_capabilities", + description: `This tool reads the live AssetFare capability surface for non-custodial cross-chain bridge and swap routes. +It takes no inputs. + +Important notes: +- Returns the supported chains and asset endpoints, the number of implemented directed routes, and how many are currently ready to prepare +- Route availability is live, not static: read this before treating any route as usable +- Fees: the AssetFare service fee is 1bp; Circle, provider, and network fees are additional and appear in each quote +- Quote sizing: the API minimum is USD 1, but USD 1 is only a reachability smoke. USD 50 was an observed competitive bucket only for dated 2026-09-23 Solana USDC to Base USDC evidence; no threshold is claimed for another corridor. USD 1,000 is the representative amount, not a minimum or guarantee +- Always compare fresh quotes at the actual intended amount; SOL-input routes add a source swap whose full fee-inclusive economics require separate evaluation +- AssetFare never signs or submits a transaction; the caller verifies, signs, and submits every action with its own wallet +- This action is read-only and performs no wallet, signing, or submission operation`, + schema: GetCapabilitiesSchema, + }) + async getCapabilities(_args: z.infer): Promise { + try { + const capabilities = await this.request("/v2/capabilities"); + + const directRouteSummaryContract = AssetFareDirectRouteSummaryCapabilitiesSchema.safeParse( + capabilities.direct_route_summary, + ); + + if (capabilities.server_signing !== false || capabilities.server_submission !== false) { + return "Error reading AssetFare capabilities: the service did not report a non-custodial no-sign, no-submit boundary"; + } + if (!directRouteSummaryContract.success) { + return "Error reading AssetFare capabilities: the service did not report a valid mandatory direct_route_summary contract"; + } + + return JSON.stringify( + { + success: true, + chains: capabilities.chains, + assetEndpoints: capabilities.asset_endpoints, + sourceOnlyAssetEndpoints: capabilities.source_only_asset_endpoints, + implementedRoutes: capabilities.execution_implemented_routes, + currentlyPrepareReadyRoutes: capabilities.currently_prepare_ready_routes, + temporarilyUnavailableRoutes: capabilities.temporarily_unavailable_routes, + executionAvailability: capabilities.execution_availability, + serverSigning: capabilities.server_signing, + serverSubmission: capabilities.server_submission, + directRouteSummary: directRouteSummaryContract.data, + evaluationGuidance: ASSETFARE_EVALUATION_GUIDANCE, + feeNote: + "AssetFare service fee is 1bp; Circle, provider, and network fees are additional. Each quote exposes the total token-path cost and live availability.", + }, + null, + 2, + ); + } catch (error: unknown) { + return `Error reading AssetFare capabilities: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Requests one fresh AssetFare route quote and returns its cost, timing, and risk fields. + * + * @param args - The route and USD notional to quote + * @returns A JSON string containing the quote, or an error message + */ + @CreateAction({ + name: "get_quote", + description: `This tool requests one fresh, read-only AssetFare quote for a non-custodial cross-chain bridge or swap route, for example Solana USDC to Base USDC. +It takes the following inputs: +- The source chain and asset symbol +- The destination chain and asset symbol +- A finite USD notional of at least 1; AssetFare publishes no business maximum, while live liquidity and capacity still apply + +Important notes: +- USD 1 is supported only for reachability smoke testing, not as an economic-evaluation default +- USD 50 was an observed competitive bucket only for dated 2026-09-23 Solana USDC to Base USDC evidence; no threshold is claimed for another corridor. USD 1,000 is the representative amount, including for SOL-input routes, but neither value is a minimum or a guarantee that AssetFare is best +- Always compare fresh quotes at the actual intended amount; SOL-input routes add a source swap whose full fee-inclusive economics require separate evaluation +- Judge the route by the quote's total token-path cost and expected or minimum receive, never by the 1bp service fee alone: Circle, provider, and network fees are additional +- Returns a strictly validated directRouteSummary: the intent-bound ordered provider/from/to path, exact decimal-string base-unit bounds, single AssetFare fee step, and direct_protocol_only versus external_intent classification +- Reject the whole quote if that summary is missing, malformed, discontinuous, or inconsistent with the requested intent, route, risk, fee, or raw provider steps +- route_aggregator_used=false describes AssetFare's route engine only; an Across external_intent step may use provider-internal liquidity sourcing or aggregation +- The quote reports which costs are unpriced (for example source-chain gas), so it is not an all-in ranking figure +- Quotes are short-lived (ttl_seconds) and routes are non-atomic multi-step workflows +- Treat AssetFare as one candidate and compare the returned totals against other fresh executable routes before choosing +- This action is read-only: it never authenticates a wallet, prepares an unsigned action, signs, or submits. Any later preparation requires explicit caller approval outside this provider +- Returns a sanitized continuationDescriptor only after validating the complete continuation_v3 fingerprint, hashes, expiry, exact bounds, wallet-chain/event-signer requirements, and allowed mode +- The descriptor remains unranked: this provider never creates approval_v3, selects a candidate, collects wallets, or calls prepare/session. caller_approved alone is not proof of human approval; legacy handoff is advisory +- Multi-step routes are session-only and a caller must choose exactly one continuation path outside this provider +- Never send a private key or seed phrase to any route service`, + schema: GetQuoteSchema, + }) + async getQuote(args: z.infer): Promise { + try { + const quote = await this.request("/v2/quote", { + method: "POST", + body: JSON.stringify({ + from_chain: args.fromChain, + from_token: args.fromToken, + to_chain: args.toChain, + to_token: args.toToken, + amount_usd: args.amountUsd, + }), + }); + + const risk = quote.risk as JsonRecord | undefined; + const execution = quote.execution as JsonRecord | undefined; + const costSummary = quote.cost_summary as JsonRecord | undefined; + const directRouteSummary = validatedDirectRouteSummary(quote, args); + const continuationDescriptor = directRouteSummary + ? validatedContinuationDescriptor(quote, directRouteSummary) + : undefined; + + if ( + risk?.server_signing !== false || + risk?.server_submission !== false || + execution?.supported !== true || + !costSummary || + costSummary.scope !== "token_path_only_network_gas_excluded" || + !directRouteSummary || + !continuationDescriptor || + typeof quote.ttl_seconds !== "number" || + quote.ttl_seconds <= 0 || + quote.ttl_seconds > 60 + ) { + return "Error requesting AssetFare quote: the quote did not report a valid non-custodial, intent-bound direct_route_summary and continuation_v3"; + } + + return JSON.stringify( + { + success: true, + quoteId: quote.quote_id, + asOf: quote.as_of, + ttlSeconds: quote.ttl_seconds, + intent: quote.intent, + offer: quote.offer, + costSummary: quote.cost_summary, + eta: quote.eta, + risk: quote.risk, + execution: quote.execution, + directRouteSummary, + continuationDescriptor, + agentGuidance: { + evaluationGuidance: ASSETFARE_EVALUATION_GUIDANCE, + compareWithOtherRoutes: true, + requireFreshQuoteBeforeSelection: true, + walletAuthenticationPerformed: false, + actionPrepared: false, + transactionSigned: false, + transactionSubmitted: false, + continuationV3Verified: true, + automaticSelectionForbidden: true, + approvalV3Generated: false, + walletCollectionPerformed: false, + prepareCalls: 0, + sessionCalls: 0, + callerOwnedContinuation: callerOwnedContinuation(args, continuationDescriptor), + }, + }, + null, + 2, + ); + } catch (error: unknown) { + return `Error requesting AssetFare quote: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Checks if the AssetFare action provider supports the given network. + * AssetFare quotes are read from a public API and are not bound to the agent's wallet network. + * + * @returns True, as AssetFare quote actions are available on all networks. + */ + supportsNetwork(): boolean { + return true; + } + + /** + * Performs a JSON request against the AssetFare API. + * + * @param path - API path beginning with a slash + * @param init - Optional fetch options + * @returns The parsed JSON body + */ + private async request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${this.apiBaseUrl}${path}`, { + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + const body = (await response.json()) as JsonRecord; + + if (!response.ok) { + const message = body.error ?? body.message ?? `HTTP ${response.status}`; + throw new Error(String(message)); + } + + return body; + } +} + +/** + * Creates a new instance of the AssetFare action provider. + * + * @param config - Optional configuration, such as an alternate API base URL + * @returns A new AssetFareActionProvider instance + */ +export const assetfareActionProvider = (config: AssetFareActionProviderConfig = {}) => + new AssetFareActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/assetfare/continuation.ts b/typescript/agentkit/src/action-providers/assetfare/continuation.ts new file mode 100644 index 000000000..c2a9944ad --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/continuation.ts @@ -0,0 +1,479 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { AssetFareDirectRouteSummarySchema } from "./schemas"; + +const VERSION = "assetfare-quote-bound-continuation-v3"; +const QUOTE_PAYLOAD_SHA256_SPEC = + "sha256(AssetFare typed-canonical-v1 bytes of the quote without continuation_v3 after exact base-unit substitution: n=null; t/f=boolean; d= for each finite JSON number; s=:; a=:[items]; o=:{UTF-8-byte-sorted string-key/value pairs}; every non-substituted integral JSON number must be within +/-9007199254740991; substituted paths are intent.estimated_input_base, route.input_base, route.expected_output_base, route.minimum_output_base, and every route.steps[i].expected_input_base/floor_input_base/expected_output_base/minimum_output_base from direct_route_summary exact decimal strings)"; +const HASH = /^[0-9a-f]{64}$/; +const POSITIVE = /^[1-9][0-9]*$/; +const CHAINS = ["arbitrum", "base", "optimism", "polygon", "robinhood", "solana"] as const; +const APPROVAL_FIELDS = [ + "direct_route_summary_sha256", + "idempotency_key", + "maximum_input_base", + "minimum_output_base", + "quote_fingerprint", + "quote_id", + "selected_mode", + "selection_status", + "version", +] as const; +const fingerprintSpec = + "sha256(UTF-8 sorted-key compact JSON of quote_fingerprint_claim; every numeric claim is a non-exponent decimal string)"; +const hash = z.string().regex(HASH); +const positive = z.string().regex(POSITIVE); +const chain = z.enum(CHAINS); + +const claimSchema = z + .object({ + version: z.literal(VERSION), + quote_id: z.string().uuid(), + issued_at: z.string().datetime({ offset: true }), + expires_at: z.string().datetime({ offset: true }), + ttl_seconds: positive, + intent: z + .object({ + from: z.string(), + to: z.string(), + amount_usd_decimal: z.string().regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/), + estimated_input_base: positive, + }) + .strict(), + direct_route_summary_sha256: hash, + quote_payload_sha256: hash, + quote_payload_sha256_spec: z.literal(QUOTE_PAYLOAD_SHA256_SPEC), + input_base_bounds: z.object({ minimum: positive, maximum: positive }).strict(), + minimum_output_base: positive, + required_wallet_chains: z.array(chain).min(1).max(6), + event_signer_public_required: z.boolean(), + step_count: positive, + allowed_modes: z + .array(z.enum(["one_shot", "session"])) + .min(1) + .max(2), + server_signing: z.literal(false), + server_submission: z.literal(false), + }) + .strict(); + +const continuationSchema = z + .object({ + version: z.literal(VERSION), + enforcement: z.literal("server_enforced_quote_binding"), + selection_status: z.literal("unranked_candidate"), + automatic_selection_forbidden: z.literal(true), + caller_approved_boolean_is_not_human_proof: z.literal(true), + quote_id: z.string().uuid(), + quote_fingerprint: hash, + quote_fingerprint_spec: z.literal(fingerprintSpec), + quote_fingerprint_claim: claimSchema, + issued_at: z.string().datetime({ offset: true }), + expires_at: z.string().datetime({ offset: true }), + ttl_seconds: z.number().int().min(1).max(60), + intent: z + .object({ + from: z.string(), + to: z.string(), + amount_usd: z.number().finite(), + estimated_input_base: z + .number() + .finite() + .positive() + .refine(Number.isInteger, { message: "estimated input base must be an integer" }), + }) + .passthrough(), + direct_route_summary_sha256: hash, + quote_payload_sha256: hash, + quote_payload_sha256_spec: z.literal(QUOTE_PAYLOAD_SHA256_SPEC), + input_base_bounds: z.object({ minimum: positive, maximum: positive }).strict(), + minimum_output_base: positive, + required_wallet_chains: z.array(chain).min(1).max(6), + event_signer_public_required: z.boolean(), + step_count: z.number().int().min(1).max(8), + recommended_mode: z.enum(["session", "one_shot_or_session"]), + allowed_modes: z + .array(z.enum(["one_shot", "session"])) + .min(1) + .max(2), + session_header: z + .object({ + name: z.literal("X-AssetFare-Session-Token"), + required_for: z.literal("session"), + caller_generated: z.literal(true), + minimum_entropy_bits: z.literal(256), + server_returns_raw_value: z.literal(false), + }) + .strict(), + idempotency: z + .object({ + required: z.literal(true), + field: z.literal("idempotency_key"), + pattern: z.literal("^[A-Za-z0-9._:-]{8,128}$"), + scope: z.literal("quote_and_selected_mode"), + }) + .strict(), + approval_v3_required_fields: z.tuple([ + z.literal(APPROVAL_FIELDS[0]), + z.literal(APPROVAL_FIELDS[1]), + z.literal(APPROVAL_FIELDS[2]), + z.literal(APPROVAL_FIELDS[3]), + z.literal(APPROVAL_FIELDS[4]), + z.literal(APPROVAL_FIELDS[5]), + z.literal(APPROVAL_FIELDS[6]), + z.literal(APPROVAL_FIELDS[7]), + z.literal(APPROVAL_FIELDS[8]), + ]), + legacy_handoff_enforcement: z.literal("legacy_advisory"), + server_signing: z.literal(false), + server_submission: z.literal(false), + }) + .strict(); + +type JsonRecord = Record; + +export interface AssetFareContinuationDescriptor { + version: typeof VERSION; + quote_id: string; + quote_fingerprint: string; + expires_at: string; + ttl_seconds: number; + selection_status: "unranked_candidate"; + required_wallet_chains: string[]; + event_signer_public_required: boolean; + allowed_modes: Array<"one_shot" | "session">; + recommended_mode: "session" | "one_shot_or_session"; + openapi_url: "https://api.assetfare.dev/v2/openapi"; + legacy_handoff_enforcement: "legacy_advisory"; + automatic_selection_forbidden: true; + caller_approved_boolean_is_not_human_proof: true; + wallet_collection_performed: false; + approval_v3_generated: false; + prepare_calls: 0; + session_calls: 0; + server_signing: false; + server_submission: false; +} + +/** + * Canonical sorted-key compact JSON used by the REST 2.4 fingerprint contract. + * + * @param value - JSON value to encode + * @returns Canonical JSON text + */ +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const object = value as JsonRecord; + return `{${Object.keys(object) + .sort() + .map(key => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +} + +/** + * Returns the lowercase SHA-256 of canonical JSON. + * + * @param value - JSON value to hash + * @returns Lowercase hexadecimal digest + */ +function sha256(value: unknown): string { + return createHash("sha256").update(canonical(value), "utf8").digest("hex"); +} + +/** + * Compares ordered arrays without coercion. + * + * @param value - Actual ordered values + * @param expected - Expected ordered values + * @returns Whether every value matches at the same index + */ +function exactArray(value: readonly unknown[], expected: readonly unknown[]): boolean { + return value.length === expected.length && value.every((item, index) => item === expected[index]); +} + +/** + * Compares a parsed JSON base-unit number with an exact decimal string, including >2^53 rounding. + * + * @param value - Parsed JSON number + * @param exact - Exact decimal string from the validated summary + * @returns Whether the parsed number is the JavaScript representation of the exact value + */ +function rawNumberMatches(value: unknown, exact: string): boolean { + return ( + typeof value === "number" && Number.isInteger(value) && value > 0 && Number(exact) === value + ); +} + +/** + * Renders a finite JSON number as a language-neutral non-exponent decimal. + * + * @param value - Finite JSON number + * @returns Plain decimal representation + */ +function decimalString(value: number): string { + const source = String(value); + const match = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(source); + if (!match) throw new Error("continuation numeric claim invalid"); + const negative = match[1] === "-"; + const whole = match[2]; + const fraction = match[3] || ""; + const exponent = Number(match[4] || 0); + let digits = whole + fraction; + let point = whole.length + exponent; + if (point <= 0) { + digits = "0".repeat(-point) + digits; + point = 0; + } + if (point >= digits.length) digits += "0".repeat(point - digits.length); + let rendered = + point === 0 + ? `0.${digits}` + : point === digits.length + ? digits + : `${digits.slice(0, point)}.${digits.slice(point)}`; + if (rendered.includes(".")) rendered = rendered.replace(/0+$/, "").replace(/\.$/, ""); + rendered = rendered.replace(/^0+(?=\d)/, "") || "0"; + if (rendered.startsWith(".")) rendered = `0${rendered}`; + if (/^0(?:\.0*)?$/.test(rendered)) return "0"; + return negative ? `-${rendered}` : rendered; +} + +/** + * Reports whether a JavaScript string contains an unpaired UTF-16 surrogate. + * + * @param value - String to inspect + * @returns Whether the string is not valid Unicode scalar text + */ +function hasLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) return true; + } + return false; +} + +/** + * Encodes a JSON value with AssetFare typed-canonical-v1. + * + * @param value - JSON value to encode + * @returns Typed canonical bytes + */ +function typedCanonical(value: unknown): Buffer { + if (value === null) return Buffer.from("n", "ascii"); + if (value === true) return Buffer.from("t", "ascii"); + if (value === false) return Buffer.from("f", "ascii"); + if (typeof value === "number") { + if ( + !Number.isFinite(value) || + (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) + ) + throw new Error("continuation payload unsafe number"); + const bytes = Buffer.allocUnsafe(8); + bytes.writeDoubleBE(value); + return Buffer.from(`d${bytes.toString("hex")}`, "ascii"); + } + if (typeof value === "string") { + if (hasLoneSurrogate(value)) throw new Error("continuation payload invalid unicode"); + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from(`s${bytes.length}:`, "ascii"), bytes]); + } + if (Array.isArray(value)) + return Buffer.concat([ + Buffer.from(`a${value.length}:[`, "ascii"), + ...value.map(typedCanonical), + Buffer.from("]", "ascii"), + ]); + if (typeof value !== "object") throw new Error("continuation payload invalid"); + const entries = Object.entries(value as JsonRecord).sort(([left], [right]) => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")), + ); + return Buffer.concat([ + Buffer.from(`o${entries.length}:{`, "ascii"), + ...entries.flatMap(([key, item]) => [typedCanonical(key), typedCanonical(item)]), + Buffer.from("}", "ascii"), + ]); +} + +/** + * Builds the Core 2.4 portable quote-payload projection before hashing. + * + * @param quote - Parsed quote response + * @param summary - Strictly validated direct-route summary + * @returns Portable quote projection with exact duplicated base units and normalized numbers + */ +function quotePayloadProjection( + quote: JsonRecord, + summary: z.infer, +): JsonRecord { + const payload = structuredClone(quote); + delete payload.continuation_v3; + const intent = payload.intent as JsonRecord | undefined; + const route = payload.route as JsonRecord | undefined; + const rawSteps = route?.steps; + if (!intent || !route || !Array.isArray(rawSteps) || rawSteps.length !== summary.steps.length) + throw new Error("continuation payload invalid"); + intent.estimated_input_base = summary.steps[0].expected_input_base; + route.input_base = summary.steps[0].expected_input_base; + route.expected_output_base = summary.steps[summary.steps.length - 1].expected_output_base; + route.minimum_output_base = summary.steps[summary.steps.length - 1].minimum_output_base; + rawSteps.forEach((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) + throw new Error("continuation payload invalid"); + const raw = item as JsonRecord; + const exact = summary.steps[index]; + raw.expected_input_base = exact.expected_input_base; + raw.floor_input_base = exact.minimum_input_base; + raw.expected_output_base = exact.expected_output_base; + raw.minimum_output_base = exact.minimum_output_base; + }); + return payload; +} + +/** + * Hashes the portable quote-payload projection. + * + * @param quote - Parsed quote response + * @param summary - Strictly validated direct-route summary + * @returns Lowercase SHA-256 digest + */ +function quotePayloadSha256( + quote: JsonRecord, + summary: z.infer, +): string { + return createHash("sha256") + .update(typedCanonical(quotePayloadProjection(quote, summary))) + .digest("hex"); +} + +/** + * Validates the complete quote-bound continuation and returns only non-executable discovery metadata. + * + * @param quote - Raw AssetFare quote + * @param summary - Already validated direct-route summary + * @returns A sanitized unranked descriptor, or undefined on any mismatch + */ +export function validatedContinuationDescriptor( + quote: JsonRecord, + summary: z.infer, +): AssetFareContinuationDescriptor | undefined { + const parsed = continuationSchema.safeParse(quote.continuation_v3); + if (!parsed.success) return undefined; + const continuation = parsed.data; + const intent = quote.intent as JsonRecord | undefined; + const route = quote.route as JsonRecord | undefined; + if (!intent || !route) return undefined; + const requiredChains = [ + ...new Set( + summary.steps.flatMap(step => [step.from.split(":", 1)[0], step.to.split(":", 1)[0]]), + ), + ].sort(); + const signerRequired = summary.steps.some( + step => step.provider === "circle_cctp" && step.from.startsWith("solana:"), + ); + const modes: Array<"one_shot" | "session"> = + summary.step_count > 1 ? ["session"] : ["one_shot", "session"]; + const recommended = summary.step_count > 1 ? "session" : "one_shot_or_session"; + const inputBase = summary.steps[0].expected_input_base; + const expectedOutput = summary.steps[summary.steps.length - 1].expected_output_base; + const minimumOutput = summary.steps[summary.steps.length - 1].minimum_output_base; + const rawSteps = route.steps; + if ( + !rawNumberMatches(intent.estimated_input_base, inputBase) || + !rawNumberMatches(route.input_base, inputBase) || + !rawNumberMatches(route.expected_output_base, expectedOutput) || + !rawNumberMatches(route.minimum_output_base, minimumOutput) || + !Array.isArray(rawSteps) || + rawSteps.length !== summary.steps.length || + rawSteps.some((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return true; + const raw = item as JsonRecord; + const exact = summary.steps[index]; + return ( + !rawNumberMatches(raw.expected_input_base, exact.expected_input_base) || + !rawNumberMatches(raw.floor_input_base, exact.minimum_input_base) || + !rawNumberMatches(raw.expected_output_base, exact.expected_output_base) || + !rawNumberMatches(raw.minimum_output_base, exact.minimum_output_base) + ); + }) + ) + return undefined; + const summaryHash = sha256(summary); + const payloadHash = quotePayloadSha256(quote, summary); + const issued = Date.parse(continuation.issued_at); + const expires = Date.parse(continuation.expires_at); + const now = Date.now(); + if ( + continuation.quote_id !== quote.quote_id || + canonical(continuation.intent) !== canonical(intent) || + continuation.direct_route_summary_sha256 !== summaryHash || + continuation.quote_payload_sha256 !== payloadHash || + continuation.quote_fingerprint_claim.direct_route_summary_sha256 !== summaryHash || + continuation.quote_fingerprint_claim.quote_payload_sha256 !== payloadHash || + sha256(continuation.quote_fingerprint_claim) !== continuation.quote_fingerprint || + continuation.input_base_bounds.minimum !== inputBase || + continuation.input_base_bounds.maximum !== inputBase || + continuation.minimum_output_base !== minimumOutput || + continuation.step_count !== summary.step_count || + !exactArray(continuation.required_wallet_chains, requiredChains) || + continuation.event_signer_public_required !== signerRequired || + !exactArray(continuation.allowed_modes, modes) || + continuation.recommended_mode !== recommended || + expires - issued !== continuation.ttl_seconds * 1000 || + expires <= now || + issued > now + 300_000 + ) + return undefined; + const expectedClaim = { + version: VERSION, + quote_id: quote.quote_id, + issued_at: continuation.issued_at, + expires_at: continuation.expires_at, + ttl_seconds: String(continuation.ttl_seconds), + intent: { + from: intent.from, + to: intent.to, + amount_usd_decimal: decimalString(intent.amount_usd as number), + estimated_input_base: inputBase, + }, + direct_route_summary_sha256: summaryHash, + quote_payload_sha256: payloadHash, + quote_payload_sha256_spec: QUOTE_PAYLOAD_SHA256_SPEC, + input_base_bounds: { minimum: inputBase, maximum: inputBase }, + minimum_output_base: minimumOutput, + required_wallet_chains: requiredChains, + event_signer_public_required: signerRequired, + step_count: String(summary.step_count), + allowed_modes: modes, + server_signing: false, + server_submission: false, + }; + if (canonical(continuation.quote_fingerprint_claim) !== canonical(expectedClaim)) + return undefined; + return { + version: VERSION, + quote_id: continuation.quote_id, + quote_fingerprint: continuation.quote_fingerprint, + expires_at: continuation.expires_at, + ttl_seconds: continuation.ttl_seconds, + selection_status: "unranked_candidate", + required_wallet_chains: requiredChains, + event_signer_public_required: signerRequired, + allowed_modes: modes, + recommended_mode: recommended, + openapi_url: "https://api.assetfare.dev/v2/openapi", + legacy_handoff_enforcement: "legacy_advisory", + automatic_selection_forbidden: true, + caller_approved_boolean_is_not_human_proof: true, + wallet_collection_performed: false, + approval_v3_generated: false, + prepare_calls: 0, + session_calls: 0, + server_signing: false, + server_submission: false, + }; +} diff --git a/typescript/agentkit/src/action-providers/assetfare/fixtures/core-241-unsafe-integer-quote.json b/typescript/agentkit/src/action-providers/assetfare/fixtures/core-241-unsafe-integer-quote.json new file mode 100644 index 000000000..2a3fece9b --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/fixtures/core-241-unsafe-integer-quote.json @@ -0,0 +1 @@ +{"quote_id":"650e3142-94b1-4b17-a2f2-05ec139d0886","status":"capped_public_agent_release","version":"assetfare-direct-multichain-api-quote-v2","as_of":"2026-09-24T14:07:49.635Z","ttl_seconds":60,"intent":{"from":"base:USDC","to":"arbitrum:USDC","amount_usd":1000.0,"estimated_input_base":9007199254740993},"cost_summary":{"scope":"token_path_only_network_gas_excluded","input_value_usd":1000.0,"expected_receive_value_usd":990.0,"minimum_receive_value_usd":980.0,"expected_total_cost_usd":10.0,"maximum_total_cost_usd":20.0,"expected_total_cost_percent":1.0,"maximum_total_cost_percent":2.0,"assetfare_service_fee":{"bps":1,"estimated_usd":0.1,"included_in_receive_amount":true,"note":"AssetFare service fee only; not the total route cost"},"provider_fee_components":[{"provider":"circle_cctp","kind":"standard_transfer_protocol_fee","expected_usd":0.0,"maximum_usd":0.0,"included_in_receive_amount":true},{"provider":"circle_cctp","kind":"destination_forwarding_fee","expected_usd":0.0,"maximum_usd":0.0,"included_in_receive_amount":true,"includes_destination_gas":false}],"unpriced_costs":["source_chain_network_fee"],"rankable_all_in":false,"small_amount_warning":true,"warning":"Fixed provider fees are large relative to this transfer; compare expected/minimum receive values before signing."},"eta":{"estimated_time_seconds":null,"estimated_time_range_seconds":null,"complete_route_estimate":false,"sources":[],"note":"Estimate, not a settlement guarantee"},"offer":{"expected_receive_amount":990.0,"estimated_min_receive_amount":980.0,"output_symbol":"USDC","expected_receive_usd":990.0,"estimated_min_receive_usd":980.0,"estimated_time_seconds":null,"assetfare_fee_bps":1,"fee_modeled_bps":1,"fee_collectible_now":true,"fee_blocker":null,"fee_collection_steps":[0],"fee_collection":"only_on_eligible_successful_executor_step","cost_disclosure":{"token_minimum_includes_route_and_protocol_fees":true,"source_gas_included":false,"destination_gas_included":false,"destination_native_gas_required":false,"unpriced_costs":["source_chain_network_fee"],"rankable_all_in":false}},"route":{"status":"pass","version":"assetfare-direct-multichain-quote-v2","route":"base:USDC->arbitrum:USDC","mode":"cctp_direct_composition","input_base":9007199254740993,"expected_output_base":990000000,"minimum_output_base":980000000,"steps":[{"index":0,"kind":"direct_bridge","provider":"circle_cctp","from":"base","to":"arbitrum","asset":"USDC","route_fee_bps":1,"expected_input_base":9007199254740993,"floor_input_base":9007199254740993,"expected_output_base":990000000,"minimum_output_base":980000000,"expected_evidence":{"status":"pass","inputAmount":"1000000000","aggregatorApiUsed":false},"floor_evidence":null}],"quote_latency_ms":0,"aggregator_api_used":false,"external_intent_protocol_used":false,"server_signing":false,"server_submission":false},"direct_route_summary":{"version":"assetfare-direct-route-summary-v1","route":"base:USDC->arbitrum:USDC","from":"base:USDC","to":"arbitrum:USDC","classification":"direct_protocol_only","mode":"cctp_direct_composition","route_aggregator_used":false,"external_intent_protocol_used":false,"provider_internal_dex_aggregation_possible":false,"assetfare_fee_bps":1,"fee_collection_step_index":0,"server_signing":false,"server_submission":false,"step_count":1,"steps":[{"index":0,"action":"bridge","provider":"circle_cctp","from":"base:USDC","to":"arbitrum:USDC","expected_input_base":"9007199254740993","minimum_input_base":"9007199254740993","expected_output_base":"990000000","minimum_output_base":"980000000","assetfare_fee_bps":1,"direct_protocol":true,"external_intent_protocol":false,"aggregator_api_used":false}]},"risk":{"non_atomic":true,"fresh_quote_required_each_step":true,"external_intent_protocol_used":false,"provider_internal_dex_aggregation_possible":false,"server_signing":false,"server_submission":false},"execution":{"supported":true,"first_unsigned_action_supported":true,"future_actions_require_verified_receipts":true,"public_api_enabled":true,"blocker":null,"current_prepare_readiness":"available","provider_health_checked_by_quote":true,"provider_health_is_not_future_guarantee":true},"caller_action_plan_handoff":{"kind":"caller_operated_rest_prepare","url":"https://api.assetfare.dev/v2/prepare","method":"POST","requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"request_fields":["caller_approved","from_chain","from_token","to_chain","to_token","amount_usd","wallets","event_signer_public"],"assetfare_server_signing":false,"assetfare_server_submission":false,"caller_must_verify_sign_and_submit":true,"requires_fresh_requote":true,"automatic_prepare_call_forbidden":true,"options":[{"kind":"one_shot_first_unsigned_bundle","method":"POST","url":"https://api.assetfare.dev/v2/prepare","requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"assetfare_never_signs_submits_or_auto_calls":true,"note":"Stateless: returns only the FIRST unsigned bundle (a preview/manual first action, not a session); the caller signs and submits. Choose this OR the session, not both; do not start a session after submitting this."},{"kind":"caller_approved_full_workflow_session","method":"POST","url":"https://api.assetfare.dev/v2/session","lifecycle_urls":{"create":{"method":"POST","url":"https://api.assetfare.dev/v2/session"},"read":{"method":"GET","url":"https://api.assetfare.dev/v2/session/{session_id}"},"observe_source":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/observe-source"},"observe_output":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/observe-output"},"refresh_action":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/refresh-action"}},"requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"assetfare_never_signs_submits_or_auto_calls":true,"note":"Idempotent multi-step lifecycle; future actions require verified receipts, so multi-step routes and 1bp complete only here. Recommended for any multi-step route. Choose this OR the one-shot bundle, not both."}],"note":"Advisory caller-side: choose exactly one option; multi-step routes use the session. The quote never prepares, signs, submits, or receives a private key. For Solana CCTP, event_signer_public is a caller-generated ephemeral public key; its private key stays client-side and co-signs the returned unsigned transaction. Machine-readable form: caller_action_plan_handoff_v2.","available":true},"caller_action_plan_handoff_v2":{"kind":"caller_operated_rest_prepare","url":"https://api.assetfare.dev/v2/prepare","method":"POST","requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"request_fields":["caller_approved","from_chain","from_token","to_chain","to_token","amount_usd","wallets","event_signer_public"],"assetfare_server_signing":false,"assetfare_server_submission":false,"caller_must_verify_sign_and_submit":true,"requires_fresh_requote":true,"automatic_prepare_call_forbidden":true,"schema_version":2,"selection":"choose_exactly_one","mutually_exclusive":true,"do_not_call_both":true,"selection_before_signing":true,"once_any_action_submitted_do_not_start_other_mode":true,"enforcement":"advisory_caller_side","options":[{"kind":"one_shot_first_unsigned_bundle","method":"POST","url":"https://api.assetfare.dev/v2/prepare","requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"assetfare_never_signs_submits_or_auto_calls":true,"note":"Stateless: returns only the FIRST unsigned bundle (a preview/manual first action, not a session); the caller signs and submits. Choose this OR the session, not both; do not start a session after submitting this.","preview_or_manual_first_action_only":true,"not_a_session":true,"do_not_start_session_after_submission":true},{"kind":"caller_approved_full_workflow_session","method":"POST","url":"https://api.assetfare.dev/v2/session","lifecycle_urls":{"create":{"method":"POST","url":"https://api.assetfare.dev/v2/session"},"read":{"method":"GET","url":"https://api.assetfare.dev/v2/session/{session_id}"},"observe_source":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/observe-source"},"observe_output":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/observe-output"},"refresh_action":{"method":"POST","url":"https://api.assetfare.dev/v2/session/{session_id}/refresh-action"}},"requires_explicit_caller_approval":true,"requires_public_wallet_addresses":true,"assetfare_never_signs_submits_or_auto_calls":true,"note":"Idempotent multi-step lifecycle; future actions require verified receipts, so multi-step routes and 1bp complete only here. Recommended for any multi-step route. Choose this OR the one-shot bundle, not both.","recommended_for_multistep":true}],"note":"Advisory caller-side v2: choose exactly one option; multi-step routes use the session. For Solana CCTP, event_signer_public is a caller-generated ephemeral public key; its private key stays client-side and co-signs the returned unsigned transaction. AssetFare never signs or submits.","available":true},"handoff_schema_version":2,"continuation_v3":{"version":"assetfare-quote-bound-continuation-v3","enforcement":"server_enforced_quote_binding","selection_status":"unranked_candidate","automatic_selection_forbidden":true,"caller_approved_boolean_is_not_human_proof":true,"quote_id":"650e3142-94b1-4b17-a2f2-05ec139d0886","quote_fingerprint":"2848290cbbbabe55273f3b78c57d0a5f5bc01c00b35598d6f81d2a159ba06e08","quote_fingerprint_spec":"sha256(UTF-8 sorted-key compact JSON of quote_fingerprint_claim; every numeric claim is a non-exponent decimal string)","quote_fingerprint_claim":{"version":"assetfare-quote-bound-continuation-v3","quote_id":"650e3142-94b1-4b17-a2f2-05ec139d0886","issued_at":"2026-09-24T14:07:49.636Z","expires_at":"2026-09-24T14:08:49.636Z","ttl_seconds":"60","intent":{"from":"base:USDC","to":"arbitrum:USDC","amount_usd_decimal":"1000","estimated_input_base":"9007199254740993"},"direct_route_summary_sha256":"6d3c25b8055f386d9088b121128f9b47f05a52da53aef3e9cf3a95fd64d1465a","quote_payload_sha256":"f071dead7a91a993e72ec086ac7948e801880bf24cda916ad0761e962249f17c","quote_payload_sha256_spec":"sha256(AssetFare typed-canonical-v1 bytes of the quote without continuation_v3 after exact base-unit substitution: n=null; t/f=boolean; d= for each finite JSON number; s=:; a=:[items]; o=:{UTF-8-byte-sorted string-key/value pairs}; every non-substituted integral JSON number must be within +/-9007199254740991; substituted paths are intent.estimated_input_base, route.input_base, route.expected_output_base, route.minimum_output_base, and every route.steps[i].expected_input_base/floor_input_base/expected_output_base/minimum_output_base from direct_route_summary exact decimal strings)","input_base_bounds":{"minimum":"9007199254740993","maximum":"9007199254740993"},"minimum_output_base":"980000000","required_wallet_chains":["arbitrum","base"],"event_signer_public_required":false,"step_count":"1","allowed_modes":["one_shot","session"],"server_signing":false,"server_submission":false},"issued_at":"2026-09-24T14:07:49.636Z","expires_at":"2026-09-24T14:08:49.636Z","ttl_seconds":60,"intent":{"from":"base:USDC","to":"arbitrum:USDC","amount_usd":1000.0,"estimated_input_base":9007199254740993},"direct_route_summary_sha256":"6d3c25b8055f386d9088b121128f9b47f05a52da53aef3e9cf3a95fd64d1465a","quote_payload_sha256":"f071dead7a91a993e72ec086ac7948e801880bf24cda916ad0761e962249f17c","quote_payload_sha256_spec":"sha256(AssetFare typed-canonical-v1 bytes of the quote without continuation_v3 after exact base-unit substitution: n=null; t/f=boolean; d= for each finite JSON number; s=:; a=:[items]; o=:{UTF-8-byte-sorted string-key/value pairs}; every non-substituted integral JSON number must be within +/-9007199254740991; substituted paths are intent.estimated_input_base, route.input_base, route.expected_output_base, route.minimum_output_base, and every route.steps[i].expected_input_base/floor_input_base/expected_output_base/minimum_output_base from direct_route_summary exact decimal strings)","input_base_bounds":{"minimum":"9007199254740993","maximum":"9007199254740993"},"minimum_output_base":"980000000","required_wallet_chains":["arbitrum","base"],"event_signer_public_required":false,"step_count":1,"recommended_mode":"one_shot_or_session","allowed_modes":["one_shot","session"],"session_header":{"name":"X-AssetFare-Session-Token","required_for":"session","caller_generated":true,"minimum_entropy_bits":256,"server_returns_raw_value":false},"idempotency":{"required":true,"field":"idempotency_key","pattern":"^[A-Za-z0-9._:-]{8,128}$","scope":"quote_and_selected_mode"},"approval_v3_required_fields":["direct_route_summary_sha256","idempotency_key","maximum_input_base","minimum_output_base","quote_fingerprint","quote_id","selected_mode","selection_status","version"],"legacy_handoff_enforcement":"legacy_advisory","server_signing":false,"server_submission":false}} diff --git a/typescript/agentkit/src/action-providers/assetfare/index.ts b/typescript/agentkit/src/action-providers/assetfare/index.ts new file mode 100644 index 000000000..03d4e4a5f --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/index.ts @@ -0,0 +1,2 @@ +export * from "./assetfareActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/assetfare/schemas.ts b/typescript/agentkit/src/action-providers/assetfare/schemas.ts new file mode 100644 index 000000000..2a9d418ef --- /dev/null +++ b/typescript/agentkit/src/action-providers/assetfare/schemas.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; + +/** + * Advisory quote-sizing guidance published with the AssetFare integration. + * These values do not change the API minimum or guarantee the best route. + */ +export const ASSETFARE_EVALUATION_GUIDANCE = { + routeMinimumUsd: 1, + reachabilitySmokeUsd: 1, + reachabilitySmokeOnly: true, + observedCompetitiveBucketUsd: 50, + observedEvidenceRoute: "solana:USDC->base:USDC", + thresholdClaimedForOtherCorridors: false, + representativeEconomicEvaluationUsd: 1000, + solInputRepresentativeEvaluationUsd: 1000, + solInputCaveat: + "SOL-input routes add a source swap, so compare their full fee-inclusive route economics separately", + evidenceAsOf: "2026-09-23", + evidenceScope: + "Dated Solana native USDC to Base native USDC measurements at USD 50, 250, and 1000", + notAMinimum: true, + notGuaranteedBest: true, + alwaysCompareFreshAtIntendedAmount: true, +} as const; + +/** + * Chains exposed by the AssetFare public v2 route matrix. + */ +export const AssetFareChainSchema = z.enum([ + "solana", + "base", + "arbitrum", + "robinhood", + "polygon", + "optimism", +]); + +/** + * Assets exposed by the AssetFare public v2 route matrix. + */ +export const AssetFareTokenSchema = z.enum(["SOL", "ETH", "USDC", "USDG"]); + +const tokensByChain: Record, readonly string[]> = { + solana: ["SOL", "USDC", "USDG"], + base: ["ETH", "USDC"], + arbitrum: ["ETH", "USDC"], + robinhood: ["ETH", "USDG"], + polygon: ["USDC"], + optimism: ["USDC"], +}; + +/** + * Normalized chain-and-asset endpoints used by AssetFare's direct-route summary. + */ +export const AssetFareEndpointSchema = z.enum([ + "solana:SOL", + "solana:USDC", + "solana:USDG", + "base:ETH", + "base:USDC", + "arbitrum:ETH", + "arbitrum:USDC", + "robinhood:ETH", + "robinhood:USDG", + "polygon:USDC", + "optimism:USDC", +]); + +const AssetFareDestinationEndpointSchema = z.enum([ + "solana:SOL", + "solana:USDC", + "solana:USDG", + "base:ETH", + "base:USDC", + "arbitrum:ETH", + "arbitrum:USDC", + "robinhood:ETH", + "robinhood:USDG", +]); + +const AssetFareDirectRouteModeSchema = z.enum([ + "cctp_direct_composition", + "optimism_source_cctp", + "polygon_source_cctp", + "robinhood_across_ingress_composition", + "robinhood_paxos_egress_composition", + "same_chain_direct", + "same_chain_direct_composition", +]); + +const AssetFareDirectRouteProviderSchema = z.enum([ + "across_intent_bridge", + "circle_cctp", + "orca_whirlpool", + "paxos_usdg_layerzero_oft", + "raydium_clmm", + "uniswap_v3", +]); + +const positiveBaseUnitStringSchema = z + .string() + .regex(/^[1-9][0-9]*$/, "must be a positive base-unit integer encoded as a decimal string"); + +const directSwapProviders = new Set(["orca_whirlpool", "raydium_clmm", "uniswap_v3"]); + +/** + * One ordered and amount-bounded step in AssetFare's direct-route summary. + */ +export const AssetFareDirectRouteStepSchema = z + .object({ + index: z.number().int().nonnegative(), + action: z.enum(["swap", "bridge"]), + provider: AssetFareDirectRouteProviderSchema, + from: AssetFareEndpointSchema, + to: AssetFareDestinationEndpointSchema, + expected_input_base: positiveBaseUnitStringSchema, + minimum_input_base: positiveBaseUnitStringSchema, + expected_output_base: positiveBaseUnitStringSchema, + minimum_output_base: positiveBaseUnitStringSchema, + assetfare_fee_bps: z.union([z.literal(0), z.literal(1)]), + direct_protocol: z.boolean(), + external_intent_protocol: z.boolean(), + aggregator_api_used: z.literal(false), + }) + .strict() + .superRefine((value, context) => { + const externalIntent = value.provider === "across_intent_bridge"; + const expectedAction = directSwapProviders.has(value.provider) ? "swap" : "bridge"; + + if (value.action !== expectedAction) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["action"], + message: "action does not match the disclosed provider", + }); + } + if (value.direct_protocol !== !externalIntent) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["direct_protocol"], + message: "direct-protocol classification does not match the disclosed provider", + }); + } + if (value.external_intent_protocol !== externalIntent) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["external_intent_protocol"], + message: "external-intent classification does not match the disclosed provider", + }); + } + if (BigInt(value.minimum_input_base) > BigInt(value.expected_input_base)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minimum_input_base"], + message: "minimum input cannot exceed expected input", + }); + } + if (BigInt(value.minimum_output_base) > BigInt(value.expected_output_base)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minimum_output_base"], + message: "minimum output cannot exceed expected output", + }); + } + }); + +/** + * Fail-closed, agent-readable path returned with every AssetFare v2 quote. + */ +export const AssetFareDirectRouteSummarySchema = z + .object({ + version: z.literal("assetfare-direct-route-summary-v1"), + route: z.string(), + from: AssetFareEndpointSchema, + to: AssetFareDestinationEndpointSchema, + classification: z.enum(["direct_protocol_only", "external_intent"]), + mode: AssetFareDirectRouteModeSchema, + route_aggregator_used: z.literal(false), + external_intent_protocol_used: z.boolean(), + provider_internal_dex_aggregation_possible: z.boolean(), + assetfare_fee_bps: z.literal(1), + fee_collection_step_index: z.number().int().min(0).max(7), + server_signing: z.literal(false), + server_submission: z.literal(false), + step_count: z.number().int().min(1).max(8), + steps: z.array(AssetFareDirectRouteStepSchema).min(1).max(8), + }) + .strict() + .superRefine((value, context) => { + const issue = (path: (string | number)[], message: string) => + context.addIssue({ code: z.ZodIssueCode.custom, path, message }); + const expectedRoute = `${value.from}->${value.to}`; + const fromChain = value.from.split(":", 1)[0]; + const toChain = value.to.split(":", 1)[0]; + const acrossSteps = value.steps.filter(step => step.provider === "across_intent_bridge"); + const feeSteps = value.steps.filter(step => step.assetfare_fee_bps === 1); + + if (value.from === value.to) issue(["to"], "identity routes must not be quoted"); + if (value.route !== expectedRoute) + issue(["route"], "route must match the normalized endpoints"); + if (value.step_count !== value.steps.length) + issue(["step_count"], "step count must match the ordered path"); + + value.steps.forEach((step, index) => { + if (step.index !== index) issue(["steps", index, "index"], "step indices must be contiguous"); + if (index === 0 && step.from !== value.from) + issue(["steps", index, "from"], "first step must begin at the quoted source"); + if (index === value.steps.length - 1 && step.to !== value.to) + issue(["steps", index, "to"], "last step must end at the quoted destination"); + + const next = value.steps[index + 1]; + if (next) { + if (step.to !== next.from) + issue(["steps", index + 1, "from"], "provider path must be continuous"); + if (step.expected_output_base !== next.expected_input_base) + issue( + ["steps", index + 1, "expected_input_base"], + "expected base-unit bounds must be continuous", + ); + if (step.minimum_output_base !== next.minimum_input_base) + issue( + ["steps", index + 1, "minimum_input_base"], + "minimum base-unit bounds must be continuous", + ); + } + }); + + if ( + feeSteps.length !== 1 || + feeSteps[0]?.index !== value.fee_collection_step_index || + value.steps[value.fee_collection_step_index]?.assetfare_fee_bps !== 1 + ) { + issue( + ["fee_collection_step_index"], + "exactly one 1bp fee step must match the declared collection index", + ); + } + + const usesExternalIntent = acrossSteps.length === 1; + if (acrossSteps.length > 1) + issue(["steps"], "the ordered path may contain at most one Across intent step"); + if (value.classification !== (usesExternalIntent ? "external_intent" : "direct_protocol_only")) + issue(["classification"], "classification must match the ordered provider path"); + if (value.external_intent_protocol_used !== usesExternalIntent) + issue( + ["external_intent_protocol_used"], + "external-intent flag must match the ordered provider path", + ); + if (value.provider_internal_dex_aggregation_possible !== usesExternalIntent) + issue( + ["provider_internal_dex_aggregation_possible"], + "provider-internal aggregation caveat must be true exactly for an Across intent path", + ); + + const expectedMode = + fromChain === "polygon" + ? "polygon_source_cctp" + : fromChain === "optimism" + ? "optimism_source_cctp" + : fromChain === toChain + ? value.steps.length === 1 + ? "same_chain_direct" + : "same_chain_direct_composition" + : toChain === "robinhood" + ? "robinhood_across_ingress_composition" + : fromChain === "robinhood" + ? "robinhood_paxos_egress_composition" + : "cctp_direct_composition"; + if (value.mode !== expectedMode) issue(["mode"], "mode must match the normalized route"); + }); + +/** + * Capability metadata that advertises the mandatory per-quote summary contract. + */ +export const AssetFareDirectRouteSummaryCapabilitiesSchema = z + .object({ + version: z.literal("assetfare-direct-route-summary-v1"), + required_on_every_quote: z.literal(true), + route_count: z.number().int().positive(), + step_count: z.number().int().positive(), + ordered_provider_path: z.literal(true), + normalized_chain_asset_endpoints: z.literal(true), + base_unit_amounts_are_decimal_strings: z.literal(true), + assetfare_fee_step_bound: z.literal(true), + classification_values: z.tuple([ + z.literal("direct_protocol_only"), + z.literal("external_intent"), + ]), + route_aggregator_used_scope: z.literal("assetfare_engine_only"), + external_intent: z + .string() + .refine(value => value.includes("Across") && value.includes("provider-internal"), { + message: "must disclose the Across provider-internal liquidity caveat", + }), + server_signing: z.literal(false), + server_submission: z.literal(false), + }) + .strict(); + +/** + * Input schema for reading AssetFare capabilities and live route readiness. + */ +export const GetCapabilitiesSchema = z + .object({}) + .strict() + .describe("No inputs; reads the public AssetFare capability and status surface"); + +/** + * Input schema for requesting one fresh AssetFare route quote. + */ +export const GetQuoteSchema = z + .object({ + fromChain: AssetFareChainSchema.describe("Source chain, e.g. solana"), + fromToken: AssetFareTokenSchema.describe("Source asset symbol, e.g. USDC"), + toChain: AssetFareChainSchema.describe("Destination chain, e.g. base"), + toToken: AssetFareTokenSchema.describe("Destination asset symbol, e.g. USDC"), + amountUsd: z + .number() + .finite() + .min(1) + .describe( + "Finite USD notional to route. The API minimum is 1 with no business maximum, but USD 1 is only for reachability smoke testing. USD 50 was an observed competitive bucket only for dated Solana USDC to Base USDC evidence; no threshold is claimed for another corridor. USD 1,000 is the representative evaluation amount, not a minimum or best-route guarantee. Always compare fresh quotes at the actual intended amount; live liquidity and capacity still apply", + ), + }) + .strict() + .superRefine((value, context) => { + if (!tokensByChain[value.fromChain].includes(value.fromToken)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fromToken"], + message: "token is not supported on source chain", + }); + } + if (!tokensByChain[value.toChain].includes(value.toToken)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["toToken"], + message: "token is not supported on destination chain", + }); + } + if (value.fromChain === value.toChain && value.fromToken === value.toToken) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["toToken"], + message: "identity routes do not require a quote", + }); + } + if (value.toChain === "polygon" || value.toChain === "optimism") { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["toChain"], + message: "Polygon and Optimism are source-only", + }); + } + if ( + (value.fromChain === "polygon" || value.fromChain === "optimism") && + !( + value.fromToken === "USDC" && + (value.toChain === "base" || value.toChain === "arbitrum") && + value.toToken === "USDC" + ) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["toChain"], + message: "source-only route must be native USDC to Base or Arbitrum USDC", + }); + } + }) + .describe("Instructions for requesting one fresh cross-chain route quote"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..37cda35c1 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -5,6 +5,7 @@ export * from "./customActionProvider"; export * from "./across"; export * from "./alchemy"; +export * from "./assetfare"; export * from "./baseAccount"; export * from "./basename"; export * from "./cdp";