diff --git a/typescript/.changeset/agora402-escrow-provider.md b/typescript/.changeset/agora402-escrow-provider.md new file mode 100644 index 000000000..7ea4b8a95 --- /dev/null +++ b/typescript/.changeset/agora402-escrow-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added Agora402 action provider for USDC escrow protection, on-chain trust scores, and dispute resolution on Base diff --git a/typescript/agentkit/src/action-providers/agora402/README.md b/typescript/agentkit/src/action-providers/agora402/README.md new file mode 100644 index 000000000..d6f16c444 --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/README.md @@ -0,0 +1,141 @@ +# Agora402 Action Provider + +This directory contains the **Agora402ActionProvider**, which adds USDC escrow protection, on-chain trust scores, and dispute resolution to any AgentKit agent. + +Agora402 complements the x402 action provider: while x402 handles direct payments, Agora402 adds buyer protection by routing funds through an on-chain escrow contract. Funds are only released when delivery is verified. + +## Directory Structure + +``` +agora402/ +├── agora402ActionProvider.ts # Main provider with 6 escrow actions +├── agora402ActionProvider.test.ts # Unit tests +├── schemas.ts # Zod schemas for action inputs +├── constants.ts # Contract addresses, ABIs, state names +├── index.ts # Main exports +└── README.md # This file +``` + +## Configuration + +```typescript +import { agora402ActionProvider } from "@coinbase/agentkit"; + +// Default: uses deployed contracts on Base mainnet / Base Sepolia +const provider = agora402ActionProvider(); + +// Or with custom contract addresses: +const customProvider = agora402ActionProvider({ + escrowAddress: "0x...", + reputationAddress: "0x...", + usdcAddress: "0x...", +}); +``` + +## Actions + +### Core Escrow Actions + +| Action | Description | +|--------|-------------| +| `agora402_create_escrow` | Lock USDC in escrow for a transaction ($0.10–$100) | +| `agora402_release_escrow` | Confirm delivery, release funds to seller (2% fee) | +| `agora402_dispute_escrow` | Flag bad delivery, lock funds for arbiter review | +| `agora402_check_escrow` | Check escrow state (Funded/Released/Disputed/Expired/...) | +| `agora402_check_trust_score` | On-chain trust score lookup (0–100) before transacting | +| `agora402_protected_api_call` | **Flagship** — escrow + API call + verify + auto-release/dispute | + +### Escrow State Machine + +``` +FUNDED → RELEASED (buyer confirms delivery) + → DISPUTED → RESOLVED (arbiter rules) + → EXPIRED → REFUNDED (auto-refund to buyer) +``` + +### `agora402_create_escrow` + +Creates a USDC escrow. Automatically approves USDC spending if needed. + +```typescript +{ + seller: "0x1234...abcd", // Seller's Ethereum address + amount_usdc: 0.50, // $0.50 USDC (whole units, not wei) + timelock_minutes: 30, // Escrow expires after 30 minutes + service_url: "https://api.example.com/weather" +} +``` + +### `agora402_release_escrow` + +Releases funds to the seller after delivery is confirmed. A 2% protocol fee is deducted. + +```typescript +{ + escrow_id: "0" // Escrow ID from create_escrow +} +``` + +### `agora402_dispute_escrow` + +Flags a problem and locks funds for arbiter review. + +```typescript +{ + escrow_id: "0", + reason: "API returned error 500 instead of valid data" +} +``` + +### `agora402_check_trust_score` + +Looks up on-chain trust score before transacting with an unknown agent. + +```typescript +{ + address: "0x1234...abcd" // Agent address to check +} +// Returns: { score: 85, recommendation: "high_trust", totalEscrows: 42, ... } +``` + +### `agora402_protected_api_call` (Flagship) + +One-shot escrow-protected API call with automatic verification: + +```typescript +{ + url: "https://api.example.com/weather", + method: "GET", + seller_address: "0x1234...abcd", + amount_usdc: 0.10, + timelock_minutes: 30, + verification_schema: '{"type":"object","required":["temperature","location"]}' +} +// If response has temperature + location → auto-release payment +// If response fails schema → auto-dispute, funds locked for review +``` + +## Network Support + +| Network | Chain ID | Status | +|---------|----------|--------| +| Base mainnet | 8453 | Deployed | +| Base Sepolia | 84532 | Deployed | + +## Protocol Details + +- **Token**: USDC only +- **Fee**: 2% on release/resolve, 0% on refund +- **Escrow range**: $0.10 – $100 per escrow +- **Timelock**: 5 minutes – 30 days +- **Trust scores**: 0–100, computed on-chain from escrow history + +## Dependencies + +No additional dependencies — uses only `viem` (already included in AgentKit). + +## Additional Resources + +- [Agora402 GitHub](https://github.com/michu5696/agentBank) +- [Agora402 MCP Server](https://www.npmjs.com/package/agora402) — for non-AgentKit agents +- [x402 Protocol](https://www.x402.org/) diff --git a/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.test.ts b/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.test.ts new file mode 100644 index 000000000..3343b1a47 --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.test.ts @@ -0,0 +1,338 @@ +import { Agora402ActionProvider } from "./agora402ActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Network } from "../../network"; +import { ESCROW_ADDRESSES } from "./constants"; + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe("Agora402ActionProvider", () => { + let provider: Agora402ActionProvider; + let mockWallet: jest.Mocked; + + const BASE_SEPOLIA_NETWORK: Network = { + protocolFamily: "evm", + networkId: "base-sepolia", + chainId: "84532", + }; + + const BASE_MAINNET_NETWORK: Network = { + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }; + + const UNSUPPORTED_NETWORK: Network = { + protocolFamily: "evm", + networkId: "ethereum-mainnet", + chainId: "1", + }; + + const SOLANA_NETWORK: Network = { + protocolFamily: "svm", + networkId: "solana-mainnet", + }; + + beforeEach(() => { + provider = new Agora402ActionProvider(); + mockWallet = { + getAddress: jest.fn().mockReturnValue("0x1234567890abcdef1234567890abcdef12345678"), + getNetwork: jest.fn().mockReturnValue(BASE_SEPOLIA_NETWORK), + sendTransaction: jest.fn().mockResolvedValue("0xmocktxhash"), + waitForTransactionReceipt: jest.fn().mockResolvedValue({ + logs: [ + { + address: ESCROW_ADDRESSES[84532]!, + topics: [ + "0xdaaa07e73a11f25fe84ab8e517c7a63b3fed5bac71421cc8f4e41cfd42581f28", + "0x0000000000000000000000000000000000000000000000000000000000000001", + ], + }, + ], + }), + readContract: jest.fn(), + } as unknown as jest.Mocked; + mockFetch.mockReset(); + }); + + describe("supportsNetwork", () => { + it("should support Base Sepolia", () => { + expect(provider.supportsNetwork(BASE_SEPOLIA_NETWORK)).toBe(true); + }); + + it("should support Base mainnet", () => { + expect(provider.supportsNetwork(BASE_MAINNET_NETWORK)).toBe(true); + }); + + it("should not support Ethereum mainnet", () => { + expect(provider.supportsNetwork(UNSUPPORTED_NETWORK)).toBe(false); + }); + + it("should not support Solana", () => { + expect(provider.supportsNetwork(SOLANA_NETWORK)).toBe(false); + }); + }); + + describe("createEscrow", () => { + it("should create an escrow successfully", async () => { + // Mock allowance check returns 0 (needs approval) + mockWallet.readContract.mockResolvedValueOnce(0n); + + const result = await provider.createEscrow(mockWallet, { + seller: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 1.0, + timelock_minutes: 30, + service_url: "https://api.example.com/data", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.txHash).toBe("0xmocktxhash"); + expect(parsed.amount).toContain("USDC"); + expect(parsed.seller).toBe("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"); + // Should have called sendTransaction twice: approve + createAndFund + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(2); + }); + + it("should skip approval when allowance is sufficient", async () => { + // Mock allowance returns large value + mockWallet.readContract.mockResolvedValueOnce(BigInt(10_000_000)); + + const result = await provider.createEscrow(mockWallet, { + seller: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 1.0, + timelock_minutes: 30, + service_url: "https://api.example.com/data", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.approveTxHash).toBe("already approved"); + // Should have called sendTransaction once: only createAndFund + expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("should return error on failure", async () => { + mockWallet.readContract.mockRejectedValueOnce(new Error("network error")); + + const result = await provider.createEscrow(mockWallet, { + seller: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 1.0, + timelock_minutes: 30, + service_url: "https://api.example.com/data", + }); + + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.details).toContain("network error"); + }); + }); + + describe("releaseEscrow", () => { + it("should release an escrow successfully", async () => { + const result = await provider.releaseEscrow(mockWallet, { + escrow_id: "1", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.action).toBe("released"); + expect(parsed.txHash).toBe("0xmocktxhash"); + }); + }); + + describe("disputeEscrow", () => { + it("should dispute an escrow successfully", async () => { + const result = await provider.disputeEscrow(mockWallet, { + escrow_id: "1", + reason: "API returned error 500", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.action).toBe("disputed"); + expect(parsed.reason).toBe("API returned error 500"); + expect(parsed.txHash).toBe("0xmocktxhash"); + }); + }); + + describe("checkEscrow", () => { + it("should return escrow details", async () => { + mockWallet.readContract.mockResolvedValueOnce([ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + BigInt(1_000_000), // $1.00 USDC + BigInt(1700000000), + BigInt(1700001800), + 1, // Funded state + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]); + + const result = await provider.checkEscrow(mockWallet, { + escrow_id: "0", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.state).toBe("Funded"); + expect(parsed.amount).toContain("USDC"); + }); + }); + + describe("checkTrustScore", () => { + it("should return trust score for agent with history", async () => { + // Mock getScore + mockWallet.readContract.mockResolvedValueOnce(BigInt(85)); + // Mock getReputation + mockWallet.readContract.mockResolvedValueOnce([ + BigInt(10), // totalCompleted + BigInt(1), // totalDisputed + BigInt(0), // totalRefunded + BigInt(5), // totalAsProvider + BigInt(6), // totalAsClient + BigInt(50_000_000), // totalVolume ($50) + BigInt(1700000000), // firstSeen + BigInt(1700001000), // lastSeen + ]); + + const result = await provider.checkTrustScore(mockWallet, { + address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.score).toBe(85); + expect(parsed.recommendation).toBe("high_trust"); + expect(parsed.totalEscrows).toBe(11); + }); + + it("should return low_trust for address with no history", async () => { + // Mock getScore + mockWallet.readContract.mockResolvedValueOnce(BigInt(0)); + // Mock getReputation — all zeros + mockWallet.readContract.mockResolvedValueOnce([ + BigInt(0), + BigInt(0), + BigInt(0), + BigInt(0), + BigInt(0), + BigInt(0), + BigInt(0), + BigInt(0), + ]); + + const result = await provider.checkTrustScore(mockWallet, { + address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.score).toBe(50); + expect(parsed.recommendation).toBe("low_trust"); + }); + }); + + describe("protectedApiCall", () => { + it("should auto-release when response matches schema", async () => { + // Mock allowance — sufficient + mockWallet.readContract.mockResolvedValueOnce(BigInt(10_000_000)); + + // Mock API response + mockFetch.mockResolvedValueOnce({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ data: "hello", status: "ok" })), + }); + + const result = await provider.protectedApiCall(mockWallet, { + url: "https://api.example.com/data", + method: "GET", + seller_address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 0.5, + timelock_minutes: 30, + verification_schema: '{"type":"object","required":["data","status"]}', + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.action).toBe("auto_released"); + expect(parsed.createTxHash).toBe("0xmocktxhash"); + expect(parsed.releaseTxHash).toBe("0xmocktxhash"); + }); + + it("should auto-dispute when response fails schema", async () => { + // Mock allowance — sufficient + mockWallet.readContract.mockResolvedValueOnce(BigInt(10_000_000)); + + // Mock API response — missing required field + mockFetch.mockResolvedValueOnce({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ error: "not found" })), + }); + + const result = await provider.protectedApiCall(mockWallet, { + url: "https://api.example.com/data", + method: "GET", + seller_address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 0.5, + timelock_minutes: 30, + verification_schema: '{"type":"object","required":["data","status"]}', + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(false); + expect(parsed.action).toBe("auto_disputed"); + expect(parsed.disputeTxHash).toBe("0xmocktxhash"); + }); + + it("should auto-dispute when API call fails", async () => { + // Mock allowance — sufficient + mockWallet.readContract.mockResolvedValueOnce(BigInt(10_000_000)); + + // Mock fetch failure + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + const result = await provider.protectedApiCall(mockWallet, { + url: "https://api.example.com/data", + method: "GET", + seller_address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 0.5, + timelock_minutes: 30, + verification_schema: '{"type":"object","required":["data"]}', + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(false); + expect(parsed.action).toBe("auto_disputed"); + expect(parsed.error).toContain("ECONNREFUSED"); + }); + }); + + describe("custom config", () => { + it("should use custom addresses when provided", async () => { + const customProvider = new Agora402ActionProvider({ + escrowAddress: "0xcccccccccccccccccccccccccccccccccccccccc", + reputationAddress: "0xdddddddddddddddddddddddddddddddddddddd", + usdcAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }); + + // Mock allowance check + mockWallet.readContract.mockResolvedValueOnce(BigInt(10_000_000)); + + // The sendTransaction should use the custom escrow address + await customProvider.createEscrow(mockWallet, { + seller: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + amount_usdc: 1.0, + timelock_minutes: 30, + service_url: "test", + }); + + // Verify the allowance check used custom USDC address + expect(mockWallet.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }), + ); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.ts b/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.ts new file mode 100644 index 000000000..4e684e504 --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/agora402ActionProvider.ts @@ -0,0 +1,680 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { + encodeFunctionData, + keccak256, + toBytes, + parseUnits, + formatUnits, + type Address, + type Hex, +} from "viem"; +import { + CreateEscrowSchema, + ReleaseEscrowSchema, + DisputeEscrowSchema, + EscrowStatusSchema, + TrustScoreSchema, + ProtectedCallSchema, +} from "./schemas"; +import { + ESCROW_ABI, + REPUTATION_ABI, + ERC20_ABI, + ESCROW_ADDRESSES, + REPUTATION_ADDRESSES, + USDC_ADDRESSES, + USDC_DECIMALS, + STATE_NAMES, +} from "./constants"; + +/** + * Optional configuration to override default contract addresses. + */ +export interface Agora402Config { + escrowAddress?: Address; + reputationAddress?: Address; + usdcAddress?: Address; +} + +/** + * Agora402ActionProvider adds USDC escrow protection, trust scores, and dispute + * resolution to any AgentKit agent. It complements the x402 action provider by + * adding buyer protection — funds are locked until delivery is verified. + * + * Deployed on Base mainnet and Base Sepolia. + * Protocol: 2% fee on release/resolve, 0% on refund. + * More info: https://github.com/michu5696/agentBank + */ +export class Agora402ActionProvider extends ActionProvider { + private readonly escrowAddr?: Address; + private readonly reputationAddr?: Address; + private readonly usdcAddr?: Address; + + /** + * Creates a new Agora402ActionProvider instance. + * + * @param config - Optional configuration to override default contract addresses + */ + constructor(config?: Agora402Config) { + super("agora402", []); + this.escrowAddr = config?.escrowAddress; + this.reputationAddr = config?.reputationAddress; + this.usdcAddr = config?.usdcAddress; + } + + /** + * Creates a USDC escrow to protect a transaction with a seller. + * + * @param walletProvider - The wallet provider for signing transactions + * @param args - Escrow parameters: seller, amount, timelock, service URL + * @returns JSON with escrow ID, transaction hash, and status + */ + @CreateAction({ + name: "agora402_create_escrow", + description: `Create a USDC escrow to protect an agent-to-agent transaction on Base. +Funds are locked until delivery is confirmed (release) or flagged (dispute). +A 2% protocol fee is deducted on release. If the escrow expires, the buyer gets a full refund. + +Amount is in whole USDC units (e.g., 0.50 for $0.50 USDC). Do not convert to smallest units. +Minimum: $0.10, Maximum: $100.`, + schema: CreateEscrowSchema, + }) + async createEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const escrowAddr = this.getEscrowAddress(chainId); + const amount = this.parseUsdc(args.amount_usdc); + const timelockDuration = BigInt(args.timelock_minutes * 60); + const serviceHash = keccak256(toBytes(args.service_url)); + + const approveTxHash = await this.ensureAllowance(walletProvider, chainId, amount); + + const data = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "createAndFund", + args: [args.seller as Address, amount, timelockDuration, serviceHash], + }); + + const txHash = await walletProvider.sendTransaction({ to: escrowAddr, data }); + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + const escrowCreatedLog = receipt.logs?.find( + (log: { address: string; topics: string[] }) => + log.address.toLowerCase() === escrowAddr.toLowerCase() && + log.topics[0] === + keccak256(toBytes("EscrowCreated(uint256,address,address,uint256,uint256,bytes32)")), + ); + + const escrowId = escrowCreatedLog?.topics[1] ? BigInt(escrowCreatedLog.topics[1]) : 0n; + + return JSON.stringify({ + success: true, + escrowId: escrowId.toString(), + amount: this.formatUsdc(amount), + seller: args.seller, + serviceUrl: args.service_url, + expiresInMinutes: args.timelock_minutes, + approveTxHash: approveTxHash ?? "already approved", + txHash, + message: `Escrow #${escrowId} created. ${this.formatUsdc(amount)} locked. Call agora402_release_escrow when delivery is confirmed, or agora402_dispute_escrow if there is a problem.`, + }); + } catch (error) { + return JSON.stringify({ + error: true, + message: "Failed to create escrow", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Releases escrowed USDC to the seller after confirming delivery. + * + * @param walletProvider - The wallet provider for signing transactions + * @param args - The escrow ID to release + * @returns JSON with release confirmation and transaction hash + */ + @CreateAction({ + name: "agora402_release_escrow", + description: + "Confirm delivery and release escrowed USDC to the seller. Only call this after verifying the service was delivered correctly. A 2% protocol fee is deducted from the release amount.", + schema: ReleaseEscrowSchema, + }) + async releaseEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const escrowAddr = this.getEscrowAddress(chainId); + const escrowId = BigInt(args.escrow_id); + + const data = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "release", + args: [escrowId], + }); + + const txHash = await walletProvider.sendTransaction({ to: escrowAddr, data }); + await walletProvider.waitForTransactionReceipt(txHash); + + return JSON.stringify({ + success: true, + escrowId: args.escrow_id, + action: "released", + txHash, + message: `Escrow #${args.escrow_id} released. Funds sent to seller (minus 2% protocol fee).`, + }); + } catch (error) { + return JSON.stringify({ + error: true, + message: "Failed to release escrow", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Disputes an escrow and locks funds for arbiter review. + * + * @param walletProvider - The wallet provider for signing transactions + * @param args - The escrow ID and reason for dispute + * @returns JSON with dispute confirmation and transaction hash + */ + @CreateAction({ + name: "agora402_dispute_escrow", + description: + "Flag a problem with delivery and lock the escrowed funds for arbiter review. Use this when the service was not delivered, returned errors, or the quality was unacceptable.", + schema: DisputeEscrowSchema, + }) + async disputeEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const escrowAddr = this.getEscrowAddress(chainId); + const escrowId = BigInt(args.escrow_id); + + const data = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "dispute", + args: [escrowId], + }); + + const txHash = await walletProvider.sendTransaction({ to: escrowAddr, data }); + await walletProvider.waitForTransactionReceipt(txHash); + + return JSON.stringify({ + success: true, + escrowId: args.escrow_id, + action: "disputed", + reason: args.reason, + txHash, + message: `Escrow #${args.escrow_id} disputed. Funds locked for arbiter review. Reason: ${args.reason}`, + }); + } catch (error) { + return JSON.stringify({ + error: true, + message: "Failed to dispute escrow", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Checks the current state of an escrow. + * + * @param walletProvider - The wallet provider for reading contract state + * @param args - The escrow ID to check + * @returns JSON with escrow details including state, buyer, seller, amount, and timestamps + */ + @CreateAction({ + name: "agora402_check_escrow", + description: + "Check the current state of an escrow: Funded, Released, Disputed, Resolved, Expired, or Refunded.", + schema: EscrowStatusSchema, + }) + async checkEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const escrowAddr = this.getEscrowAddress(chainId); + const escrowId = BigInt(args.escrow_id); + + const result = (await walletProvider.readContract({ + address: escrowAddr, + abi: ESCROW_ABI, + functionName: "getEscrow", + args: [escrowId], + })) as [Address, Address, bigint, bigint, bigint, number, Hex]; + + const [buyer, seller, amount, createdAt, expiresAt, state] = result; + + return JSON.stringify({ + success: true, + escrowId: args.escrow_id, + state: STATE_NAMES[state] ?? "Unknown", + buyer, + seller, + amount: this.formatUsdc(amount), + createdAt: new Date(Number(createdAt) * 1000).toISOString(), + expiresAt: new Date(Number(expiresAt) * 1000).toISOString(), + }); + } catch (error) { + return JSON.stringify({ + error: true, + message: "Failed to check escrow", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Looks up the on-chain trust score of an agent address. + * + * @param walletProvider - The wallet provider for reading contract state + * @param args - The address to look up + * @returns JSON with trust score, escrow history, and recommendation + */ + @CreateAction({ + name: "agora402_check_trust_score", + description: `Look up the on-chain trust score of an agent address before transacting. +Score is 0-100 based on real escrow history (completed, disputed, refunded). +Check this before sending money to unknown agents. No escrow history returns score 50 with a low_trust recommendation.`, + schema: TrustScoreSchema, + }) + async checkTrustScore( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const repAddr = this.getReputationAddress(chainId); + const agentAddr = args.address as Address; + + const [score, repData] = await Promise.all([ + walletProvider.readContract({ + address: repAddr, + abi: REPUTATION_ABI, + functionName: "getScore", + args: [agentAddr], + }) as Promise, + walletProvider.readContract({ + address: repAddr, + abi: REPUTATION_ABI, + functionName: "getReputation", + args: [agentAddr], + }) as Promise<[bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]>, + ]); + + const [ + totalCompleted, + totalDisputed, + totalRefunded, + totalAsProvider, + totalAsClient, + totalVolume, + ] = repData; + + const totalEscrows = Number(totalCompleted) + Number(totalDisputed) + Number(totalRefunded); + + if (totalEscrows === 0) { + return JSON.stringify({ + success: true, + address: args.address, + score: 50, + totalEscrows: 0, + recommendation: "low_trust", + message: "No on-chain escrow history. New/unknown agent — use small escrow amounts.", + }); + } + + const successRate = ((Number(totalCompleted) / totalEscrows) * 100).toFixed(1); + const s = Number(score); + const recommendation = s >= 80 ? "high_trust" : s >= 50 ? "moderate_trust" : "low_trust"; + + return JSON.stringify({ + success: true, + address: args.address, + score: s, + totalEscrows, + successfulEscrows: Number(totalCompleted), + disputedEscrows: Number(totalDisputed), + refundedEscrows: Number(totalRefunded), + asProvider: Number(totalAsProvider), + asClient: Number(totalAsClient), + totalVolume: this.formatUsdc(totalVolume), + successRate: `${successRate}%`, + recommendation, + }); + } catch (error) { + return JSON.stringify({ + error: true, + message: "Failed to check trust score", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Makes an API call with automatic escrow protection and response verification. + * + * @param walletProvider - The wallet provider for signing transactions + * @param args - API call parameters, seller address, escrow amount, and verification schema + * @returns JSON with API response, escrow outcome (released or disputed), and transaction hashes + */ + @CreateAction({ + name: "agora402_protected_api_call", + description: `Make an API call with automatic USDC escrow protection. This is the flagship Agora402 action. + +Flow: Create escrow → Call API → Verify response against JSON Schema → Auto-release if valid, auto-dispute if not. + +Use this instead of direct x402 payments when you want buyer protection. If the API returns bad data +or fails, funds are automatically disputed instead of lost. + +Amount is in whole USDC units (e.g., 0.50 for $0.50). Do not convert to smallest units.`, + schema: ProtectedCallSchema, + }) + async protectedApiCall( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const chainId = this.getChainId(walletProvider.getNetwork()); + const escrowAddr = this.getEscrowAddress(chainId); + const amount = this.parseUsdc(args.amount_usdc); + const timelockDuration = BigInt(args.timelock_minutes * 60); + const serviceHash = keccak256(toBytes(args.url)); + + // Step 1: Create escrow + await this.ensureAllowance(walletProvider, chainId, amount); + + const createData = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "createAndFund", + args: [args.seller_address as Address, amount, timelockDuration, serviceHash], + }); + + const createTxHash = await walletProvider.sendTransaction({ + to: escrowAddr, + data: createData, + }); + const receipt = await walletProvider.waitForTransactionReceipt(createTxHash); + + const createdLog = receipt.logs?.find( + (log: { address: string; topics: string[] }) => + log.address.toLowerCase() === escrowAddr.toLowerCase() && + log.topics[0] === + keccak256(toBytes("EscrowCreated(uint256,address,address,uint256,uint256,bytes32)")), + ); + const escrowId = createdLog?.topics[1] ? BigInt(createdLog.topics[1]) : 0n; + + // Step 2: Make the API call + let apiResponse: Response; + let responseBody: string; + try { + apiResponse = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.method === "GET" || args.method === "DELETE" ? undefined : args.body, + }); + responseBody = await apiResponse.text(); + } catch (fetchError) { + // API unreachable — auto-dispute + const disputeData = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "dispute", + args: [escrowId], + }); + const disputeTxHash = await walletProvider.sendTransaction({ + to: escrowAddr, + data: disputeData, + }); + await walletProvider.waitForTransactionReceipt(disputeTxHash); + + return JSON.stringify({ + success: false, + escrowId: escrowId.toString(), + error: `API call failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`, + action: "auto_disputed", + createTxHash, + disputeTxHash, + }); + } + + // Step 3: Validate response against JSON Schema + let parsedResponse: unknown; + try { + parsedResponse = JSON.parse(responseBody); + } catch { + parsedResponse = responseBody; + } + + let schema: Record; + try { + schema = JSON.parse(args.verification_schema); + } catch { + schema = {}; + } + + const valid = validateSchema(parsedResponse, schema); + + // Step 4: Auto-release or auto-dispute + if (valid) { + const releaseData = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "release", + args: [escrowId], + }); + const releaseTxHash = await walletProvider.sendTransaction({ + to: escrowAddr, + data: releaseData, + }); + await walletProvider.waitForTransactionReceipt(releaseTxHash); + + return JSON.stringify({ + success: true, + escrowId: escrowId.toString(), + amount: this.formatUsdc(amount), + seller: args.seller_address, + url: args.url, + httpStatus: apiResponse.status, + action: "auto_released", + createTxHash, + releaseTxHash, + response: parsedResponse, + message: `Payment of ${this.formatUsdc(amount)} released to ${args.seller_address}. Response verified against schema.`, + }); + } else { + const disputeData = encodeFunctionData({ + abi: ESCROW_ABI, + functionName: "dispute", + args: [escrowId], + }); + const disputeTxHash = await walletProvider.sendTransaction({ + to: escrowAddr, + data: disputeData, + }); + await walletProvider.waitForTransactionReceipt(disputeTxHash); + + return JSON.stringify({ + success: false, + escrowId: escrowId.toString(), + amount: this.formatUsdc(amount), + seller: args.seller_address, + url: args.url, + httpStatus: apiResponse.status, + action: "auto_disputed", + createTxHash, + disputeTxHash, + response: parsedResponse, + message: `Escrow #${escrowId} auto-disputed. Response failed schema verification.`, + }); + } + } catch (error) { + return JSON.stringify({ + error: true, + message: "Protected API call failed", + details: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Checks if the action provider supports the given network. + * Agora402 is deployed on Base mainnet (8453) and Base Sepolia (84532). + * + * @param network - The network to check support for + * @returns True if the network is Base mainnet or Base Sepolia + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && (network.chainId === "8453" || network.chainId === "84532"); + + /** + * Resolves the escrow contract address for a given chain. + * + * @param chainId - The chain ID to resolve + * @returns The escrow contract address + */ + private getEscrowAddress(chainId: number): Address { + if (this.escrowAddr) return this.escrowAddr; + const addr = ESCROW_ADDRESSES[chainId]; + if (!addr) throw new Error(`No Agora402 escrow contract on chain ${chainId}`); + return addr; + } + + /** + * Resolves the reputation contract address for a given chain. + * + * @param chainId - The chain ID to resolve + * @returns The reputation contract address + */ + private getReputationAddress(chainId: number): Address { + if (this.reputationAddr) return this.reputationAddr; + const addr = REPUTATION_ADDRESSES[chainId]; + if (!addr) throw new Error(`No Agora402 reputation contract on chain ${chainId}`); + return addr; + } + + /** + * Resolves the USDC token address for a given chain. + * + * @param chainId - The chain ID to resolve + * @returns The USDC token address + */ + private getUsdcAddress(chainId: number): Address { + if (this.usdcAddr) return this.usdcAddr; + const addr = USDC_ADDRESSES[chainId]; + if (!addr) throw new Error(`No USDC address for chain ${chainId}`); + return addr; + } + + /** + * Extracts the numeric chain ID from a Network object. + * + * @param network - The network to extract chain ID from + * @returns The numeric chain ID (defaults to Base Sepolia 84532) + */ + private getChainId(network: Network): number { + return Number(network.chainId ?? "84532"); + } + + /** + * Converts a USDC amount in whole units to its smallest unit (6 decimals). + * + * @param amount - The USDC amount in whole units (e.g., 1.50) + * @returns The amount in smallest units as a bigint + */ + private parseUsdc(amount: number): bigint { + return parseUnits(amount.toString(), USDC_DECIMALS); + } + + /** + * Formats a USDC amount from smallest units to a human-readable string. + * + * @param amount - The USDC amount in smallest units + * @returns A formatted string like "$1.50 USDC" + */ + private formatUsdc(amount: bigint): string { + return `$${formatUnits(amount, USDC_DECIMALS)} USDC`; + } + + /** + * Ensures the escrow contract has sufficient USDC allowance. + * Sends an approve transaction if the current allowance is too low. + * + * @param walletProvider - The wallet provider for sending transactions + * @param chainId - The chain ID to operate on + * @param amount - The required USDC allowance in smallest units + * @returns The approve transaction hash, or null if already approved + */ + private async ensureAllowance( + walletProvider: EvmWalletProvider, + chainId: number, + amount: bigint, + ): Promise { + const usdcAddr = this.getUsdcAddress(chainId); + const escrowAddr = this.getEscrowAddress(chainId); + const owner = walletProvider.getAddress() as Address; + + const allowance = (await walletProvider.readContract({ + address: usdcAddr, + abi: ERC20_ABI, + functionName: "allowance", + args: [owner, escrowAddr], + })) as bigint; + + if (allowance < amount) { + const data = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [escrowAddr, amount], + }); + const txHash = await walletProvider.sendTransaction({ to: usdcAddr, data }); + await walletProvider.waitForTransactionReceipt(txHash); + return txHash; + } + + return null; + } +} + +/** + * Minimal JSON Schema validation — checks type and required fields. + * Used to verify API responses before releasing escrow payments. + * + * @param data - The data to validate + * @param schema - The JSON Schema to validate against + * @returns True if the data matches the schema + */ +function validateSchema(data: unknown, schema: Record): boolean { + if (!schema || Object.keys(schema).length === 0) return true; + if (schema.type === "object" && typeof data === "object" && data !== null) { + const required = (schema.required as string[]) ?? []; + const obj = data as Record; + return required.every(key => key in obj); + } + if (schema.type === "array") return Array.isArray(data); + if (schema.type === "string") return typeof data === "string"; + if (schema.type === "number") return typeof data === "number"; + return true; +} + +/** + * Factory function to create an Agora402ActionProvider instance. + * + * @param config - Optional configuration to override default contract addresses + * @returns A new Agora402ActionProvider instance + */ +export const agora402ActionProvider = (config?: Agora402Config) => + new Agora402ActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/agora402/constants.ts b/typescript/agentkit/src/action-providers/agora402/constants.ts new file mode 100644 index 000000000..00663ab9f --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/constants.ts @@ -0,0 +1,126 @@ +import type { Address } from "viem"; + +/** Deployed Agora402Escrow contract addresses on Base. */ +export const ESCROW_ADDRESSES: Record = { + 8453: "0xDcA5E5Dd1E969A4b824adDE41569a5d80A965aDe", + 84532: "0x9Ea8c817bFDfb15FA50a30b08A186Cb213F11BCC", +}; + +/** Deployed Agora402Reputation contract addresses on Base. */ +export const REPUTATION_ADDRESSES: Record = { + 8453: "0x9Ea8c817bFDfb15FA50a30b08A186Cb213F11BCC", + 84532: "0x2A216a829574e88dD632e7C95660d43bCE627CDf", +}; + +/** Circle USDC addresses on Base. */ +export const USDC_ADDRESSES: Record = { + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", +}; + +export const USDC_DECIMALS = 6; + +/** Human-readable escrow state names. */ +export const STATE_NAMES: Record = { + 0: "Created", + 1: "Funded", + 2: "Released", + 3: "Disputed", + 4: "Resolved", + 5: "Expired", + 6: "Refunded", +}; + +/** Minimal ABIs — only the functions we call. */ +export const ESCROW_ABI = [ + { + type: "function", + name: "createAndFund", + inputs: [ + { name: "seller", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "timelockDuration", type: "uint256" }, + { name: "serviceHash", type: "bytes32" }, + ], + outputs: [{ name: "escrowId", type: "uint256" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "release", + inputs: [{ name: "escrowId", type: "uint256" }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "dispute", + inputs: [{ name: "escrowId", type: "uint256" }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "getEscrow", + inputs: [{ name: "escrowId", type: "uint256" }], + outputs: [ + { name: "buyer", type: "address" }, + { name: "seller", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "createdAt", type: "uint256" }, + { name: "expiresAt", type: "uint256" }, + { name: "state", type: "uint8" }, + { name: "serviceHash", type: "bytes32" }, + ], + stateMutability: "view", + }, +] as const; + +export const REPUTATION_ABI = [ + { + type: "function", + name: "getReputation", + inputs: [{ name: "agent", type: "address" }], + outputs: [ + { name: "totalCompleted", type: "uint64" }, + { name: "totalDisputed", type: "uint64" }, + { name: "totalRefunded", type: "uint64" }, + { name: "totalAsProvider", type: "uint64" }, + { name: "totalAsClient", type: "uint64" }, + { name: "totalVolume", type: "uint256" }, + { name: "firstSeen", type: "uint256" }, + { name: "lastSeen", type: "uint256" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "getScore", + inputs: [{ name: "agent", type: "address" }], + outputs: [{ name: "score", type: "uint256" }], + stateMutability: "view", + }, +] as const; + +export const ERC20_ABI = [ + { + type: "function", + name: "approve", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "allowance", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/agora402/index.ts b/typescript/agentkit/src/action-providers/agora402/index.ts new file mode 100644 index 000000000..f6bf3083d --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/index.ts @@ -0,0 +1,2 @@ +export { Agora402ActionProvider, agora402ActionProvider } from "./agora402ActionProvider"; +export type { Agora402Config } from "./agora402ActionProvider"; diff --git a/typescript/agentkit/src/action-providers/agora402/schemas.ts b/typescript/agentkit/src/action-providers/agora402/schemas.ts new file mode 100644 index 000000000..bde899c02 --- /dev/null +++ b/typescript/agentkit/src/action-providers/agora402/schemas.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +const EthAddressRegex = /^0x[a-fA-F0-9]{40}$/; + +export const CreateEscrowSchema = z + .object({ + seller: z + .string() + .regex(EthAddressRegex, "Invalid Ethereum address format") + .describe("Ethereum address of the seller/service provider to receive payment"), + amount_usdc: z + .number() + .positive() + .describe( + "Amount of USDC to lock in escrow, in whole units (e.g., 0.50 for $0.50). " + + "Minimum $0.10, maximum $100. Do not convert to wei.", + ), + timelock_minutes: z + .number() + .int() + .positive() + .default(30) + .describe( + "Minutes until the escrow expires and the buyer can claim a refund. " + + "Default: 30 minutes for API calls. Use longer for tasks (e.g., 1440 for 24 hours).", + ), + service_url: z + .string() + .describe( + "URL or description of the service being purchased. Hashed on-chain as serviceHash.", + ), + }) + .strip() + .describe("Create a USDC escrow to protect a transaction with a seller"); + +export const ReleaseEscrowSchema = z + .object({ + escrow_id: z + .string() + .describe("The numeric escrow ID returned by create_escrow (e.g., '0', '1', '42')"), + }) + .strip() + .describe("Release escrowed USDC to the seller after confirming delivery"); + +export const DisputeEscrowSchema = z + .object({ + escrow_id: z.string().describe("The numeric escrow ID to dispute"), + reason: z + .string() + .describe( + "Brief reason for the dispute (e.g., 'API returned error 500', 'response was empty')", + ), + }) + .strip() + .describe("Dispute an escrow and lock funds for arbiter review"); + +export const EscrowStatusSchema = z + .object({ + escrow_id: z.string().describe("The numeric escrow ID to check"), + }) + .strip() + .describe("Check the current state of an escrow"); + +export const TrustScoreSchema = z + .object({ + address: z + .string() + .regex(EthAddressRegex, "Invalid Ethereum address format") + .describe("Ethereum address to look up the on-chain trust score for"), + }) + .strip() + .describe("Look up the on-chain trust score of an agent address"); + +export const ProtectedCallSchema = z + .object({ + url: z.string().url().describe("URL of the API endpoint to call"), + method: z + .enum(["GET", "POST", "PUT", "DELETE", "PATCH"]) + .default("GET") + .describe("HTTP method for the API call"), + headers: z.record(z.string()).optional().describe("Optional HTTP headers"), + body: z.string().optional().describe("Optional request body for POST/PUT/PATCH requests"), + seller_address: z + .string() + .regex(EthAddressRegex, "Invalid Ethereum address format") + .describe("Ethereum address of the API provider/seller"), + amount_usdc: z + .number() + .positive() + .describe( + "USDC amount to escrow, in whole units (e.g., 0.50 for $0.50). Do not convert to wei.", + ), + timelock_minutes: z + .number() + .int() + .positive() + .default(30) + .describe("Escrow expiry in minutes (default: 30)"), + verification_schema: z + .string() + .describe( + "JSON Schema string to validate the API response against. " + + 'Example: \'{"type":"object","required":["data","status"]}\'. ' + + "If the response matches, payment is auto-released. If not, it is auto-disputed.", + ), + }) + .strip() + .describe("Make an API call with automatic escrow protection and response verification"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index e0eccdeca..6549ec682 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -4,6 +4,7 @@ export * from "./actionProvider"; export * from "./customActionProvider"; export * from "./across"; +export * from "./agora402"; export * from "./alchemy"; export * from "./baseAccount"; export * from "./basename";