diff --git a/.changeset/add-walletprint-action-provider.md b/.changeset/add-walletprint-action-provider.md new file mode 100644 index 000000000..1c9d6b4e2 --- /dev/null +++ b/.changeset/add-walletprint-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added WalletPrintActionProvider for behavioral transaction risk scoring diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..16fcbc701 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -35,6 +35,7 @@ export * from "./allora"; export * from "./flaunch"; export * from "./onramp"; export * from "./vaultsfyi"; +export * from "./walletprint"; export * from "./x402"; export * from "./yelay"; export * from "./zerion"; diff --git a/typescript/agentkit/src/action-providers/walletprint/README.md b/typescript/agentkit/src/action-providers/walletprint/README.md new file mode 100644 index 000000000..eabbeee1b --- /dev/null +++ b/typescript/agentkit/src/action-providers/walletprint/README.md @@ -0,0 +1,64 @@ +# WalletPrint Action Provider + +Behavioral transaction risk scoring for AI agent wallets. + +## Overview + +The WalletPrint action provider scores proposed transactions against a wallet's own behavioral history **before they are signed**. It calls the [WalletPrint API](https://walletprint.up.railway.app) and returns a risk score (0–100), a band (`low` / `medium` / `high`), and plain-English reason codes. + +This action is **advisory only** — it never blocks a transaction. The agent decides what to do with the result. + +## Supported Networks + +- Ethereum mainnet (chain ID 1) +- Base (chain ID 8453) + +## Usage + +```typescript +import { AgentKit } from "@coinbase/agentkit"; +import { walletprintActionProvider } from "@coinbase/agentkit/action-providers/walletprint"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [ + walletprintActionProvider({ apiKey: "your-api-key" }), + ], +}); +``` + +Use `walletprint-dev-key` as the API key for sandbox testing. + +## Actions + +### `score_transaction` + +Scores a proposed transaction before signing. + +**Inputs:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `to` | string | ✓ | Recipient address (0x-prefixed) | +| `value_usd` | number | ✓ | USD value of the transaction | +| `asset` | string | ✓ | Asset being transferred (e.g. `"USDC"`, `"ETH"`) | +| `contract_category` | string | | Category of the contract being called (e.g. `"erc20"`, `"defi"`, `"bridge"`) | + +**Output:** + +```json +{ + "success": true, + "risk_score": 72, + "band": "high", + "reasons": ["New recipient address", "Amount 4x above 30-day average"], + "recommendation": "escalate", + "summary": "Risk score: 72/100 (high). New recipient address; Amount 4x above 30-day average." +} +``` + +## Links + +- [GitHub](https://github.com/Loai17/walletprint-sdk) +- [npm](https://www.npmjs.com/package/@walletprint/sdk) +- [API](https://walletprint.up.railway.app) diff --git a/typescript/agentkit/src/action-providers/walletprint/index.ts b/typescript/agentkit/src/action-providers/walletprint/index.ts new file mode 100644 index 000000000..be4f12733 --- /dev/null +++ b/typescript/agentkit/src/action-providers/walletprint/index.ts @@ -0,0 +1,2 @@ +export * from "./walletprintActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/walletprint/schemas.ts b/typescript/agentkit/src/action-providers/walletprint/schemas.ts new file mode 100644 index 000000000..814bd6db0 --- /dev/null +++ b/typescript/agentkit/src/action-providers/walletprint/schemas.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** + * Input schema for WalletPrint score_transaction action. + */ +export const ScoreTransactionSchema = z + .object({ + to: z + .string() + .describe("The recipient address of the proposed transaction (0x-prefixed hex address)"), + value_usd: z + .number() + .positive() + .describe("The USD value of the proposed transaction"), + asset: z + .string() + .describe('The asset being transferred (e.g. "USDC", "ETH", "WBTC")'), + contract_category: z + .string() + .optional() + .describe( + 'Optional category of the contract being called (e.g. "erc20", "defi", "bridge", "nft")', + ), + }) + .strict(); diff --git a/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.test.ts b/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.test.ts new file mode 100644 index 000000000..d41398ef1 --- /dev/null +++ b/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.test.ts @@ -0,0 +1,163 @@ +import { walletprintActionProvider } from "./walletprintActionProvider"; + +describe("WalletPrintActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + const provider = walletprintActionProvider({ apiKey: "walletprint-dev-key" }); + + beforeEach(() => { + jest.resetAllMocks().restoreAllMocks(); + }); + + describe("supportsNetwork", () => { + it("should support Ethereum mainnet (chainId 1)", () => { + expect(provider.supportsNetwork({ chainId: "1" } as any)).toBe(true); + }); + + it("should support Base (chainId 8453)", () => { + expect(provider.supportsNetwork({ chainId: "8453" } as any)).toBe(true); + }); + + it("should not support unsupported chains", () => { + expect(provider.supportsNetwork({ chainId: "137" } as any)).toBe(false); + }); + + it("should not support networks without chainId", () => { + expect(provider.supportsNetwork({} as any)).toBe(false); + }); + }); + + describe("scoreTransaction", () => { + it("should return parsed risk score on success", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + risk_score: 72, + band: "high", + reasons: ["New recipient address", "Amount 4x above 30-day average"], + recommendation: "escalate", + }), + }); + + const result = await provider.scoreTransaction({ + to: "0xabc123", + value_usd: 50000, + asset: "USDC", + }); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.risk_score).toBe(72); + expect(parsed.band).toBe("high"); + expect(parsed.reasons).toHaveLength(2); + expect(parsed.recommendation).toBe("escalate"); + }); + + it("should pass contract_category when provided", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + risk_score: 10, + band: "low", + reasons: [], + }), + }); + + await provider.scoreTransaction({ + to: "0xdef456", + value_usd: 100, + asset: "ETH", + contract_category: "erc20", + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.contract_category).toBe("erc20"); + }); + + it("should not include contract_category in body when not provided", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ risk_score: 10, band: "low", reasons: [] }), + }); + + await provider.scoreTransaction({ + to: "0xdef456", + value_usd: 100, + asset: "ETH", + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.contract_category).toBeUndefined(); + }); + + it("should return error on non-ok HTTP response", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + const result = await provider.scoreTransaction({ + to: "0xabc123", + value_usd: 500, + asset: "USDC", + }); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain("HTTP 401"); + }); + + it("should return error on fetch exception", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network error")); + + const result = await provider.scoreTransaction({ + to: "0xabc123", + value_usd: 500, + asset: "USDC", + }); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain("Network error"); + }); + + it("should infer recommendation when API omits it", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ risk_score: 55, band: "medium", reasons: ["Unusual timing"] }), + }); + + const result = await provider.scoreTransaction({ + to: "0xabc123", + value_usd: 1000, + asset: "USDC", + }); + const parsed = JSON.parse(result); + expect(parsed.recommendation).toBe("review"); + }); + + it("should use custom baseUrl when provided", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ risk_score: 5, band: "low", reasons: [] }), + }); + + const customProvider = walletprintActionProvider({ + apiKey: "test-key", + baseUrl: "https://custom.example.com", + }); + await customProvider.scoreTransaction({ to: "0x123", value_usd: 10, asset: "ETH" }); + + expect(fetchMock.mock.calls[0][0]).toContain("custom.example.com"); + }); + + it("should send X-Api-Key header", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ risk_score: 5, band: "low", reasons: [] }), + }); + + await provider.scoreTransaction({ to: "0x123", value_usd: 10, asset: "ETH" }); + + expect(fetchMock.mock.calls[0][1].headers["X-Api-Key"]).toBe("walletprint-dev-key"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.ts b/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.ts new file mode 100644 index 000000000..637314ea6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/walletprint/walletprintActionProvider.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { ScoreTransactionSchema } from "./schemas"; + +const DEFAULT_BASE_URL = "https://walletprint.up.railway.app"; +const SUPPORTED_CHAIN_IDS = new Set(["1", "8453"]); + +/** + * Configuration options for WalletPrintActionProvider. + */ +export interface WalletPrintConfig { + /** WalletPrint API key. Use "walletprint-dev-key" for sandbox testing. */ + apiKey: string; + /** Optional base URL override. Defaults to the production API. */ + baseUrl?: string; +} + +/** + * WalletPrintActionProvider provides behavioral transaction risk scoring for AI agent wallets. + * It calls the WalletPrint API to score proposed transactions against the wallet's own + * behavioral history before they are signed. + * + * This action is advisory only — the agent decides what to do with the result. + */ +export class WalletPrintActionProvider extends ActionProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + + /** + * Constructs a new WalletPrintActionProvider. + * + * @param config - Configuration including API key and optional base URL. + */ + constructor(config: WalletPrintConfig) { + super("walletprint", []); + this.apiKey = config.apiKey; + this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + } + + /** + * Scores a proposed transaction against the sending wallet's behavioral history. + * Call this before executing a transaction to get a behavioral risk assessment. + * + * @param args - The transaction details to score. + * @returns A plain-English risk assessment the agent can read and act on. + */ + @CreateAction({ + name: "score_transaction", + description: `Score a proposed transaction against the sending wallet's behavioral history before signing. + +This action is ADVISORY ONLY — it never blocks a transaction. The agent decides what to do with the result. +Call it before send_transaction or equivalent signing actions when you want a behavioral risk check. + +Inputs: +- to: The recipient address (0x-prefixed) +- value_usd: The USD value of the transaction +- asset: The asset being transferred (e.g. "USDC", "ETH", "WBTC") +- contract_category (optional): Category of the contract being called (e.g. "erc20", "defi", "bridge") + +Returns: +- risk_score: 0–100 (higher = riskier) +- band: "low", "medium", or "high" +- reasons: Plain-English list of behavioral anomalies detected (e.g. "New recipient address", "Amount 4x above 30-day average") +- recommendation: Suggested action ("proceed", "review", or "escalate") + +Example use: Before sending 50,000 USDC to an address, call score_transaction to check whether the +recipient, amount, or timing looks unusual relative to this wallet's history. If the band is "high", +consider pausing and asking the user to confirm before proceeding. +`, + schema: ScoreTransactionSchema, + }) + async scoreTransaction(args: z.infer): Promise { + try { + const response = await fetch(`${this.baseUrl}/v1/score`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Api-Key": this.apiKey, + }, + body: JSON.stringify({ + to: args.to, + value_usd: args.value_usd, + asset: args.asset, + ...(args.contract_category ? { contract_category: args.contract_category } : {}), + }), + }); + + if (!response.ok) { + return JSON.stringify({ + success: false, + error: `WalletPrint API error: HTTP ${response.status}`, + }); + } + + const data = await response.json(); + + const reasons: string[] = Array.isArray(data.reasons) ? data.reasons : []; + const reasonSummary = + reasons.length > 0 ? reasons.join("; ") : "No specific anomalies detected"; + + return JSON.stringify({ + success: true, + risk_score: data.risk_score, + band: data.band, + reasons: reasons, + recommendation: data.recommendation ?? (data.band === "high" ? "escalate" : data.band === "medium" ? "review" : "proceed"), + summary: `Risk score: ${data.risk_score}/100 (${data.band}). ${reasonSummary}.`, + }); + } catch (error: unknown) { + return JSON.stringify({ + success: false, + error: `Error calling WalletPrint API: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + /** + * Checks if the WalletPrint action provider supports the given network. + * Supports Ethereum mainnet (chain ID 1) and Base (chain ID 8453). + * + * @param network - The network to check. + * @returns True if the network is supported. + */ + supportsNetwork = (network: Network): boolean => { + return network.chainId !== undefined && SUPPORTED_CHAIN_IDS.has(String(network.chainId)); + }; +} + +/** + * Creates a new WalletPrintActionProvider instance. + * + * @param config - Configuration including API key and optional base URL. + * @returns A new WalletPrintActionProvider. + */ +export const walletprintActionProvider = (config: WalletPrintConfig) => + new WalletPrintActionProvider(config);