Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/recta-route-second-opinion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a new action provider to ask Recta whether a cheaper cross-chain stablecoin route can be proven before a transfer
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./farcaster";
export * from "./jupiter";
export * from "./messari";
export * from "./pyth";
export * from "./recta";
export * from "./moonwell";
export * from "./morpho";
export * from "./opensea";
Expand Down
42 changes: 42 additions & 0 deletions typescript/agentkit/src/action-providers/recta/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Recta Action Provider

This directory contains the **RectaActionProvider**, which gives an agent a free second opinion on a cross-chain
stablecoin transfer before it sends it: can [Recta](https://api.recta.build/llms.txt) prove a cheaper route than the
one the agent would use, at the same chain state?

The provider only reads. It never signs and never sends a transaction, and it needs no API key.

## Directory Structure

```
recta/
├── rectaActionProvider.ts # Main provider with Recta functionality
├── rectaActionProvider.test.ts # Test file for Recta provider
├── constants.ts # API origin, served networks and declarable routes
├── schemas.ts # Action schemas
├── index.ts # Main exports
└── README.md # This file
```

## Actions

- `check_route_coverage`: whether Recta covers a corridor from the wallet's network, and which routes it compares.
- `get_route_saving_odds`: how often Recta's recorded decisions found a saving against each route on that corridor.
- `compare_bridge_route`: for a planned transfer and the route the agent would use (`baselineProvider`), whether a
cheaper route can be proven, its saving (lower bound, USDC) and Recta's fee. Limited to 3 calls a day per address.

## Pricing

The actions here are free. Recta charges exactly 5 % of a saving it verifies on chain, and only after the agent
executes Recta's route; with no saving, the agent keeps its own route and owes nothing. To get and execute a route,
the agent signs a request with [`@recta/client`](https://www.npmjs.com/package/@recta/client) or the
[`@recta/mcp`](https://www.npmjs.com/package/@recta/mcp) server.

## Network Support

EVM sources: Ethereum, Arbitrum, Base, Optimism, Polygon, Avalanche, BNB Chain, Linea, Unichain, Ink, HyperEVM,
Monad and Arc. Destinations are listed per corridor by `check_route_coverage`.

## Notes

For more information, see Recta's [agent guide](https://api.recta.build/llms.txt).
35 changes: 35 additions & 0 deletions typescript/agentkit/src/action-providers/recta/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Recta's public API. Every endpoint used here is free, unsigned and needs no API key.
*/
export const RECTA_API_URL = "https://api.recta.build";

/**
* EVM chains a Recta route can start from, by chain id, with the network name Recta's API uses.
*/
export const RECTA_NETWORK_BY_CHAIN_ID: Readonly<Record<string, string>> = {
"1": "ethereum",
"10": "optimism",
"56": "bnb_chain",
"130": "unichain",
"137": "polygon",
"143": "monad",
"999": "hyperevm",
"5042": "arc",
"8453": "base",
"42161": "arbitrum",
"43114": "avalanche",
"57073": "ink",
"59144": "linea",
};

/**
* Routes an agent can declare as the one it would use without Recta.
*/
export const RECTA_BASELINE_PROVIDERS = [
"across",
"stargate_v2",
"usdt0",
"usdg_oft",
"debridge_dln",
"lifi",
] as const;
2 changes: 2 additions & 0 deletions typescript/agentkit/src/action-providers/recta/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./rectaActionProvider";
export * from "./schemas";
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { rectaActionProvider, RectaActionProvider } from "./rectaActionProvider";
import { RectaCompareRouteSchema } from "./schemas";
import { EvmWalletProvider } from "../../wallet-providers";

const SENDER = "0x1234567890123456789012345678901234567890";

const TEMPLATE = {
schema: "RECTA_REQUEST_TEMPLATE_V1",
covered: true,
body: {
schema: "RECTA_COMMERCIAL_ECONOMIC_REQUEST_V1",
source_network: "ethereum",
source_asset: "USDC",
destination_network: "base",
destination_asset: "USDC",
amount: "6000",
constraints: { guarantee_class: "ANY", latency_class: "ANY", destination_execution: "NONE" },
sender: "<YOUR_SENDER>",
recipient: "<RECIPIENT>",
baseline_provider: "stargate_v2",
execution_authority: {
headroom: "0.2",
source_max_fee_per_gas_wei: "<MAX_FEE_PER_GAS_WEI>",
source_max_priority_fee_per_gas_wei: "<MAX_PRIORITY_FEE_PER_GAS_WEI>",
},
},
};

const TRIAL = { status: "TRIAL", outcome: "SAVING_FOUND", saving_usdc: "2.39903" };

/**
* A fetch response.
*
* @param body - The JSON body.
* @param status - The HTTP status.
* @returns The response.
*/
function json(body: unknown, status = 200): Response {
return { ok: status < 400, status, text: async () => JSON.stringify(body) } as Response;
}

describe("RectaActionProvider", () => {
let provider: RectaActionProvider;
let wallet: jest.Mocked<EvmWalletProvider>;
let fetchMock: jest.SpyInstance;

beforeEach(() => {
provider = rectaActionProvider();
wallet = {
getAddress: jest.fn().mockReturnValue(SENDER),
getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "ethereum-mainnet", chainId: "1" }),
getPublicClient: jest.fn().mockReturnValue({
estimateFeesPerGas: jest.fn().mockResolvedValue({ maxFeePerGas: 20_000_000_000n, maxPriorityFeePerGas: 2_000_000_000n }),
}),
sendTransaction: jest.fn(),
signMessage: jest.fn(),
signTypedData: jest.fn(),
} as unknown as jest.Mocked<EvmWalletProvider>;
fetchMock = jest.spyOn(global, "fetch");
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("supportsNetwork", () => {
it("supports EVM networks a Recta route can start from", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "8453" })).toBe(true);
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "42161" })).toBe(true);
});

