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 .changeset/add-walletprint-action-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added WalletPrintActionProvider for behavioral transaction risk scoring
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 @@ -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";
Expand Down
64 changes: 64 additions & 0 deletions typescript/agentkit/src/action-providers/walletprint/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./walletprintActionProvider";
export * from "./schemas";
25 changes: 25 additions & 0 deletions typescript/agentkit/src/action-providers/walletprint/schemas.ts
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading
Loading