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/olive-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a new action provider to screen counterparties and content with RelayShield, paid per call over x402
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 @@ -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";
Expand Down
65 changes: 65 additions & 0 deletions typescript/agentkit/src/action-providers/relayshield/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Exports for relayshield action provider
*
* @module relayshield
*/

export * from "./relayshieldActionProvider";
export * from "./schemas";
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading
Loading