From a4fe5cf5e86b8f69e4f7d53603f137f072786d41 Mon Sep 17 00:00:00 2001 From: Jugmaster Date: Fri, 12 Jun 2026 13:39:35 -0400 Subject: [PATCH] feat: add SAID Protocol action provider (Solana agent identity + reputation) Adds a `said` action provider backed by SAID Protocol on Solana: - get_agent_reputation: look up any wallet's reputation tier and composite score before paying it (e.g. via x402); unknown wallets return an explicit "unknown counterparty" result rather than a fake neutral score - find_agents: discover SAID-registered agents ranked by reputation, with their published A2A/MCP endpoints and x402 payment wallets - register_said_identity: register and verify this wallet's on-chain SAID identity in one self-paid transaction (PDA rent + 0.01 SOL verification fee); the already-verified check reads is_verified from the on-chain account, and confirmation timeouts re-check signature status so a landed transaction is never reported as an error - send_agent_message / check_agent_messages: agent-to-agent messaging through the SAID relay. The sender must be a registered SAID agent and the relay records its verification and reputation; the recipient reads messages from its SAID mailbox, so no per-agent server is required and any registered agent is reachable Reputation lookups and messaging are read/write HTTP calls requiring no credentials. Registration is fully self-custodial: the agent's own wallet signs and pays. Co-Authored-By: Claude Fable 5 --- typescript/.changeset/said-action-provider.md | 5 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/said/README.md | 37 ++ .../src/action-providers/said/constants.ts | 39 ++ .../src/action-providers/said/index.ts | 2 + .../said/saidActionProvider.test.ts | 394 ++++++++++++++ .../said/saidActionProvider.ts | 505 ++++++++++++++++++ .../src/action-providers/said/schemas.ts | 96 ++++ .../src/action-providers/said/utils.ts | 102 ++++ 9 files changed, 1181 insertions(+) create mode 100644 typescript/.changeset/said-action-provider.md create mode 100644 typescript/agentkit/src/action-providers/said/README.md create mode 100644 typescript/agentkit/src/action-providers/said/constants.ts create mode 100644 typescript/agentkit/src/action-providers/said/index.ts create mode 100644 typescript/agentkit/src/action-providers/said/saidActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/said/saidActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/said/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/said/utils.ts diff --git a/typescript/.changeset/said-action-provider.md b/typescript/.changeset/said-action-provider.md new file mode 100644 index 000000000..ae376f093 --- /dev/null +++ b/typescript/.changeset/said-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added SAID Protocol action provider for Solana agent identity and reputation: look up any wallet's reputation before paying it (get_agent_reputation), discover reputable agents with their A2A/MCP/x402 endpoints (find_agents), register a self-paid on-chain SAID identity (register_said_identity), and send/receive agent-to-agent messages through the SAID relay with sender reputation attached (send_agent_message, check_agent_messages). diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..8379490d4 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 "./said"; export * from "./spl"; export * from "./superfluid"; export * from "./sushi"; diff --git a/typescript/agentkit/src/action-providers/said/README.md b/typescript/agentkit/src/action-providers/said/README.md new file mode 100644 index 000000000..bedbafa90 --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/README.md @@ -0,0 +1,37 @@ +# SAID Action Provider + +This directory contains the **SaidActionProvider** implementation, which provides actions to interact with the [SAID Protocol](https://saidprotocol.com) — an on-chain identity and reputation layer for AI agents on Solana. + +SAID gives every agent wallet a verifiable on-chain identity and a reputation score derived from observable behavior: x402 payments received, anchored work receipts, peer feedback and attestations. Reputation is a byproduct of the agent actually doing work and getting paid — not self-reported claims. + +## Directory Structure + +``` +said/ +├── saidActionProvider.ts # Main provider with SAID functionality +├── constants.ts # Program ID, API URL, fee constants +├── schemas.ts # Action schemas +├── utils.ts # Instruction builders and PDA derivation +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +- `get_agent_reputation`: Look up the SAID reputation of **any** Solana wallet — tier, composite score (0–1), registration and verification status. Designed as a pre-payment trust check: call it before paying an unknown counterparty (e.g. via x402). A wallet with no track record returns an explicit "unknown counterparty" result, never a fake neutral score. +- `find_agents`: Discover SAID-registered agents ranked by reputation, filtered by free-text query or skill. Results include each agent's service endpoints (A2A, MCP, x402 wallet) when published — so agents can find, vet and then pay or message each other. +- `register_said_identity`: Register **this** wallet as a SAID agent and verify it on-chain, in one self-paid transaction (`register_agent` + `get_verified`). The wallet pays its own costs: ~0.0035 SOL account rent plus a one-time 0.01 SOL verification fee. Requires a metadata URI (an [A2A agent card](https://google.github.io/A2A/) URL works well). +- `send_agent_message`: Send an agent-to-agent (A2A) message to another SAID agent through the SAID relay. This wallet must be registered (the relay records the sender's verification and reputation). The recipient does not need to run its own server — every SAID agent has a relay mailbox — so this works for any registered agent. Returns a task id. +- `check_agent_messages`: Read incoming A2A messages addressed to this wallet, each annotated with the sender's wallet, name, verification status and reputation score, so the agent can decide whether to act based on who is asking. + +## Network Support + +Solana mainnet (`solana-mainnet`) only. Registration is a mainnet transaction; reputation data covers mainnet agents. + +## Notes + +- The reputation lookups are read-only HTTP calls to the SAID API (`https://api.saidprotocol.com`, configurable via `apiUrl`). No keys or authentication required. +- Registration is fully self-custodial: the agent's own wallet signs and pays; no third party signs anything. +- SAID is complementary to human-verification systems (e.g. proof-of-personhood): it scores the **agent wallet's own track record**, not the human behind it. It is also complementary to the ERC-8004 provider: SAID covers the Solana side and derives scores from payment/delivery behavior. + +For more information, see the [SAID Protocol documentation](https://saidprotocol.com). diff --git a/typescript/agentkit/src/action-providers/said/constants.ts b/typescript/agentkit/src/action-providers/said/constants.ts new file mode 100644 index 000000000..fab5ba114 --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/constants.ts @@ -0,0 +1,39 @@ +/** + * Constants for the SAID Protocol action provider. + */ + +/** + * The SAID Protocol program ID on Solana mainnet. + */ +export const SAID_PROGRAM_ID = "5dpw6KEQPn248pnkkaYyWfHwu2nfb3LUMbTucb6LaA8G"; + +/** + * Default base URL for the SAID Protocol API. + */ +export const DEFAULT_SAID_API_URL = "https://api.saidprotocol.com"; + +/** + * One-time verification fee transferred to the SAID treasury by `get_verified` (0.01 SOL). + */ +export const VERIFICATION_FEE_LAMPORTS = 10_000_000; + +/** + * Approximate rent for the agent identity PDA created by `register_agent` (~0.003 SOL). + */ +export const REGISTRATION_RENT_LAMPORTS = 3_500_000; + +/** + * Headroom for transaction fees. + */ +export const TX_FEE_HEADROOM_LAMPORTS = 500_000; + +/** + * Minimum balance required to register + verify in one transaction. + */ +export const MIN_REGISTER_AND_VERIFY_LAMPORTS = + VERIFICATION_FEE_LAMPORTS + REGISTRATION_RENT_LAMPORTS + TX_FEE_HEADROOM_LAMPORTS; + +/** + * Minimum balance required to verify an already-registered agent. + */ +export const MIN_VERIFY_ONLY_LAMPORTS = VERIFICATION_FEE_LAMPORTS + TX_FEE_HEADROOM_LAMPORTS; diff --git a/typescript/agentkit/src/action-providers/said/index.ts b/typescript/agentkit/src/action-providers/said/index.ts new file mode 100644 index 000000000..88abddd21 --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/index.ts @@ -0,0 +1,2 @@ +export * from "./saidActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/said/saidActionProvider.test.ts b/typescript/agentkit/src/action-providers/said/saidActionProvider.test.ts new file mode 100644 index 000000000..907d31d95 --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/saidActionProvider.test.ts @@ -0,0 +1,394 @@ +import { Connection, Keypair } from "@solana/web3.js"; +import { SvmWalletProvider } from "../../wallet-providers/svmWalletProvider"; +import { SaidActionProvider } from "./saidActionProvider"; +import { MIN_REGISTER_AND_VERIFY_LAMPORTS } from "./constants"; +import { deriveAgentIdentityPda } from "./utils"; + +jest.mock("../../wallet-providers/svmWalletProvider"); + +const OWNER = Keypair.generate().publicKey; +const OTHER_WALLET = Keypair.generate().publicKey.toBase58(); + +/** + * Builds a mock fetch Response. + * + * @param body - The JSON body to resolve with + * @param ok - Whether the response is ok (default true) + * @param status - The HTTP status code (defaults to 200 if ok, else 500) + * @returns A mock Response-like object + */ +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 500) { + return { + ok, + status, + json: jest.fn().mockResolvedValue(body), + }; +} + +describe("SaidActionProvider", () => { + let actionProvider: SaidActionProvider; + let mockWallet: jest.Mocked; + let mockConnection: jest.Mocked; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + actionProvider = new SaidActionProvider(); + + mockConnection = { + getAccountInfo: jest.fn().mockResolvedValue(null), + getBalance: jest.fn().mockResolvedValue(MIN_REGISTER_AND_VERIFY_LAMPORTS * 2), + getLatestBlockhash: jest + .fn() + .mockResolvedValue({ blockhash: "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi" }), + } as unknown as jest.Mocked; + + mockWallet = { + getConnection: jest.fn().mockReturnValue(mockConnection), + getPublicKey: jest.fn().mockReturnValue(OWNER), + signMessage: jest.fn().mockResolvedValue(new Uint8Array(64).fill(7)), + signAndSendTransaction: jest.fn().mockResolvedValue("mock-signature"), + waitForSignatureResult: jest.fn().mockResolvedValue({ + context: { slot: 1 }, + value: { err: null }, + }), + } as unknown as jest.Mocked; + + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + describe("supportsNetwork", () => { + it("should support Solana mainnet", () => { + expect( + actionProvider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" }), + ).toBe(true); + }); + + it("should not support other networks", () => { + expect( + actionProvider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-devnet" }), + ).toBe(false); + expect( + actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }), + ).toBe(false); + }); + }); + + describe("getAgentReputation", () => { + it("should return the reputation summary for a scored wallet", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + wallet: OTHER_WALLET, + tier: "silver", + compositeScore: 0.7421, + registered: true, + verified: true, + scored: true, + }), + ); + + const result = await actionProvider.getAgentReputation(mockWallet, { + wallet: OTHER_WALLET, + }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.saidprotocol.com/api/trust/${OTHER_WALLET}`, + ); + expect(result).toContain("silver"); + expect(result).toContain("0.7421"); + expect(result).toContain("Registered SAID agent: yes"); + expect(result).toContain("On-chain verified: yes"); + }); + + it("should describe an unknown wallet as an unknown counterparty", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + wallet: OTHER_WALLET, + tier: "unranked", + compositeScore: 0, + registered: false, + verified: false, + scored: false, + }), + ); + + const result = await actionProvider.getAgentReputation(mockWallet, { + wallet: OTHER_WALLET, + }); + + expect(result).toContain("unknown counterparty"); + expect(result).not.toContain("Tier: unranked"); + }); + + it("should reject an invalid address without calling the API", async () => { + const result = await actionProvider.getAgentReputation(mockWallet, { + wallet: "not-a-solana-address", + }); + + expect(result).toContain("not a valid Solana address"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should surface API errors", async () => { + fetchMock.mockResolvedValue(jsonResponse({}, false)); + + const result = await actionProvider.getAgentReputation(mockWallet, { + wallet: OTHER_WALLET, + }); + + expect(result).toContain("Error fetching SAID reputation"); + }); + }); + + describe("findAgents", () => { + const agent = { + name: "Research Bot", + description: "Performs deep research for hire", + wallet: OTHER_WALLET, + isVerified: true, + skills: ["research", "summarization"], + serviceTypes: ["api"], + a2aEndpoint: "https://bot.example.com/a2a", + mcpEndpoint: null, + x402Wallet: OTHER_WALLET, + reputation: { tier: "gold", compositeScore: 0.81, scored: true }, + }; + + it("should return a ranked list with endpoints", async () => { + fetchMock.mockResolvedValue(jsonResponse({ agents: [agent] })); + + const result = await actionProvider.findAgents(mockWallet, { + query: "research", + verifiedOnly: true, + limit: 5, + }); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("search=research"); + expect(calledUrl).toContain("verified=true"); + expect(calledUrl).toContain("limit=5"); + expect(result).toContain("Research Bot"); + expect(result).toContain("gold (0.81)"); + expect(result).toContain("A2A: https://bot.example.com/a2a"); + expect(result).toContain(`x402 wallet: ${OTHER_WALLET}`); + }); + + it("should handle empty results", async () => { + fetchMock.mockResolvedValue(jsonResponse({ agents: [] })); + + const result = await actionProvider.findAgents(mockWallet, { + query: "nonexistent", + verifiedOnly: true, + limit: 5, + }); + + expect(result).toBe("No SAID agents matched the search."); + }); + }); + + describe("sendAgentMessage", () => { + it("should send a message via the relay and return the task id", async () => { + fetchMock.mockResolvedValue(jsonResponse({ taskId: "task_123", status: "created" })); + + const result = await actionProvider.sendAgentMessage(mockWallet, { + toWallet: OTHER_WALLET, + message: "please research X", + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`https://api.saidprotocol.com/a2a/${OTHER_WALLET}/message`); + expect(init.method).toBe("POST"); + const sent = JSON.parse(init.body); + expect(sent.from).toBe(OWNER.toBase58()); + expect(mockWallet.signMessage).toHaveBeenCalledTimes(1); + expect(typeof sent.signature).toBe("string"); + expect(typeof sent.timestamp).toBe("number"); + expect(result).toContain("task_123"); + expect(result).toContain("created"); + }); + + it("should still send unauthenticated when the wallet cannot sign messages", async () => { + mockWallet.signMessage.mockRejectedValue(new Error("Message signing is not supported yet")); + fetchMock.mockResolvedValue(jsonResponse({ taskId: "task_456", status: "created" })); + + const result = await actionProvider.sendAgentMessage(mockWallet, { + toWallet: OTHER_WALLET, + message: "hi", + }); + + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sent.signature).toBeUndefined(); + expect(sent.timestamp).toBeUndefined(); + expect(result).toContain("unauthenticated"); + expect(result).toContain("task_456"); + }); + + it("should tell the agent to register when the relay rejects an unregistered sender", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: "Sender not registered on SAID" }, false, 403), + ); + + const result = await actionProvider.sendAgentMessage(mockWallet, { + toWallet: OTHER_WALLET, + message: "hi", + }); + + expect(result).toContain("register_said_identity"); + }); + + it("should report when the recipient is not a SAID agent", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "Recipient not found" }, false, 404)); + + const result = await actionProvider.sendAgentMessage(mockWallet, { + toWallet: OTHER_WALLET, + message: "hi", + }); + + expect(result).toContain("not a registered SAID agent"); + }); + + it("should reject an invalid recipient address", async () => { + const result = await actionProvider.sendAgentMessage(mockWallet, { + toWallet: "nope", + message: "hi", + }); + + expect(result).toContain("not a valid Solana address"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("checkAgentMessages", () => { + it("should list incoming messages with sender reputation", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + count: 1, + messages: [ + { + taskId: "task_9", + message: "can you summarize this?", + status: "created", + createdAt: "2026-06-13T00:00:00Z", + from: { wallet: OTHER_WALLET, name: "Caller Bot", verified: true, reputation: 0.72 }, + }, + ], + }), + ); + + const result = await actionProvider.checkAgentMessages(mockWallet, { limit: 20 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain(`/a2a/${OWNER.toBase58()}/inbox`); + expect(result).toContain("Caller Bot"); + expect(result).toContain("verified, reputation 0.72"); + expect(result).toContain("task_9"); + }); + + it("should handle an empty inbox", async () => { + fetchMock.mockResolvedValue(jsonResponse({ count: 0, messages: [] })); + + const result = await actionProvider.checkAgentMessages(mockWallet, { limit: 20 }); + + expect(result).toBe("No incoming agent messages."); + }); + }); + + describe("registerSaidIdentity", () => { + const META = "https://example.com/.well-known/agent-card.json"; + + /** + * Builds raw agent identity account data matching the on-chain layout. + * + * @param isVerified - The value of the on-chain is_verified flag + * @returns The raw account data buffer + */ + function agentAccountData(isVerified: boolean): Buffer { + const uri = Buffer.from(META, "utf8"); + const data = Buffer.alloc(8 + 32 + 32 + 4 + uri.length + 8 + 1 + 16); + data.writeUInt32LE(uri.length, 8 + 32 + 32); + uri.copy(data, 8 + 32 + 32 + 4); + data[8 + 32 + 32 + 4 + uri.length + 8] = isVerified ? 1 : 0; + return data; + } + + it("should register and verify an unregistered wallet", async () => { + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + expect(mockWallet.signAndSendTransaction).toHaveBeenCalledTimes(1); + const tx = mockWallet.signAndSendTransaction.mock.calls[0][0]; + expect(tx.message.compiledInstructions).toHaveLength(2); + expect(result).toContain("registered and verified"); + expect(result).toContain("mock-signature"); + expect(result).toContain(deriveAgentIdentityPda(OWNER).toBase58()); + }); + + it("should only verify when an unverified identity PDA already exists", async () => { + mockConnection.getAccountInfo.mockResolvedValue({ data: agentAccountData(false) } as never); + + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + const tx = mockWallet.signAndSendTransaction.mock.calls[0][0]; + expect(tx.message.compiledInstructions).toHaveLength(1); + expect(result).toContain("verified"); + }); + + it("should do nothing when the on-chain identity is already verified", async () => { + mockConnection.getAccountInfo.mockResolvedValue({ data: agentAccountData(true) } as never); + + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + expect(mockWallet.signAndSendTransaction).not.toHaveBeenCalled(); + expect(result).toContain("already has a verified SAID identity"); + }); + + it("should refuse when the balance is insufficient", async () => { + mockConnection.getBalance.mockResolvedValue(1_000_000); + + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + expect(mockWallet.signAndSendTransaction).not.toHaveBeenCalled(); + expect(result).toContain("insufficient balance"); + }); + + it("should report success when confirmation times out but the transaction landed", async () => { + mockWallet.waitForSignatureResult.mockRejectedValue( + new Error("Signature has expired: block height exceeded"), + ); + mockConnection.getSignatureStatuses = jest.fn().mockResolvedValue({ + value: [{ err: null, confirmationStatus: "finalized" }], + }) as never; + + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + expect(result).toContain("registered and verified"); + expect(result).toContain("mock-signature"); + }); + + it("should report an error when confirmation fails and the transaction did not land", async () => { + mockWallet.waitForSignatureResult.mockRejectedValue( + new Error("Signature has expired: block height exceeded"), + ); + mockConnection.getSignatureStatuses = jest.fn().mockResolvedValue({ + value: [null], + }) as never; + + const result = await actionProvider.registerSaidIdentity(mockWallet, { + metadataUri: META, + }); + + expect(result).toContain("Error registering SAID identity"); + }, 15000); + }); +}); diff --git a/typescript/agentkit/src/action-providers/said/saidActionProvider.ts b/typescript/agentkit/src/action-providers/said/saidActionProvider.ts new file mode 100644 index 000000000..d5a0cb4ba --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/saidActionProvider.ts @@ -0,0 +1,505 @@ +import { PublicKey, TransactionMessage, VersionedTransaction } from "@solana/web3.js"; +import bs58 from "bs58"; +import { z } from "zod"; +import { CreateAction } from "../actionDecorator"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { SvmWalletProvider } from "../../wallet-providers/svmWalletProvider"; +import { + DEFAULT_SAID_API_URL, + MIN_REGISTER_AND_VERIFY_LAMPORTS, + MIN_VERIFY_ONLY_LAMPORTS, +} from "./constants"; +import { + CheckAgentMessagesSchema, + FindAgentsSchema, + GetAgentReputationSchema, + RegisterSaidIdentitySchema, + SendAgentMessageSchema, +} from "./schemas"; +import { + buildGetVerifiedInstruction, + buildRegisterAgentInstruction, + deriveAgentIdentityPda, + parseIsVerified, +} from "./utils"; + +/** + * Configuration options for the SAID action provider. + */ +export interface SaidActionProviderConfig { + /** + * Base URL of the SAID Protocol API. Defaults to https://api.saidprotocol.com. + */ + apiUrl?: string; +} + +interface TrustResponse { + wallet: string; + tier: string; + compositeScore: number; + registered: boolean; + verified: boolean; + scored: boolean; +} + +interface AgentListItem { + name: string | null; + description: string | null; + wallet: string; + isVerified: boolean; + skills: string[]; + serviceTypes: string[]; + a2aEndpoint: string | null; + mcpEndpoint: string | null; + x402Wallet: string | null; + reputation?: { tier: string; compositeScore: number; scored: boolean }; +} + +interface InboxResponse { + messages: Array<{ + taskId: string; + message: string; + status: string; + createdAt: string; + from: { wallet: string; name: string; verified: boolean; reputation: number }; + }>; + count: number; +} + +/** + * SaidActionProvider provides agent identity and reputation actions backed by the + * SAID Protocol (https://saidprotocol.com) on Solana. + * + * SAID gives every agent wallet an on-chain identity and a reputation score derived + * from observable behavior: x402 payments received, anchored work receipts, peer + * feedback and attestations. Agents use it to check a counterparty's track record + * before paying it, to discover reputable agents to hire, and to establish their own + * verifiable identity. + */ +export class SaidActionProvider extends ActionProvider { + private readonly apiUrl: string; + + /** + * Creates a new SaidActionProvider. + * + * @param config - Optional configuration (API base URL override) + */ + constructor(config: SaidActionProviderConfig = {}) { + super("said", []); + this.apiUrl = (config.apiUrl ?? DEFAULT_SAID_API_URL).replace(/\/$/, ""); + } + + /** + * Looks up the SAID reputation of any Solana wallet. + * + * @param walletProvider - The wallet provider (unused; this is a read-only lookup) + * @param args - The wallet address to look up + * @returns A human-readable reputation summary + */ + @CreateAction({ + name: "get_agent_reputation", + description: ` +Looks up the SAID Protocol reputation of any Solana wallet address. +Use this BEFORE paying an unknown agent or service (e.g. via x402) to check its track record. +Returns the wallet's reputation tier, composite score (0-1), and whether it is a registered, +on-chain verified SAID agent. +A wallet with no SAID track record is not necessarily malicious — it is an UNKNOWN counterparty, +so apply the caution you would apply to a stranger. +`, + schema: GetAgentReputationSchema, + }) + async getAgentReputation( + walletProvider: SvmWalletProvider, + args: z.infer, + ): Promise { + let wallet: PublicKey; + try { + wallet = new PublicKey(args.wallet); + } catch { + return `Error: ${args.wallet} is not a valid Solana address.`; + } + + let trust: TrustResponse; + try { + trust = await this.fetchJson(`/api/trust/${wallet.toBase58()}`); + } catch (error) { + return `Error fetching SAID reputation: ${error}`; + } + + if (!trust.scored && !trust.registered) { + return ( + `${wallet.toBase58()} has no SAID identity or track record. ` + + `Treat it as an unknown counterparty: it has not registered an on-chain identity ` + + `and SAID has observed no payment, delivery or feedback history for it.` + ); + } + + const lines = [ + `SAID reputation for ${wallet.toBase58()}:`, + `- Tier: ${trust.tier} (composite score ${trust.compositeScore})`, + `- Registered SAID agent: ${trust.registered ? "yes" : "no"}`, + `- On-chain verified: ${trust.verified ? "yes" : "no"}`, + ]; + if (!trust.scored) { + lines.push( + `- Note: registered but not yet scored — SAID has no behavioral history for this wallet yet.`, + ); + } + return lines.join("\n"); + } + + /** + * Discovers SAID-registered agents, ranked by reputation. + * + * @param walletProvider - The wallet provider (unused; this is a read-only lookup) + * @param args - Search filters (free-text query, skill, verified-only, limit) + * @returns A ranked list of matching agents with their endpoints + */ + @CreateAction({ + name: "find_agents", + description: ` +Discovers SAID-registered agents, ranked by reputation score. +Use this to find reputable agents to hire, pay (e.g. via x402) or communicate with. +Results include each agent's wallet, reputation tier, declared skills, and service +endpoints (A2A endpoint, MCP endpoint, x402 payment wallet) when the agent has published them. +`, + schema: FindAgentsSchema, + }) + async findAgents( + walletProvider: SvmWalletProvider, + args: z.infer, + ): Promise { + const params = new URLSearchParams(); + if (args.query) params.set("search", args.query); + if (args.skill) params.set("skill", args.skill); + if (args.verifiedOnly !== false) params.set("verified", "true"); + params.set("limit", String(args.limit ?? 5)); + + let agents: AgentListItem[]; + try { + const response = await this.fetchJson<{ agents: AgentListItem[] }>( + `/api/agents?${params.toString()}`, + ); + agents = response.agents; + } catch (error) { + return `Error searching SAID agents: ${error}`; + } + + if (!agents.length) { + return "No SAID agents matched the search."; + } + + const entries = agents.map((agent, i) => { + const lines = [ + `${i + 1}. ${agent.name ?? "(unnamed)"} — ${agent.wallet}`, + ` Reputation: ${agent.reputation?.scored ? `${agent.reputation.tier} (${agent.reputation.compositeScore})` : "not yet scored"}${agent.isVerified ? ", on-chain verified" : ""}`, + ]; + if (agent.description) { + lines.push(` ${agent.description.slice(0, 160)}`); + } + if (agent.skills?.length) { + lines.push(` Skills: ${agent.skills.join(", ")}`); + } + const endpoints = [ + agent.a2aEndpoint ? `A2A: ${agent.a2aEndpoint}` : null, + agent.mcpEndpoint ? `MCP: ${agent.mcpEndpoint}` : null, + agent.x402Wallet ? `x402 wallet: ${agent.x402Wallet}` : null, + ].filter(Boolean); + if (endpoints.length) { + lines.push(` Endpoints: ${endpoints.join(" | ")}`); + } + return lines.join("\n"); + }); + + return `Found ${agents.length} SAID agent(s), ranked by reputation:\n${entries.join("\n")}`; + } + + /** + * Registers and verifies the agent's own SAID identity on Solana. The agent's + * wallet signs the transaction and pays for it (PDA rent + 0.01 SOL verification fee). + * + * @param walletProvider - The wallet provider used to sign and pay + * @param args - The metadata URI describing this agent + * @returns A message with the transaction signature, or an error message + */ + @CreateAction({ + name: "register_said_identity", + description: ` +Registers THIS wallet as a SAID agent on Solana mainnet and verifies it on-chain. +This establishes a portable, verifiable identity: other agents can then look up this wallet's +reputation, which accrues automatically from observable behavior (x402 payments received, +anchored work receipts, peer feedback). +Costs are paid by this wallet: ~0.0035 SOL identity account rent plus a one-time 0.01 SOL +verification fee to the SAID treasury. The wallet must hold at least 0.015 SOL. +Requires a metadata URI describing the agent (an A2A agent card URL works well). +`, + schema: RegisterSaidIdentitySchema, + }) + async registerSaidIdentity( + walletProvider: SvmWalletProvider, + args: z.infer, + ): Promise { + try { + const owner = walletProvider.getPublicKey(); + const connection = walletProvider.getConnection(); + const agentPda = deriveAgentIdentityPda(owner); + + // The on-chain account is the source of truth for the verified flag; the + // indexer API can lag a fresh registration, and re-running get_verified on + // an already-verified identity would pay the 0.01 SOL fee again. + const existing = await connection.getAccountInfo(agentPda); + if (existing && parseIsVerified(existing.data)) { + return `This wallet already has a verified SAID identity (PDA ${agentPda.toBase58()}). Nothing to do.`; + } + + const required = existing ? MIN_VERIFY_ONLY_LAMPORTS : MIN_REGISTER_AND_VERIFY_LAMPORTS; + const balance = await connection.getBalance(owner); + if (balance < required) { + return ( + `Error: insufficient balance to ${existing ? "verify" : "register and verify"} a SAID identity. ` + + `Need at least ${required / 1e9} SOL (verification fee + ${existing ? "fees" : "account rent + fees"}), ` + + `wallet holds ${balance / 1e9} SOL.` + ); + } + + const instructions = existing + ? [buildGetVerifiedInstruction(owner, owner)] + : [ + buildRegisterAgentInstruction(owner, args.metadataUri), + buildGetVerifiedInstruction(owner, owner), + ]; + + const { blockhash } = await connection.getLatestBlockhash(); + const message = new TransactionMessage({ + payerKey: owner, + recentBlockhash: blockhash, + instructions, + }).compileToV0Message(); + const transaction = new VersionedTransaction(message); + + const signature = await walletProvider.signAndSendTransaction(transaction); + try { + await walletProvider.waitForSignatureResult(signature); + } catch (waitError) { + // Confirmation polling can time out (e.g. blockhash expiry on a slow RPC) + // even though the transaction landed. Check the signature status before + // reporting failure so a landed transaction is never reported as an error. + const landed = await this.signatureLanded(connection, signature); + if (!landed) { + throw waitError; + } + } + + return ( + `Successfully ${existing ? "verified" : "registered and verified"} this wallet's SAID identity.\n` + + `- Agent identity PDA: ${agentPda.toBase58()}\n` + + `- Transaction: ${signature}\n` + + `Reputation now accrues automatically from this wallet's behavior (x402 payments received, ` + + `work receipts, peer feedback). Other agents can check it via get_agent_reputation.` + ); + } catch (error) { + return `Error registering SAID identity: ${error}`; + } + } + + /** + * Sends an agent-to-agent message to another SAID agent through the SAID relay. + * The relay requires the sender (this wallet) to be a registered SAID agent and + * delivers to the recipient's SAID mailbox, so the recipient does not need to run + * its own A2A endpoint. + * + * @param walletProvider - The wallet provider; its public key is the sender + * @param args - Recipient wallet, message text, and optional structured context + * @returns A message with the relay task id, or an error message + */ + @CreateAction({ + name: "send_agent_message", + description: ` +Sends an agent-to-agent (A2A) message to another SAID-registered agent through the SAID relay. +THIS wallet must be a registered SAID agent (use register_said_identity first) — the relay +records the sender's verification and reputation alongside the message. +The recipient does not need to run its own server: every SAID agent has a relay mailbox. +Use find_agents to discover recipients and get_agent_reputation to vet them before messaging. +Returns a task id the recipient can act on and report results against. +`, + schema: SendAgentMessageSchema, + }) + async sendAgentMessage( + walletProvider: SvmWalletProvider, + args: z.infer, + ): Promise { + let toWallet: PublicKey; + try { + toWallet = new PublicKey(args.toWallet); + } catch { + return `Error: ${args.toWallet} is not a valid Solana address.`; + } + + const from = walletProvider.getPublicKey().toBase58(); + + // Sign the message so the relay can prove this wallet is the real sender; + // without it the recipient sees the message as an unauthenticated, no-trust + // message. Some wallet providers cannot sign arbitrary messages — in that + // case we still send, just unauthenticated. + let signature: string | undefined; + let timestamp: number | undefined; + try { + timestamp = Date.now(); + const payload = `SAID-A2A:${from}:${toWallet.toBase58()}:${timestamp}:${args.message}`; + const sigBytes = await walletProvider.signMessage(new TextEncoder().encode(payload)); + signature = bs58.encode(sigBytes); + } catch { + signature = undefined; + timestamp = undefined; + } + + try { + const response = await fetch(`${this.apiUrl}/a2a/${toWallet.toBase58()}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from, + message: args.message, + context: args.context, + signature, + timestamp, + }), + }); + const body = (await response.json().catch(() => ({}))) as { + taskId?: string; + status?: string; + error?: string; + }; + if (!response.ok) { + if (response.status === 403) { + return `Cannot send: this wallet (${from}) is not registered on SAID. Register it first with register_said_identity.`; + } + if (response.status === 404) { + return `Cannot send: ${toWallet.toBase58()} is not a registered SAID agent.`; + } + return `Error sending message: ${body.error ?? `SAID relay returned ${response.status}`}`; + } + return ( + `Message sent to ${toWallet.toBase58()} via the SAID relay${signature ? "" : " (unauthenticated — this wallet could not sign)"}.\n` + + `- Task id: ${body.taskId}\n` + + `- Status: ${body.status}\n` + + `The recipient can read it from its SAID inbox and report results against this task id.` + ); + } catch (error) { + return `Error sending message: ${error}`; + } + } + + /** + * Reads incoming A2A messages addressed to this wallet from its SAID mailbox. + * + * @param walletProvider - The wallet provider; its public key is the inbox owner + * @param args - Optional status filter and result limit + * @returns A human-readable list of incoming messages with sender reputation + */ + @CreateAction({ + name: "check_agent_messages", + description: ` +Reads incoming agent-to-agent (A2A) messages addressed to THIS wallet from its SAID mailbox. +Each message includes the sender's wallet, name, SAID verification status and reputation score, +so this agent can decide whether to act on a request based on who sent it. +`, + schema: CheckAgentMessagesSchema, + }) + async checkAgentMessages( + walletProvider: SvmWalletProvider, + args: z.infer, + ): Promise { + const self = walletProvider.getPublicKey().toBase58(); + const params = new URLSearchParams(); + params.set("limit", String(args.limit ?? 20)); + if (args.status) params.set("status", args.status); + + let inbox: InboxResponse; + try { + inbox = await this.fetchJson(`/a2a/${self}/inbox?${params.toString()}`); + } catch (error) { + return `Error reading inbox: ${error}`; + } + + if (!inbox.messages.length) { + return "No incoming agent messages."; + } + + const entries = inbox.messages.map((m, i) => { + const rep = m.from.verified + ? `verified, reputation ${m.from.reputation}` + : "unverified sender"; + return ( + `${i + 1}. from ${m.from.name} (${m.from.wallet}) — ${rep}\n` + + ` [${m.status}] task ${m.taskId}: ${m.message}` + ); + }); + + return `${inbox.count} incoming message(s):\n${entries.join("\n")}`; + } + + /** + * Checks if the action provider supports the given network. + * Registration is a Solana mainnet transaction; reputation data covers mainnet agents. + * + * @param network - The network to check support for + * @returns True if the network is Solana mainnet + */ + supportsNetwork(network: Network): boolean { + return network.protocolFamily === "svm" && network.networkId === "solana-mainnet"; + } + + /** + * Checks whether a signature landed successfully, retrying briefly to allow + * for RPC propagation. + * + * @param connection - The Solana connection + * @param signature - The transaction signature to check + * @returns True if the transaction is confirmed or finalized without error + */ + private async signatureLanded( + connection: ReturnType, + signature: string, + ): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const { value } = await connection.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + const status = value[0]; + if ( + status && + status.err === null && + (status.confirmationStatus === "confirmed" || status.confirmationStatus === "finalized") + ) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + return false; + } + + /** + * Fetches and parses JSON from the SAID API. + * + * @param path - API path beginning with a slash + * @returns The parsed JSON response + */ + private async fetchJson(path: string): Promise { + const response = await fetch(`${this.apiUrl}${path}`); + if (!response.ok) { + throw new Error(`SAID API returned ${response.status} for ${path}`); + } + return (await response.json()) as T; + } +} + +/** + * Factory function to create a new SaidActionProvider instance. + * + * @param config - Optional configuration (API base URL override) + * @returns A new SaidActionProvider instance + */ +export const saidActionProvider = (config: SaidActionProviderConfig = {}) => + new SaidActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/said/schemas.ts b/typescript/agentkit/src/action-providers/said/schemas.ts new file mode 100644 index 000000000..174485c4a --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/schemas.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +/** + * Input schema for looking up a wallet's SAID reputation. + */ +export const GetAgentReputationSchema = z + .object({ + wallet: z + .string() + .describe("The Solana wallet address (base58) of the agent or counterparty to look up"), + }) + .strip() + .describe("Look up the SAID reputation of a Solana wallet"); + +/** + * Input schema for discovering SAID-registered agents. + */ +export const FindAgentsSchema = z + .object({ + query: z.string().optional().describe("Free-text search over agent names and descriptions"), + skill: z.string().optional().describe("Filter agents by a declared skill"), + verifiedOnly: z + .boolean() + .optional() + .default(true) + .describe("Only return on-chain verified agents (default true)"), + limit: z + .number() + .int() + .min(1) + .max(25) + .optional() + .default(5) + .describe("Maximum number of agents to return (default 5)"), + }) + .strip() + .describe("Discover SAID-registered agents ranked by reputation"); + +/** + * Input schema for registering the agent's own SAID identity. + */ +export const RegisterSaidIdentitySchema = z + .object({ + metadataUri: z + .string() + .min(10) + .max(200) + .refine( + uri => uri.startsWith("https://") || uri.startsWith("ipfs://") || uri.startsWith("ar://"), + "metadataUri must start with https://, ipfs:// or ar://", + ) + .describe( + "URI of the agent's metadata card (JSON with name, description, skills, endpoints). " + + "An A2A agent card URL (e.g. https:///.well-known/agent-card.json) works well.", + ), + }) + .strip() + .describe("Register and verify this wallet's SAID identity on Solana"); + +/** + * Input schema for sending an A2A message to another agent via the SAID relay. + */ +export const SendAgentMessageSchema = z + .object({ + toWallet: z + .string() + .describe("The Solana wallet address of the SAID-registered agent to message"), + message: z.string().min(1).describe("The message text to send"), + context: z + .record(z.string(), z.unknown()) + .optional() + .describe("Optional structured context object to attach to the message (e.g. a task spec)"), + }) + .strip() + .describe("Send an agent-to-agent message to another SAID agent via the SAID relay"); + +/** + * Input schema for reading this agent's A2A inbox. + */ +export const CheckAgentMessagesSchema = z + .object({ + status: z + .string() + .optional() + .describe("Optional filter by task status (e.g. created, in_progress, completed)"), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .default(20) + .describe("Maximum number of messages to return (default 20)"), + }) + .strip() + .describe("Read incoming A2A messages addressed to this wallet, with sender reputation"); diff --git a/typescript/agentkit/src/action-providers/said/utils.ts b/typescript/agentkit/src/action-providers/said/utils.ts new file mode 100644 index 000000000..573ebe458 --- /dev/null +++ b/typescript/agentkit/src/action-providers/said/utils.ts @@ -0,0 +1,102 @@ +import { createHash } from "crypto"; +import { PublicKey, SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import { SAID_PROGRAM_ID } from "./constants"; + +const PROGRAM_ID = new PublicKey(SAID_PROGRAM_ID); + +/** + * Computes the 8-byte Anchor instruction discriminator for a global instruction. + * + * @param name - The snake_case instruction name + * @returns The 8-byte discriminator buffer + */ +function anchorDiscriminator(name: string): Buffer { + return createHash("sha256").update(`global:${name}`).digest().subarray(0, 8); +} + +/** + * Derives the agent identity PDA for a wallet. + * + * @param owner - The agent's wallet public key + * @returns The agent identity PDA + */ +export function deriveAgentIdentityPda(owner: PublicKey): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("agent"), owner.toBuffer()], + PROGRAM_ID, + ); + return pda; +} + +/** + * Derives the SAID treasury PDA. + * + * @returns The treasury PDA + */ +export function deriveTreasuryPda(): PublicKey { + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("treasury")], PROGRAM_ID); + return pda; +} + +/** + * Parses the `is_verified` flag from a raw agent identity account. + * Layout: 8-byte discriminator, owner (32), authority (32), metadata_uri + * (4-byte length prefix + bytes), created_at (8), is_verified (1). + * + * @param data - The raw account data + * @returns True if the agent identity is verified on-chain + */ +export function parseIsVerified(data: Buffer): boolean { + const uriLength = data.readUInt32LE(8 + 32 + 32); + return data[8 + 32 + 32 + 4 + uriLength + 8] === 1; +} + +/** + * Builds the `register_agent` instruction. The owner signs and pays rent for the + * agent identity PDA. + * + * @param owner - The agent's wallet public key (signer + payer) + * @param metadataUri - URI of the agent's metadata card (https://, ipfs:// or ar://) + * @returns The register_agent instruction + */ +export function buildRegisterAgentInstruction( + owner: PublicKey, + metadataUri: string, +): TransactionInstruction { + const uriBytes = Buffer.from(metadataUri, "utf8"); + const uriLen = Buffer.alloc(4); + uriLen.writeUInt32LE(uriBytes.length); + return new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: deriveAgentIdentityPda(owner), isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data: Buffer.concat([anchorDiscriminator("register_agent"), uriLen, uriBytes]), + }); +} + +/** + * Builds the `get_verified` instruction. The authority (the owner, unless transferred) + * signs and pays the one-time verification fee to the SAID treasury. + * + * @param owner - The agent's wallet public key (used for PDA derivation) + * @param authority - The agent identity's authority (signer + fee payer) + * @returns The get_verified instruction + */ +export function buildGetVerifiedInstruction( + owner: PublicKey, + authority: PublicKey, +): TransactionInstruction { + return new TransactionInstruction({ + programId: PROGRAM_ID, + keys: [ + { pubkey: deriveAgentIdentityPda(owner), isSigner: false, isWritable: true }, + { pubkey: deriveTreasuryPda(), isSigner: false, isWritable: true }, + { pubkey: authority, isSigner: true, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data: anchorDiscriminator("get_verified"), + }); +}