diff --git a/typescript/.changeset/rsoft-bank-action-provider.md b/typescript/.changeset/rsoft-bank-action-provider.md new file mode 100644 index 000000000..adc95f10c --- /dev/null +++ b/typescript/.changeset/rsoft-bank-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added RsoftBankActionProvider — AI-native USDC lending for autonomous agents on Base mainnet via RSoft Agentic Bank: check interest rates and creditworthiness, vet counterparties with AgentTrust-8004 trust scores, request loans (EIP-712 LoanRequest signed natively with the agent's wallet provider), and confirm on-chain repayments that build portable ERC-8004 credit reputation. diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..c2b6491bd 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -20,6 +20,7 @@ export * from "./farcaster"; export * from "./jupiter"; export * from "./messari"; export * from "./pyth"; +export * from "./rsoftBank"; export * from "./moonwell"; export * from "./morpho"; export * from "./opensea"; diff --git a/typescript/agentkit/src/action-providers/rsoftBank/README.md b/typescript/agentkit/src/action-providers/rsoftBank/README.md new file mode 100644 index 000000000..28d52d583 --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/README.md @@ -0,0 +1,55 @@ +# RSoft Bank Action Provider + +This directory contains the **RsoftBankActionProvider** implementation, which +provides actions to interact with [RSoft Agentic Bank](https://rsoft-agentic-bank.com/) — +an AI-native USDC lending service for autonomous agents, live on **Base mainnet**. + +Agents can check their creditworthiness, vet counterparties with on-chain trust +scores, request real USDC loans signed with their own wallet, and confirm +repayments — building a portable **ERC-8004 on-chain credit reputation** with +every repaid loan. + +## Directory Structure + +``` +rsoftBank/ +├── rsoftBankActionProvider.ts # Main provider with bank actions +├── rsoftBankActionProvider.test.ts # Tests +├── constants.ts # URLs and EIP-712 domain/types +├── schemas.ts # Action schemas +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +- `get_interest_rates`: Current USDC lending rates and terms by risk tier (free, no key) +- `get_creditworthiness`: Credit score, loan history and outstanding debt of an agent (free) +- `get_trust_score`: AgentTrust-8004 trust score (0-100) of any agent wallet, with anomaly flag (free) +- `request_loan`: Request a real USDC loan — signs the bank's EIP-712 `LoanRequest` + struct with the agent's own wallet via `walletProvider.signTypedData` and submits + it. Requires a bank API key (fail-closed without one) +- `get_repayment_info`: Amount owed, treasury address, USDC contract and request id (free) +- `confirm_repayment`: Report the on-chain USDC transfer hash so the bank verifies + it on Base and marks the loan repaid + +## Setup + +```typescript +import { rsoftBankActionProvider } from "@coinbase/agentkit"; + +const provider = rsoftBankActionProvider({ + apiKey: process.env.RSOFT_BANK_API_KEY, // required only for request_loan +}); +``` + +## Notes + +- **Network support**: Base mainnet only (`base-mainnet` / chain id 8453). +- **Real money**: loans are real USDC with on-chain consequences; defaults are + recorded against the agent's reputation. Loans start at the $5 floor and grow + along a credit ladder with each successful repayment. +- The bank never sees a private key: the wallet signs, the provider transports. +- Repayment uses the standard `erc20` transfer action; this provider then + confirms the tx hash with the bank. +- Docs: [rsoft-agentic-bank.com/docs](https://rsoft-agentic-bank.com/docs) diff --git a/typescript/agentkit/src/action-providers/rsoftBank/constants.ts b/typescript/agentkit/src/action-providers/rsoftBank/constants.ts new file mode 100644 index 000000000..ce2886c02 --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/constants.ts @@ -0,0 +1,20 @@ +export const DEFAULT_BASE_URL = "https://rsoft-agentic-bank.com/api/v1"; +export const DEFAULT_TRUST_URL = + "https://7pdor5bjoty7gyat56u6fgcrue0gbvnd.lambda-url.us-east-1.on.aws"; + +// EIP-712 domain + struct — MUST match the bank's verifier exactly. +export const CHAIN_ID = 8453; +export const VERIFYING_CONTRACT = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432"; +export const EIP712_DOMAIN_NAME = "RSoft Agentic Bank"; +export const EIP712_DOMAIN_VERSION = "1"; +export const LOAN_REQUEST_TYPES = { + LoanRequest: [ + { name: "agentWallet", type: "address" }, + { name: "loanAmountUsdc6", type: "uint256" }, + { name: "nonce", type: "string" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +/** Seconds a signed loan request stays valid. */ +export const SIGNATURE_TTL_SECONDS = 900; diff --git a/typescript/agentkit/src/action-providers/rsoftBank/index.ts b/typescript/agentkit/src/action-providers/rsoftBank/index.ts new file mode 100644 index 000000000..6de9d2329 --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/index.ts @@ -0,0 +1,2 @@ +export * from "./rsoftBankActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.test.ts b/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.test.ts new file mode 100644 index 000000000..edc3c73fd --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.test.ts @@ -0,0 +1,163 @@ +import { rsoftBankActionProvider, RsoftBankActionProvider } from "./rsoftBankActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Network } from "../../network"; + +// The @CreateAction decorator fires an unawaited analytics fetch on every +// invocation; neutralize it so mocked fetch state can't leak across tests. +jest.mock("../../analytics", () => ({ sendAnalyticsEvent: jest.fn() })); + +const AGENT_WALLET = "0x4CFfda19125efA278475d61Ff28F37EAd2c05bef"; +const BASE_MAINNET: Network = { + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", +}; + +describe("RsoftBankActionProvider", () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + let mockWallet: jest.Mocked; + let provider: RsoftBankActionProvider; + + beforeEach(() => { + jest.resetAllMocks(); + mockWallet = { + getAddress: jest.fn().mockReturnValue(AGENT_WALLET), + getName: jest.fn().mockReturnValue("mock_wallet_provider"), + getNetwork: jest.fn().mockReturnValue(BASE_MAINNET), + signTypedData: jest.fn().mockResolvedValue("0xsigned"), + } as unknown as jest.Mocked; + provider = rsoftBankActionProvider({ apiKey: "test-key" }); + }); + + describe("supportsNetwork", () => { + it("supports Base mainnet", () => { + expect(provider.supportsNetwork(BASE_MAINNET)).toBe(true); + }); + + it("rejects other EVM networks", () => { + expect( + provider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + chainId: "1", + }), + ).toBe(false); + }); + + it("rejects non-EVM networks", () => { + expect(provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" })).toBe( + false, + ); + }); + }); + + describe("getInterestRates", () => { + it("returns the bank's rate table", async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('{"tiers":[]}'), + }); + const result = await provider.getInterestRates(mockWallet); + expect(result).toBe('{"tiers":[]}'); + expect(fetchMock).toHaveBeenCalledWith( + "https://rsoft-agentic-bank.com/api/v1/interest-rates", + ); + }); + + it("prefixes API errors with the status", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + text: jest.fn().mockResolvedValue("down"), + }); + const result = await provider.getInterestRates(mockWallet); + expect(result).toContain("Bank API error 503"); + }); + }); + + describe("getCreditworthiness", () => { + it("defaults to the agent's own wallet", async () => { + fetchMock.mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue("{}") }); + await provider.getCreditworthiness(mockWallet, {}); + expect(fetchMock).toHaveBeenCalledWith( + `https://rsoft-agentic-bank.com/api/v1/agents/${AGENT_WALLET}/creditworthiness`, + ); + }); + }); + + describe("requestLoan", () => { + it("is fail-closed without an API key", async () => { + const keyless = rsoftBankActionProvider(); + const result = await keyless.requestLoan(mockWallet, { amount: 5 }); + expect(result).toContain("API key not configured"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockWallet.signTypedData).not.toHaveBeenCalled(); + }); + + it("signs the EIP-712 LoanRequest with the agent's wallet and submits it", async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('{"request_id":"req_1"}'), + }); + + const result = await provider.requestLoan(mockWallet, { amount: 5 }); + + expect(mockWallet.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + domain: expect.objectContaining({ + name: "RSoft Agentic Bank", + chainId: 8453, + }), + primaryType: "LoanRequest", + message: expect.objectContaining({ + agentWallet: AGENT_WALLET, + loanAmountUsdc6: BigInt(5_000_000), + }), + }), + ); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://rsoft-agentic-bank.com/api/v1/loan/request"); + expect(init.headers["X-API-Key"]).toBe("test-key"); + const body = JSON.parse(init.body); + expect(body).toMatchObject({ + agent_wallet: AGENT_WALLET, + loan_amount: 5, + signature: "0xsigned", + }); + expect(result).toBe('{"request_id":"req_1"}'); + }); + }); + + describe("confirmRepayment", () => { + it("posts the request id and tx hash", async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('{"status":"repaid"}'), + }); + const txHash = "0x" + "ab".repeat(32); + const result = await provider.confirmRepayment(mockWallet, { + requestId: "req_1", + txHash, + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://rsoft-agentic-bank.com/api/v1/loan/repay"); + expect(JSON.parse(init.body)).toEqual({ request_id: "req_1", tx_hash: txHash }); + expect(result).toBe('{"status":"repaid"}'); + }); + }); + + describe("getTrustScore", () => { + it("queries the trust API", async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('{"score":72}'), + }); + const result = await provider.getTrustScore(mockWallet, { wallet: AGENT_WALLET }); + expect(result).toBe('{"score":72}'); + expect(fetchMock.mock.calls[0][0]).toContain(`/score/${AGENT_WALLET}`); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.ts b/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.ts new file mode 100644 index 000000000..364e541f9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/rsoftBankActionProvider.ts @@ -0,0 +1,283 @@ +import { randomBytes } from "crypto"; +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { + CHAIN_ID, + DEFAULT_BASE_URL, + DEFAULT_TRUST_URL, + EIP712_DOMAIN_NAME, + EIP712_DOMAIN_VERSION, + LOAN_REQUEST_TYPES, + SIGNATURE_TTL_SECONDS, + VERIFYING_CONTRACT, +} from "./constants"; +import { + ConfirmRepaymentSchema, + RequestLoanSchema, + TrustScoreSchema, + WalletArgSchema, +} from "./schemas"; + +/** + * Configuration for {@link RsoftBankActionProvider}. + */ +export interface RsoftBankActionProviderConfig { + /** + * Bank API key — required for request_loan (money POSTs are fail-closed). + * Read actions work without it. + */ + apiKey?: string; + /** Override the bank API base URL (default: production). */ + baseUrl?: string; + /** Override the RSoft Trust API base URL (default: production). */ + trustApiUrl?: string; +} + +/** + * RsoftBankActionProvider gives an agent the full RSoft Agentic Bank credit + * cycle on Base mainnet: check rates and credit history, vet counterparties + * with AgentTrust-8004 trust scores, request real USDC loans — signing the + * bank's EIP-712 LoanRequest struct natively with the agent's own wallet + * provider — and confirm repayments. The bank never sees a private key: the + * wallet signs, the provider transports. + */ +export class RsoftBankActionProvider extends ActionProvider { + private readonly baseUrl: string; + private readonly trustUrl: string; + private readonly apiKey?: string; + + /** + * Constructor for the RsoftBankActionProvider class. + * + * @param config - Optional configuration (API key and URL overrides). + */ + constructor(config: RsoftBankActionProviderConfig = {}) { + super("rsoft-bank", []); + this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + this.trustUrl = (config.trustApiUrl ?? DEFAULT_TRUST_URL).replace(/\/$/, ""); + this.apiKey = config.apiKey; + } + + /** + * Gets RSoft Bank's current USDC lending rates and terms by risk tier. + * + * @param _walletProvider - The wallet provider (unused). + * @returns A JSON string with the bank's rate table. + */ + @CreateAction({ + name: "get_interest_rates", + description: + "Get RSoft Bank's current USDC lending rates and terms on Base mainnet, by risk tier (AAA to D). Use before requesting a loan.", + schema: z.object({}), + }) + async getInterestRates(_walletProvider: EvmWalletProvider): Promise { + return this.httpGet("/interest-rates"); + } + + /** + * Gets the credit score, loan history and outstanding debt of an agent. + * + * @param walletProvider - The wallet provider (used for the default wallet). + * @param args - Optional wallet address override. + * @returns A JSON string with the agent's credit profile. + */ + @CreateAction({ + name: "get_creditworthiness", + description: + "Credit score, loan history and outstanding debt of an agent at RSoft Bank. Defaults to the agent's own wallet. Use to know what the credit ladder will allow before borrowing.", + schema: WalletArgSchema, + }) + async getCreditworthiness( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const wallet = args.wallet ?? walletProvider.getAddress(); + return this.httpGet(`/agents/${wallet}/creditworthiness`); + } + + /** + * Gets the AgentTrust-8004 on-chain trust score of any agent wallet. + * + * @param _walletProvider - The wallet provider (unused). + * @param args - The wallet address to score. + * @returns A JSON string with the trust score and anomaly flag. + */ + @CreateAction({ + name: "get_trust_score", + description: + "On-chain trust score (0-100) of ANY agent wallet, from AgentTrust-8004 (model trained on the real ERC-8004 Base mainnet census). Includes an anomaly flag for incoherent profiles like reputation farming. Use to vet a counterparty before trading, lending or collaborating.", + schema: TrustScoreSchema, + }) + async getTrustScore( + _walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const res = await fetch(`${this.trustUrl}/score/${args.wallet}`); + const text = await res.text(); + if (!res.ok) return `Trust API error ${res.status}: ${text.slice(0, 400)}`; + return text; + } + + /** + * Requests a USDC loan, signing the bank's EIP-712 LoanRequest struct with + * the agent's own wallet. + * + * @param walletProvider - The wallet provider that signs the request. + * @param args - The loan amount in USDC. + * @returns A JSON string with the loan request outcome. + */ + @CreateAction({ + name: "request_loan", + description: + "Request a real USDC loan from RSoft Bank on Base mainnet. Signs the bank's EIP-712 LoanRequest struct with the agent's own wallet (the bank never originates unsigned loans) and submits it. On approval the bank disburses USDC to this wallet. Requires the provider to be configured with a bank API key.", + schema: RequestLoanSchema, + }) + async requestLoan( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + if (!this.apiKey) { + return ( + "RSoft Bank API key not configured. Loan origination is fail-closed: " + + "construct rsoftBankActionProvider({ apiKey }) with a key issued by the bank." + ); + } + const agentWallet = walletProvider.getAddress(); + const nonce = "agentkit-" + randomBytes(8).toString("hex"); + const deadline = Math.floor(Date.now() / 1000) + SIGNATURE_TTL_SECONDS; + + const signature = await walletProvider.signTypedData({ + domain: { + name: EIP712_DOMAIN_NAME, + version: EIP712_DOMAIN_VERSION, + chainId: CHAIN_ID, + verifyingContract: VERIFYING_CONTRACT, + }, + types: LOAN_REQUEST_TYPES, + primaryType: "LoanRequest", + message: { + agentWallet, + loanAmountUsdc6: BigInt(Math.round(args.amount * 1e6)), + nonce, + deadline: BigInt(deadline), + }, + }); + + return this.httpPost("/loan/request", { + agent_wallet: agentWallet, + loan_amount: args.amount, + nonce, + deadline, + signature, + }); + } + + /** + * Gets what the agent owes, the treasury address to pay, and the request id. + * + * @param walletProvider - The wallet provider (used for the default wallet). + * @param args - Optional wallet address override. + * @returns A JSON string with the repayment details. + */ + @CreateAction({ + name: "get_repayment_info", + description: + "What the agent owes RSoft Bank (principal + interest), the treasury address to pay, the USDC contract and the request_id needed to confirm. Use before repaying.", + schema: WalletArgSchema, + }) + async getRepaymentInfo( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const wallet = args.wallet ?? walletProvider.getAddress(); + return this.httpGet(`/loan/repay-info/${wallet}`); + } + + /** + * Reports an on-chain USDC repayment so the bank verifies it on Base and + * marks the loan repaid. + * + * @param _walletProvider - The wallet provider (unused). + * @param args - The loan request id and the transfer tx hash. + * @returns A JSON string with the confirmation outcome. + */ + @CreateAction({ + name: "confirm_repayment", + description: + "After transferring the exact USDC amount on-chain to the bank treasury (use the erc20 transfer action with the details from get_repayment_info), report the tx hash so the bank verifies it on Base and marks the loan repaid — which raises the agent's credit ladder.", + schema: ConfirmRepaymentSchema, + }) + async confirmRepayment( + _walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + return this.httpPost("/loan/repay", { + request_id: args.requestId, + tx_hash: args.txHash, + }); + } + + /** + * Checks if the provider supports the given network. The bank lends on Base + * mainnet only. + * + * @param network - The network to check. + * @returns True for Base mainnet, false otherwise. + */ + supportsNetwork = (network: Network): boolean => + network.protocolFamily === "evm" && + (network.chainId === String(CHAIN_ID) || network.networkId === "base-mainnet"); + + /** + * Performs a GET request against the bank API. + * + * @param path - The API path. + * @returns The response body, or a formatted error string. + */ + private async httpGet(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`); + return this.render(res); + } + + /** + * Performs a POST request against the bank API. + * + * @param path - The API path. + * @param body - The JSON body to send. + * @returns The response body, or a formatted error string. + */ + private async httpPost(path: string, body: unknown): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (this.apiKey) headers["X-API-Key"] = this.apiKey; + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return this.render(res); + } + + /** + * Renders a fetch response as a string, prefixing errors with the status. + * + * @param res - The fetch response. + * @returns The response text, or a formatted error string. + */ + private async render(res: Response): Promise { + const text = await res.text(); + if (!res.ok) return `Bank API error ${res.status}: ${text.slice(0, 400)}`; + return text; + } +} + +/** + * Factory for {@link RsoftBankActionProvider}. + * + * @param config - Optional configuration (API key and URL overrides). + * @returns A new RsoftBankActionProvider instance. + */ +export const rsoftBankActionProvider = (config: RsoftBankActionProviderConfig = {}) => + new RsoftBankActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/rsoftBank/schemas.ts b/typescript/agentkit/src/action-providers/rsoftBank/schemas.ts new file mode 100644 index 000000000..7afddf976 --- /dev/null +++ b/typescript/agentkit/src/action-providers/rsoftBank/schemas.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +/** Optional wallet argument — defaults to the agent's own wallet. */ +export const WalletArgSchema = z.object({ + wallet: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .optional() + .describe("EVM wallet address to query; defaults to the agent's own wallet"), +}); + +/** Input schema for requesting a USDC loan. */ +export const RequestLoanSchema = z.object({ + amount: z + .number() + .positive() + .describe( + "Loan amount in USDC. New agents start at the $5 floor and unlock larger loans by repaying (credit ladder).", + ), +}); + +/** Input schema for confirming an on-chain repayment. */ +export const ConfirmRepaymentSchema = z.object({ + requestId: z.string().describe("Loan request id (req_...) being repaid"), + txHash: z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/) + .describe("Hash of the on-chain USDC transfer to the bank treasury"), +}); + +/** Input schema for querying an agent's trust score. */ +export const TrustScoreSchema = z.object({ + wallet: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .describe("EVM wallet address of the agent to score"), +});