it("does not support other networks", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "84532" })).toBe(false);
expect(provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" })).toBe(false);
});
});

describe("schema", () => {
it("refuses an amount that is not a decimal number", () => {
const parsed = RectaCompareRouteSchema.safeParse({
destinationNetwork: "base",
asset: null,
amount: "6e3",
baselineProvider: "stargate_v2",
recipient: null,
});
expect(parsed.success).toBe(false);
});
});

describe("checkRouteCoverage", () => {
it("reads the corridor from the wallet's network", async () => {
fetchMock.mockResolvedValue(json({ covered: true }));
const result = await provider.checkRouteCoverage(wallet, { destinationNetwork: "base", asset: "USDC" });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.recta.build/v1/golden-corridor/fit?source_network=ethereum&source_asset=USDC&destination_network=base&destination_asset=USDC",
expect.anything(),
);
expect(JSON.parse(result)).toEqual({ covered: true });
});

it("refuses a wallet on a chain Recta does not serve", async () => {
wallet.getNetwork.mockReturnValue({ protocolFamily: "evm", chainId: "84532" });
const result = await provider.checkRouteCoverage(wallet, { destinationNetwork: "base", asset: "USDC" });
expect(result).toContain("cannot start on chain 84532");
expect(fetchMock).not.toHaveBeenCalled();
});
});

describe("compareBridgeRoute", () => {
const args = {
destinationNetwork: "base",
asset: "USDC",
amount: "6000",
baselineProvider: "stargate_v2" as const,
recipient: null,
};

it("fills the template with the wallet's address and fees, and never signs or sends", async () => {
fetchMock.mockResolvedValueOnce(json(TEMPLATE)).mockResolvedValueOnce(json(TRIAL));
const result = await provider.compareBridgeRoute(wallet, args);

expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.recta.build/v1/golden-corridor/request-template?source_network=ethereum&source_asset=USDC&destination_network=base&destination_asset=USDC&amount=6000&baseline_provider=stargate_v2",
);
const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe("https://api.recta.build/v1/golden-corridor/trial-evaluation");
expect(init.method).toBe("POST");
const sent = JSON.parse(init.body);
expect(sent.sender).toBe(SENDER);
expect(sent.recipient).toBe(SENDER);
expect(sent.execution_authority).toEqual({
headroom: "0.2",
source_max_fee_per_gas_wei: "20000000000",
source_max_priority_fee_per_gas_wei: "2000000000",
});
expect(JSON.parse(result)).toEqual(TRIAL);
expect(wallet.sendTransaction).not.toHaveBeenCalled();
expect(wallet.signMessage).not.toHaveBeenCalled();
expect(wallet.signTypedData).not.toHaveBeenCalled();
});

it("uses the given recipient", async () => {
const recipient = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd";
fetchMock.mockResolvedValueOnce(json(TEMPLATE)).mockResolvedValueOnce(json(TRIAL));
await provider.compareBridgeRoute(wallet, { ...args, recipient });
expect(JSON.parse(fetchMock.mock.calls[1][1].body).recipient).toBe(recipient);
});

it("stops when Recta does not cover the transfer", async () => {
fetchMock.mockResolvedValueOnce(json({ covered: false, reason: "UNSERVED_CORRIDOR" }));
const result = await provider.compareBridgeRoute(wallet, args);
expect(result).toContain("Recta does not cover this transfer");
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("returns Recta's refusal as it came", async () => {
fetchMock
.mockResolvedValueOnce(json(TEMPLATE))
.mockResolvedValueOnce(json({ status: "REFUSED", reason_code: "TRIAL_LIMIT_REACHED" }, 429));
const result = await provider.compareBridgeRoute(wallet, args);
expect(JSON.parse(result)).toEqual({ status: "REFUSED", reason_code: "TRIAL_LIMIT_REACHED" });
});

it("reports a network error", async () => {
fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
const result = await provider.compareBridgeRoute(wallet, args);
expect(result).toBe("Error comparing the route with Recta: connect ECONNREFUSED");
});
});
});
Loading
Loading