diff --git a/typescript/.changeset/olive-donkeys-shave.md b/typescript/.changeset/olive-donkeys-shave.md new file mode 100644 index 000000000..b78d29090 --- /dev/null +++ b/typescript/.changeset/olive-donkeys-shave.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a new action provider to screen counterparties and content with RelayShield, paid per call over x402 diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..ef2526b33 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -23,6 +23,7 @@ export * from "./pyth"; export * from "./moonwell"; export * from "./morpho"; export * from "./opensea"; +export * from "./relayshield"; export * from "./spl"; export * from "./superfluid"; export * from "./sushi"; diff --git a/typescript/agentkit/src/action-providers/relayshield/README.md b/typescript/agentkit/src/action-providers/relayshield/README.md new file mode 100644 index 000000000..e4283d006 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/README.md @@ -0,0 +1,65 @@ +# RelayShield Action Provider + +Counterparty and content screening for agents, paid per call over x402. + +An agent about to send funds to an address, swap into a token, buy an NFT, or follow a link +has a question it cannot answer from onchain data alone: is the thing on the other side known +to be malicious? This provider answers that question, and the agent pays for the answer out of +its own wallet. + +## Why this needs no API key + +Every action calls a RelayShield pay-as-you-go endpoint, which replies with an HTTP 402 +challenge. The agent's wallet settles the payment in USDC and the call proceeds in the same +request cycle. There is no account to create, no key to store and no key to rotate, which is +the only shape that works when the buyer is software. + +## Actions + +| Action | Answers | Price | +|---|---|---| +| `screen_wallet` | Is this counterparty address associated with scams, exploits, drainers or sanctions? | $0.05 | +| `check_token_security` | Is this token a honeypot, mintable, blacklistable or otherwise restricted? | $0.05 | +| `check_nft_security` | Is this NFT collection fake, copied or transfer-restricted? | $0.10 | +| `screen_url` | Is this link phishing or malware? | $0.05 | + +`screen_wallet` detects the chain from the address format and covers EVM, Solana, TON and +Bitcoin, so the agent does not have to know or ask which chain an address belongs to. + +## Supported networks + +Payment settles on **Base mainnet** and **Solana mainnet**. + +Note that this is narrower than what the screening itself covers. `supportsNetwork` reflects +where a payment can settle, not which chains can be screened, and an address on any supported +chain can be screened from a wallet on either of these two. + +## Setup + +```typescript +import { AgentKit, relayshieldActionProvider } from "@coinbase/agentkit"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [relayshieldActionProvider()], +}); +``` + +The wallet needs a small USDC balance on Base or Solana to pay for calls. + +## A clean result is not a guarantee + +These checks report what is currently known. Absence of a risk flag means nothing is known +against the item, not that it is safe, and the action descriptions instruct the model to say +so when reporting results. + +For the same reason, a failed check is never reported as a clean one. If the endpoint errors +or the payment fails, the action returns a message stating plainly that the check did not +complete and the item should be treated as unverified. A screening tool that looks like it +found nothing when it actually failed is worse than one that admits it could not answer. + +## Links + +- [API reference](https://api.relayshield.net/docs) +- [OpenAPI specification](https://api.relayshield.net/openapi.json) +- [Developer portal](https://api.relayshield.net/developers) diff --git a/typescript/agentkit/src/action-providers/relayshield/constants.ts b/typescript/agentkit/src/action-providers/relayshield/constants.ts new file mode 100644 index 000000000..86edc6768 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/constants.ts @@ -0,0 +1,33 @@ +/** + * Constants for the RelayShield action provider. + */ + +/** + * Base URL of the RelayShield API. + * + * The pay-as-you-go routes under `/v1/payg/` answer with an HTTP 402 challenge + * and settle in USDC, so an agent wallet can pay for a call directly without + * an account or an API key. + */ +export const RELAYSHIELD_API_BASE = "https://api.relayshield.net"; + +/** + * Networks RelayShield accepts x402 payment on. + * + * The screening itself covers many more chains than this. These are only the + * networks a payment can settle on, which is what determines whether an agent + * wallet can use the provider at all. + */ +export const SUPPORTED_NETWORKS = ["base-mainnet", "solana-mainnet"] as const; + +/** + * Endpoint paths used by this provider, with their per-call price in USDC. + * Prices are informational, shown in the action descriptions. The authoritative + * price always comes from the 402 challenge at call time. + */ +export const ENDPOINTS = { + walletRisk: { path: "/v1/payg/wallet-risk", priceUsd: 0.05 }, + tokenSecurity: { path: "/v1/payg/token-security", priceUsd: 0.05 }, + nftSecurity: { path: "/v1/payg/nft-security", priceUsd: 0.1 }, + scanUrl: { path: "/v1/payg/scan-url", priceUsd: 0.05 }, +} as const; diff --git a/typescript/agentkit/src/action-providers/relayshield/index.ts b/typescript/agentkit/src/action-providers/relayshield/index.ts new file mode 100644 index 000000000..97fde9d91 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/index.ts @@ -0,0 +1,8 @@ +/** + * Exports for relayshield action provider + * + * @module relayshield + */ + +export * from "./relayshieldActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.test.ts b/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.test.ts new file mode 100644 index 000000000..e37960963 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.test.ts @@ -0,0 +1,148 @@ +import { RelayShieldActionProvider } from "./relayshieldActionProvider"; +import { Network } from "../../network"; +import { WalletProvider } from "../../wallet-providers"; + +const mockFetchWithPayment = jest.fn(); + +jest.mock("@x402/fetch", () => ({ + x402Client: jest.fn().mockImplementation(() => ({})), + wrapFetchWithPayment: () => mockFetchWithPayment, +})); +jest.mock("@x402/evm/exact/client", () => ({ registerExactEvmScheme: jest.fn() })); +jest.mock("@x402/svm/exact/client", () => ({ registerExactSvmScheme: jest.fn() })); + +describe("RelayShieldActionProvider", () => { + let provider: RelayShieldActionProvider; + let walletProvider: WalletProvider; + + beforeEach(() => { + provider = new RelayShieldActionProvider(); + walletProvider = {} as WalletProvider; + mockFetchWithPayment.mockReset(); + }); + + describe("supportsNetwork", () => { + it("supports the networks x402 payment can settle on", () => { + expect(provider.supportsNetwork({ networkId: "base-mainnet" } as Network)).toBe(true); + expect(provider.supportsNetwork({ networkId: "solana-mainnet" } as Network)).toBe(true); + }); + + it("rejects networks payment cannot settle on", () => { + expect(provider.supportsNetwork({ networkId: "ethereum-mainnet" } as Network)).toBe(false); + expect(provider.supportsNetwork({ networkId: "base-sepolia" } as Network)).toBe(false); + expect(provider.supportsNetwork({} as Network)).toBe(false); + }); + }); + + describe("screen_wallet", () => { + it("posts the address and returns the response body", async () => { + const body = JSON.stringify({ ok: true, risk_level: "LOW", risk_flags: [] }); + mockFetchWithPayment.mockResolvedValue({ ok: true, status: 200, text: async () => body }); + + const result = await provider.screenWallet(walletProvider, { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + }); + + expect(result).toBe(body); + const [url, init] = mockFetchWithPayment.mock.calls[0]; + expect(url).toBe("https://api.relayshield.net/v1/payg/wallet-risk"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + }); + }); + + it("returns a HIGH verdict unchanged", async () => { + const body = JSON.stringify({ ok: true, risk_level: "HIGH", risk_flags: ["sanctioned"] }); + mockFetchWithPayment.mockResolvedValue({ ok: true, status: 200, text: async () => body }); + + const result = await provider.screenWallet(walletProvider, { address: "0xbad" }); + + expect(result).toBe(body); + expect(result).toContain("HIGH"); + }); + + it("never reports a failed check as a clean result", async () => { + mockFetchWithPayment.mockResolvedValue({ + ok: false, + status: 500, + text: async () => "upstream error", + }); + + const result = await provider.screenWallet(walletProvider, { address: "0xabc" }); + + expect(result).toContain("did NOT complete"); + expect(result).toContain("not a clean result"); + expect(result).toContain("500"); + }); + + it("reports a thrown error as an incomplete check rather than swallowing it", async () => { + mockFetchWithPayment.mockRejectedValue(new Error("insufficient funds")); + + const result = await provider.screenWallet(walletProvider, { address: "0xabc" }); + + expect(result).toContain("did NOT complete"); + expect(result).toContain("insufficient funds"); + }); + }); + + describe("check_token_security", () => { + it("maps camelCase args onto the API's snake_case body", async () => { + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }); + + await provider.checkTokenSecurity(walletProvider, { + contractAddress: "0xtoken", + chainId: "8453", + }); + + const [url, init] = mockFetchWithPayment.mock.calls[0]; + expect(url).toBe("https://api.relayshield.net/v1/payg/token-security"); + expect(JSON.parse(init.body)).toEqual({ + contract_address: "0xtoken", + chain_id: "8453", + }); + }); + }); + + describe("check_nft_security", () => { + it("calls the nft-security endpoint", async () => { + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true }), + }); + + await provider.checkNftSecurity(walletProvider, { + contractAddress: "0xnft", + chainId: "1", + }); + + const [url, init] = mockFetchWithPayment.mock.calls[0]; + expect(url).toBe("https://api.relayshield.net/v1/payg/nft-security"); + expect(JSON.parse(init.body)).toEqual({ contract_address: "0xnft", chain_id: "1" }); + }); + }); + + describe("screen_url", () => { + it("calls the scan-url endpoint with the url", async () => { + mockFetchWithPayment.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true, verdict: "malicious" }), + }); + + const result = await provider.screenUrl(walletProvider, { + url: "https://phishing.example.com/claim", + }); + + const [url, init] = mockFetchWithPayment.mock.calls[0]; + expect(url).toBe("https://api.relayshield.net/v1/payg/scan-url"); + expect(JSON.parse(init.body)).toEqual({ url: "https://phishing.example.com/claim" }); + expect(result).toContain("malicious"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.ts b/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.ts new file mode 100644 index 000000000..37e4548a3 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/relayshieldActionProvider.ts @@ -0,0 +1,251 @@ +/** + * RelayShield Action Provider + * + * Counterparty and content screening for agents, paid per call over x402. + * + * @module relayshield + */ + +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider, SvmWalletProvider, WalletProvider } from "../../wallet-providers"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { registerExactSvmScheme } from "@x402/svm/exact/client"; +import { + ScreenWalletSchema, + CheckTokenSecuritySchema, + CheckNftSecuritySchema, + ScreenUrlSchema, +} from "./schemas"; +import { RELAYSHIELD_API_BASE, SUPPORTED_NETWORKS, ENDPOINTS } from "./constants"; + +/** + * RelayShieldActionProvider screens counterparties and content before an agent acts on them. + * + * @description + * Each action calls a RelayShield pay-as-you-go endpoint, which answers with an + * HTTP 402 challenge. The agent's own wallet settles the payment in USDC, so no + * account or API key is needed. Payment settles on Base or Solana. + */ +export class RelayShieldActionProvider extends ActionProvider { + /** + * Constructor for the RelayShieldActionProvider. + */ + constructor() { + super("relayshield", []); + } + + /** + * Screens a wallet address for known malicious association. + * + * @param walletProvider - The wallet provider used to pay for the call + * @param args - The address to screen + * @returns A JSON string with the risk level and any risk flags + */ + @CreateAction({ + name: "screen_wallet", + description: ` +This tool screens a counterparty wallet address for known scam, exploit, drainer or sanctions-list association before your agent transacts with it. + +It takes a single wallet address. EVM (0x followed by 40 hex characters), Solana (base58), TON (EQ.../UQ...) and Bitcoin addresses are all accepted, and the chain is detected from the address format, so do not ask the user which chain it is on. + +Call this before sending funds to, swapping with, or otherwise transacting with an address the agent has not dealt with before. It returns a risk level and the specific risk flags that fired. + +Absence of a risk flag is not proof that an address is safe, only that nothing is currently known against it. Treat a clean result as the absence of evidence rather than evidence of absence, and say so when reporting it to the user. + +Each call costs ${ENDPOINTS.walletRisk.priceUsd} USDC, paid automatically from the agent's wallet. +`, + schema: ScreenWalletSchema, + }) + async screenWallet( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + return this.callRelayShield(walletProvider, ENDPOINTS.walletRisk.path, { + address: args.address, + }); + } + + /** + * Checks a token contract for security risks. + * + * @param walletProvider - The wallet provider used to pay for the call + * @param args - The token contract address and its chain id + * @returns A JSON string with the token's risk assessment + */ + @CreateAction({ + name: "check_token_security", + description: ` +This tool checks an ERC-20 style token contract for security risks such as honeypot behaviour, mintable supply, blacklist functions, proxy upgradeability and trading restrictions. + +It takes the token contract address and the chain id it is deployed on, as a decimal string, for example '1' for Ethereum mainnet, '8453' for Base, '56' for BNB Chain. Do not pass the token symbol as the contract address. If you do not know the contract address, ask the user rather than guessing, since two tokens can share a symbol. + +Call this before buying or swapping into a token the agent has not traded before. + +Each call costs ${ENDPOINTS.tokenSecurity.priceUsd} USDC, paid automatically from the agent's wallet. +`, + schema: CheckTokenSecuritySchema, + }) + async checkTokenSecurity( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + return this.callRelayShield(walletProvider, ENDPOINTS.tokenSecurity.path, { + contract_address: args.contractAddress, + chain_id: args.chainId, + }); + } + + /** + * Checks an NFT collection for security risks. + * + * @param walletProvider - The wallet provider used to pay for the call + * @param args - The NFT contract address and its chain id + * @returns A JSON string with the collection's risk assessment + */ + @CreateAction({ + name: "check_nft_security", + description: ` +This tool checks an NFT collection contract for security risks such as fake or copied collections, malicious transfer restrictions, and privileged owner functions. + +It takes the collection contract address and the chain id it is deployed on, as a decimal string, for example '1' for Ethereum mainnet, '8453' for Base. + +Call this before buying an NFT from a collection the agent has not dealt with before. + +Each call costs ${ENDPOINTS.nftSecurity.priceUsd} USDC, paid automatically from the agent's wallet. +`, + schema: CheckNftSecuritySchema, + }) + async checkNftSecurity( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + return this.callRelayShield(walletProvider, ENDPOINTS.nftSecurity.path, { + contract_address: args.contractAddress, + chain_id: args.chainId, + }); + } + + /** + * Screens a URL for phishing or malware. + * + * @param walletProvider - The wallet provider used to pay for the call + * @param args - The URL to screen + * @returns A JSON string with the URL verdict and the signals behind it + */ + @CreateAction({ + name: "screen_url", + description: ` +This tool screens a URL for phishing or malware before the agent follows it, renders it, or passes it on to the user. + +It takes one full URL including the scheme, for example https://example.com/claim. It returns a verdict together with the specific signals that fired. + +Call this for any link that arrived from an untrusted source, in particular one that asks for a wallet connection, a signature or a seed phrase. + +Each call costs ${ENDPOINTS.scanUrl.priceUsd} USDC, paid automatically from the agent's wallet. +`, + schema: ScreenUrlSchema, + }) + async screenUrl( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + return this.callRelayShield(walletProvider, ENDPOINTS.scanUrl.path, { url: args.url }); + } + + /** + * Checks if this provider supports the given network. + * + * @param network - The network to check support for + * @returns True if x402 payment can settle on this network + */ + supportsNetwork(network: Network): boolean { + return SUPPORTED_NETWORKS.includes(network.networkId as (typeof SUPPORTED_NETWORKS)[number]); + } + + /** + * Builds an x402 client bound to the agent's wallet. + * + * @param walletProvider - The wallet provider to sign payments with + * @returns An x402 client with the matching payment scheme registered + */ + private async createX402Client(walletProvider: WalletProvider): Promise { + const client = new x402Client(); + + if (walletProvider instanceof EvmWalletProvider) { + const account = walletProvider.toSigner(); + const signer = { + ...account, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: args.address, + abi: args.abi as never, + functionName: args.functionName as never, + args: args.args as never, + }), + }; + registerExactEvmScheme(client, { signer }); + } else if (walletProvider instanceof SvmWalletProvider) { + const signer = await walletProvider.toSigner(); + registerExactSvmScheme(client, { signer }); + } + + return client; + } + + /** + * Calls a RelayShield endpoint, paying the 402 challenge from the agent's wallet. + * + * Errors are returned as a string rather than thrown, so the model can report + * the failure to the user instead of the run aborting. The message always + * states that the check did not complete, because a screening tool that looks + * like it returned "nothing found" when it actually failed is worse than one + * that plainly says it could not answer. + * + * @param walletProvider - The wallet provider used to pay for the call + * @param path - The endpoint path to call + * @param body - The JSON request body + * @returns The endpoint response as a JSON string, or an error message + */ + private async callRelayShield( + walletProvider: WalletProvider, + path: string, + body: Record, + ): Promise { + try { + const client = await this.createX402Client(walletProvider); + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + + const response = await fetchWithPayment(`${RELAYSHIELD_API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const text = await response.text(); + + if (!response.ok) { + return `RelayShield check did NOT complete: HTTP ${response.status} from ${path}. This is not a clean result. Response: ${text}`; + } + + return text; + } catch (error) { + return `RelayShield check did NOT complete: ${error}. This is not a clean result, and the screened item should be treated as unverified.`; + } + } +} + +/** + * Factory function to create a new RelayShieldActionProvider instance. + * + * @returns A new RelayShieldActionProvider instance + */ +export const relayshieldActionProvider = () => new RelayShieldActionProvider(); diff --git a/typescript/agentkit/src/action-providers/relayshield/schemas.ts b/typescript/agentkit/src/action-providers/relayshield/schemas.ts new file mode 100644 index 000000000..51f572cb4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/relayshield/schemas.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +/** + * Action schemas for the relayshield action provider. + * + * This file contains the Zod schemas that define the shape and validation + * rules for action parameters in the relayshield action provider. + */ + +/** + * Schema for screening a single wallet address for counterparty risk. + */ +export const ScreenWalletSchema = z + .object({ + address: z + .string() + .min(1) + .describe( + "The wallet address to screen. Accepts EVM (0x followed by 40 hex characters), " + + "Solana (base58), TON (EQ... or UQ...) and Bitcoin addresses. The chain is " + + "detected from the address format, so it does not need to be supplied.", + ), + }) + .strip() + .describe("Screen a counterparty wallet address for known malicious association"); + +/** + * Schema for checking a token contract for security risks. + */ +export const CheckTokenSecuritySchema = z + .object({ + contractAddress: z.string().min(1).describe("The token contract address to check"), + chainId: z + .string() + .min(1) + .describe( + "The chain id the token is deployed on, as a decimal string. " + + "For example '1' for Ethereum mainnet, '8453' for Base, '56' for BNB Chain.", + ), + }) + .strip() + .describe("Check a token contract for honeypot, rug pull and other security risks"); + +/** + * Schema for checking an NFT collection for security risks. + */ +export const CheckNftSecuritySchema = z + .object({ + contractAddress: z.string().min(1).describe("The NFT collection contract address to check"), + chainId: z + .string() + .min(1) + .describe( + "The chain id the collection is deployed on, as a decimal string. " + + "For example '1' for Ethereum mainnet, '8453' for Base.", + ), + }) + .strip() + .describe("Check an NFT collection for security risks before buying"); + +/** + * Schema for screening a URL for phishing or malware. + */ +export const ScreenUrlSchema = z + .object({ + url: z + .string() + .url() + .describe("The full URL to screen, including the scheme (http:// or https://)"), + }) + .strip() + .describe("Screen a URL for phishing or malware before following it");