diff --git a/typescript/.changeset/brainchain-action-provider.md b/typescript/.changeset/brainchain-action-provider.md new file mode 100644 index 000000000..9986c7392 --- /dev/null +++ b/typescript/.changeset/brainchain-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added a BrainChain action provider: `post_task` pays 1 USDC on Base over x402 for a task that a frontier model does and Jev grades, with work that is not accepted refunded, and `get_task` reads the order until the result arrives. Spending is capped per task and in total. diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..12fe75e49 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -204,6 +204,19 @@ const agent = createAgent({
+BrainChain + + + + + + + + + +
post_taskPays 1 USDC over x402 for a task that a frontier model does and Jev grades; work that is not accepted is refunded.
get_taskReads a BrainChain order: its status, whether the work was accepted, and the result text once it was.
+
+
Clanker diff --git a/typescript/agentkit/src/action-providers/brainchain/README.md b/typescript/agentkit/src/action-providers/brainchain/README.md new file mode 100644 index 000000000..1af52f188 --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/README.md @@ -0,0 +1,47 @@ +# BrainChain Action Provider + +This provider lets an agent hand a self-contained task to a frontier model on [BrainChain](https://usebrainchain.com) and pay only for work that is accepted. + +## Overview + +Jev, a disclosed decision model from TypeSafe, hires one of Claude, GPT, Gemini, Grok or DeepSeek for the brief, grades the result against quality levels the agent writes, and pays the model only if the work reaches the second level or better. The agent pays 1 USDC on Base over [x402](https://www.x402.org/) from its own wallet. If the work is not accepted, or no model delivers within 24 hours, the full amount is refunded to that wallet. + +Briefs and accepted results are public. Never post credentials, private code or personal data. + +## Setup + +```typescript +import { AgentKit, brainchainActionProvider } from "@coinbase/agentkit"; + +const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [brainchainActionProvider({ maxPriceUsdc: 1, budgetUsdc: 10 })], +}); +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `maxPriceUsdc` | `1` | Most the provider pays for one task | +| `budgetUsdc` | `10` | Most the provider pays in total while it exists | + +The wallet needs USDC on Base. Payments are signatures, so no ETH and no approval are needed. On Base Sepolia the provider pays BrainChain's testnet desk in test USDC. + +## Actions + +| Action | Description | +| --- | --- | +| `post_task` | Pays for a task with a `title`, a `brief` and two or three quality `levels`, and returns an order id | +| `get_task` | Reads an order until `done`: its status, Jev's grade, the result text once accepted, or the refund | + +A `post_task` reply with `possiblyCharged` means the payment was signed but the desk's reply never arrived. Read the order with `get_task` instead of posting again. + +## Network Support + +- Base mainnet: USDC, BrainChain mainnet +- Base Sepolia: test USDC, BrainChain testnet + +## Links + +- [How BrainChain works](https://usebrainchain.com/how-it-works) +- [Every hire and grade](https://usebrainchain.com/decisions) +- [Paying from an agent](https://docs.usebrainchain.com/agents) diff --git a/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.test.ts b/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.test.ts new file mode 100644 index 000000000..b1b66e08f --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.test.ts @@ -0,0 +1,319 @@ +import { keccak256, toBytes } from "viem"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { BrainChainActionProvider } from "./brainchainActionProvider"; +import { BRAINCHAIN_NETWORKS } from "./constants"; +import { EvmWalletProvider } from "../../wallet-providers"; + +jest.mock("@x402/fetch"); +jest.mock("@x402/evm/exact/client"); + +type Requirement = { + network: string; + asset: string; + payTo: string; + amount: string; + maxTimeoutSeconds: number; +}; +type Hooks = { + policies: ((version: number, reqs: Requirement[]) => Requirement[])[]; + before?: (ctx: { + selectedRequirements: Requirement; + }) => Promise; + after?: (ctx: { paymentPayload: { payload: unknown } }) => Promise; + failure?: () => Promise; +}; + +const MAINNET = BRAINCHAIN_NETWORKS["base-mainnet"]; +const PAYLOAD = { + payload: { authorization: { nonce: "0x01", value: "1000000" }, signature: "0xab" }, +}; +const ORDER = `0x${"cd".repeat(32)}`; +const TASK = { + title: "Summarise", + brief: "Summarise the text below.", + levels: ["Complete", "Mostly complete"], +}; + +const deskRequirement = (overrides: Partial = {}): Requirement => ({ + network: MAINNET.network, + asset: MAINNET.usdc, + payTo: MAINNET.payTo, + amount: "1000000", + maxTimeoutSeconds: 300, + ...overrides, +}); + +let hooks: Hooks; +let deskReply: () => Promise; + +/** + * Stands in for the x402 client: applies the registered policies and hooks the + * way a real payment would, then returns the desk's reply. + */ +function mockPayingFetch() { + jest.mocked(x402Client).mockImplementation(() => { + hooks = { policies: [] }; + const client = { + registerPolicy: (p: Hooks["policies"][number]) => (hooks.policies.push(p), client), + onBeforePaymentCreation: (h: Hooks["before"]) => ((hooks.before = h), client), + onAfterPaymentCreation: (h: Hooks["after"]) => ((hooks.after = h), client), + onPaymentCreationFailure: (h: Hooks["failure"]) => ((hooks.failure = h), client), + }; + return client as unknown as x402Client; + }); + jest.mocked(wrapFetchWithPayment).mockImplementation( + () => + (async () => { + const offered = [deskRequirement()]; + const [selected] = hooks.policies.reduce((reqs, p) => p(2, reqs), offered); + if (!selected) throw new Error("No payment option matched"); + const verdict = await hooks.before?.({ selectedRequirements: selected }); + if (verdict && "abort" in verdict) { + await hooks.failure?.(); + throw new Error(verdict.reason); + } + await hooks.after?.({ paymentPayload: PAYLOAD }); + return deskReply(); + }) as unknown as typeof fetch, + ); +} + +describe("BrainChainActionProvider", () => { + let wallet: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockPayingFetch(); + wallet = { + getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }), + toSigner: jest + .fn() + .mockReturnValue({ address: "0x0000000000000000000000000000000000000001" }), + readContract: jest.fn(), + } as unknown as jest.Mocked; + deskReply = async () => Response.json({ order: ORDER, state: "posting" }); + }); + + describe("supportsNetwork", () => { + it("supports Base mainnet and Base Sepolia only", () => { + const provider = new BrainChainActionProvider(); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe( + true, + ); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" })).toBe( + true, + ); + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }), + ).toBe(false); + expect(provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" })).toBe( + false, + ); + }); + }); + + it("refuses limits that are not positive", () => { + expect(() => new BrainChainActionProvider({ budgetUsdc: 0 })).toThrow(/positive/); + }); + + describe("postTask", () => { + it("pays the desk and returns the order", async () => { + const result = JSON.parse(await new BrainChainActionProvider().postTask(wallet, TASK)); + expect(result).toMatchObject({ + order: ORDER, + price: "1 USDC", + statusUrl: `${MAINNET.desk}/x402/tasks/${ORDER}`, + }); + expect(registerExactEvmScheme).toHaveBeenCalled(); + }); + + it("pays only BrainChain's desk, in USDC, within the per-task cap", async () => { + await new BrainChainActionProvider({ maxPriceUsdc: 1 }).postTask(wallet, TASK); + const offers = [ + deskRequirement({ payTo: "0x0000000000000000000000000000000000000bad" }), + deskRequirement({ asset: "0x0000000000000000000000000000000000000bad" }), + deskRequirement({ amount: "2000000" }), + deskRequirement({ network: "eip155:1" }), + deskRequirement({ maxTimeoutSeconds: 3600 }), + deskRequirement(), + ]; + const kept = hooks.policies.reduce((reqs, p) => p(2, reqs), offers); + expect(kept).toEqual([deskRequirement()]); + }); + + it("stops before signing once the budget is spent", async () => { + const provider = new BrainChainActionProvider({ budgetUsdc: 1 }); + expect(JSON.parse(await provider.postTask(wallet, TASK)).order).toBe(ORDER); + const second = JSON.parse(await provider.postTask(wallet, TASK)); + expect(second.error).toMatch(/budget of 1 USDC would be exceeded.*Nothing was charged/); + }); + + it("names the order from the signed payment when the desk never answers", async () => { + deskReply = async () => { + throw new Error("socket hang up"); + }; + const result = JSON.parse(await new BrainChainActionProvider().postTask(wallet, TASK)); + expect(result.possiblyCharged).toBe(true); + expect(result.order).toBe( + keccak256( + toBytes('{"authorization":{"nonce":"0x01","value":"1000000"},"signature":"0xab"}'), + ), + ); + }); + + it("treats a refused brief as uncharged and frees the budget", async () => { + const provider = new BrainChainActionProvider({ budgetUsdc: 1 }); + deskReply = async () => Response.json({ error: "The brief is empty." }, { status: 400 }); + expect(JSON.parse(await provider.postTask(wallet, TASK)).error).toBe( + "The brief is empty. Nothing was charged.", + ); + deskReply = async () => Response.json({ order: ORDER }); + expect(JSON.parse(await provider.postTask(wallet, TASK)).order).toBe(ORDER); + }); + + it("reports a server error after signing as possibly charged", async () => { + deskReply = async () => new Response("bad gateway", { status: 502 }); + const result = JSON.parse(await new BrainChainActionProvider().postTask(wallet, TASK)); + expect(result.possiblyCharged).toBe(true); + expect(result.next).toMatch(/do not post again/); + }); + + it("explains a refused payment by the wallet's USDC balance", async () => { + deskReply = async () => Response.json({}, { status: 402 }); + wallet.readContract.mockResolvedValue(500_000n as never); + const provider = new BrainChainActionProvider({ budgetUsdc: 1 }); + const short = JSON.parse(await provider.postTask(wallet, TASK)); + expect(short.error).toBe( + "This wallet (0x0000000000000000000000000000000000000001) holds 0.5 USDC on Base, and a task costs 1 USDC. Add USDC to it and post again. Nothing was charged.", + ); + + wallet.readContract.mockResolvedValue(5_000_000n as never); + const required = Buffer.from( + JSON.stringify({ x402Version: 2, error: "invalid_signature" }), + ).toString("base64"); + deskReply = async () => + new Response("{}", { status: 402, headers: { "payment-required": required } }); + expect(JSON.parse(await provider.postTask(wallet, TASK)).error).toBe( + "The desk refused the payment (invalid_signature). Nothing was charged.", + ); + }); + + it("refuses networks BrainChain does not take payment on", async () => { + wallet.getNetwork.mockReturnValue({ protocolFamily: "evm", networkId: "ethereum-mainnet" }); + const result = JSON.parse(await new BrainChainActionProvider().postTask(wallet, TASK)); + expect(result.error).toMatch(/Base mainnet and Base Sepolia/); + expect(wrapFetchWithPayment).not.toHaveBeenCalled(); + }); + }); + + describe("getTask", () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it("returns the accepted result text", async () => { + const result = `${MAINNET.desk}/records/0x${"ee".repeat(32)}.txt`; + fetchMock + .mockResolvedValueOnce( + Response.json({ + order: ORDER, + state: "paid-out", + task: { status: "paid", accepted: true }, + result, + }), + ) + .mockResolvedValueOnce(new Response("the summary")); + const view = JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `${MAINNET.desk}/x402/tasks/${ORDER}`, + expect.anything(), + ); + expect(view).toMatchObject({ resultText: "the summary", done: true }); + }); + + it("never fetches a result link that is not the desk's own", async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ + order: ORDER, + state: "paid-out", + result: "https://elsewhere.example/x.txt", + }), + ); + const view = JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(view.resultText).toBeUndefined(); + }); + + it("finishes on a refund and waits while the work is in progress", async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ order: ORDER, state: "refunded", refundTx: "0xfeed" }), + ); + expect( + JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ), + ).toMatchObject({ + done: true, + next: expect.stringContaining("0xfeed"), + }); + fetchMock.mockResolvedValueOnce( + Response.json({ order: ORDER, state: "posted", task: { status: "hired" } }), + ); + expect( + JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ), + ).toMatchObject({ + done: false, + retryAfterSeconds: 60, + }); + }); + + it("keeps waiting while a dispute, a ruling or an appeal can still change a rejected grade", async () => { + for (const status of ["disputed", "ruled", "appealed"]) { + fetchMock.mockResolvedValueOnce( + Response.json({ order: ORDER, state: "posted", task: { status, accepted: false } }), + ); + expect( + JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ), + ).toMatchObject({ done: false }); + } + }); + + it("waits for the order to finish before answering", async () => { + jest.useFakeTimers(); + fetchMock + .mockResolvedValueOnce( + Response.json({ order: ORDER, state: "posted", task: { status: "delivered" } }), + ) + .mockResolvedValueOnce(Response.json({ order: ORDER, state: "refunded" })); + const pending = new BrainChainActionProvider().getTask(wallet, { + order: ORDER, + waitSeconds: 30, + }); + await jest.advanceTimersByTimeAsync(10_000); + expect(JSON.parse(await pending)).toMatchObject({ state: "refunded", done: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it("asks to read again when the desk does not know the order yet", async () => { + fetchMock.mockResolvedValueOnce(Response.json({ error: "Not found." }, { status: 404 })); + const view = JSON.parse( + await new BrainChainActionProvider().getTask(wallet, { order: ORDER, waitSeconds: 0 }), + ); + expect(view).toMatchObject({ done: false, retryAfterSeconds: 60 }); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.ts b/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.ts new file mode 100644 index 000000000..22871058a --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/brainchainActionProvider.ts @@ -0,0 +1,494 @@ +import { z } from "zod"; +import { erc20Abi, keccak256, toBytes } from "viem"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Network } from "../../network"; +import { GetTaskSchema, PostTaskSchema } from "./schemas"; +import { + BRAINCHAIN_NETWORKS, + BrainChainNetworkId, + MAX_TIMEOUT_SECONDS, + RESULT_LIMIT, + USDC_UNITS, +} from "./constants"; + +/** How long get_task waits for an order by default, and how often it reads it meanwhile. */ +const DEFAULT_WAIT_SECONDS = 45; +const POLL_SECONDS = 10; + +/** + * Configuration for the BrainChain action provider. + */ +export interface BrainChainConfig { + /** Most this provider pays for one task, in USDC. Defaults to 1. */ + maxPriceUsdc?: number; + /** Most this provider pays in total while it exists, in USDC. Defaults to 10. */ + budgetUsdc?: number; +} + +type Desk = (typeof BRAINCHAIN_NETWORKS)[BrainChainNetworkId]; + +const sameAddress = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +const usdc = (units: bigint) => `${Number(units) / USDC_UNITS} USDC`; + +/** + * The desk names an order after the hash of the signed payment, with keys sorted, + * so an order can be found even when the desk's reply never arrived. + * + * @param value - The signed payment's payload + * @returns Canonical JSON of the payload + */ +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const source = value as Record; + return `{${Object.keys(source) + .filter(key => source[key] !== undefined) + .sort() + .map(key => `${JSON.stringify(key)}:${canonical(source[key])}`) + .join(",")}}`; +} + +/** + * Turns an unknown error into one line. + * + * @param error - The error + * @returns Its message + */ +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * What an order's state means and when to read it again. + * + * @param view - The order as the desk serves it + * @param desk - The desk the order was paid on + * @returns Whether the order is finished, what to do next, and when to read it again + */ +function guidance( + view: Record, + desk: Desk, +): { done: boolean; next: string; retryAfterSeconds?: number } { + const task = (view.task ?? {}) as { status?: string; accepted?: boolean; due?: { by?: unknown } }; + const wait = (retryAfterSeconds: number, next: string) => ({ + done: false, + next, + retryAfterSeconds, + }); + switch (view.state) { + case "paid-out": + return { + done: true, + next: + typeof view.resultText === "string" + ? "Jev accepted the work and the model was paid; the result is in resultText." + : "Jev accepted the work and the model was paid.", + }; + case "refunded": + return { + done: true, + next: `The work was not accepted and the full amount was refunded${typeof view.refundTx === "string" ? ` in ${view.refundTx}` : ""}.`, + }; + case "refund-failed": + return { + done: true, + next: "The refund could not be sent automatically. Write to hello@usebrainchain.com with this order id.", + }; + case "failed": + return { + done: true, + next: "The payment did not settle: nothing was charged and no task was posted.", + }; + case "paying": + case "paid": + case "posting": + return wait(20, "The payment is settling and the task is being posted."); + case "unconfirmed": + return wait( + 30, + "The payment may have gone through and is being checked against the chain. Do not post the task again.", + ); + case "refunding": + return wait(60, "The work was not accepted; the refund is being sent."); + } + // A dispute, a ruling still open to appeal, or an appeal can each change the grade, so none of them is final. + const by = typeof task.due?.by === "string" ? task.due.by : undefined; + switch (task.status) { + case "disputed": + return wait( + 600, + `The grade is disputed; Jev rules on it${by ? ` by ${by}` : ""}. If no ruling comes, the grade stands.`, + ); + case "ruled": + return wait( + 900, + `Jev ruled on the dispute. The ruling settles when its appeal window closes${by ? ` at ${by}` : ""}, unless the side it went against appeals.`, + ); + case "appealed": + return wait( + 600, + `The ruling was appealed; Jev rules again${by ? ` by ${by}` : ""}. If no second ruling comes, the first one stands.`, + ); + } + if (task.accepted === true) { + if (typeof view.resultText !== "string") { + return wait(60, "Jev accepted the work; the result is being prepared."); + } + return { + done: true, + next: `Jev accepted the work; the result is in resultText. The model is paid when the dispute window closes, ${desk.finalAfter} after grading.`, + }; + } + if (task.accepted === false) { + return { + done: true, + next: `Jev did not accept the work, so the result is withheld. The full amount is refunded when the dispute window closes, ${desk.finalAfter} after grading.`, + }; + } + switch (task.status) { + case "open": + return wait(60, "Waiting for Jev to hire a model."); + case "hired": + return wait(60, "A model is working on the task."); + case "delivered": + case "submitted": + return wait(30, "The model delivered; Jev is grading the work."); + } + return wait(60, "The order is in progress."); +} + +/** + * BrainChainActionProvider lets an agent hand a task to a frontier model on + * BrainChain and pay only for accepted work. Jev, a disclosed decision model, + * hires one of Claude, GPT, Gemini, Grok or DeepSeek, grades the result against + * the agent's quality levels, and pays the model only if the work passes. The + * agent pays 1 USDC on Base over x402 from its own wallet; work that is not + * accepted is refunded to that wallet. + */ +export class BrainChainActionProvider extends ActionProvider { + private readonly maxPrice: bigint; + private readonly budget: bigint; + private spent = 0n; + + /** + * Creates a new BrainChainActionProvider. + * + * @param config - Spending limits per task and in total + */ + constructor(config: BrainChainConfig = {}) { + super("brainchain", []); + const maxPrice = config.maxPriceUsdc ?? 1; + const budget = config.budgetUsdc ?? 10; + if (!(maxPrice > 0) || !(budget > 0)) { + throw new Error("maxPriceUsdc and budgetUsdc must be positive numbers"); + } + this.maxPrice = BigInt(Math.round(maxPrice * USDC_UNITS)); + this.budget = BigInt(Math.round(budget * USDC_UNITS)); + } + + /** + * Pays for a task on BrainChain. + * + * @param walletProvider - The wallet that pays + * @param args - The title, brief and quality levels + * @returns JSON with the order id, the price paid and where to read the order + */ + @CreateAction({ + name: "post_task", + description: `Pay for a task on BrainChain. Jev, a decision model from TypeSafe, hires a frontier model (Claude, GPT, Gemini, Grok or DeepSeek) for the brief, grades the result against your quality levels, and pays the model only if the work reaches the second level or better. + +Costs 1 USDC on Base, paid from this wallet over x402. If the work is not accepted, or no model delivers within 24 hours, the full amount is refunded to this wallet. + +Briefs and accepted results are public: never include credentials, private code or personal data. + +Inputs: +- title: one line naming the deliverable (up to 120 characters) +- brief: everything needed to do the work alone (up to 32,000 characters) +- levels: two or three quality levels, best first, each checkable from the result (up to 200 characters each) + +Returns an order id; read it with get_task until done. If the reply says possiblyCharged, do not post again: read the order.`, + schema: PostTaskSchema, + }) + async postTask( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const desk = this.deskFor(walletProvider.getNetwork()); + if (!desk) { + return JSON.stringify({ + error: + "BrainChain takes payment on Base mainnet and Base Sepolia only. Nothing was charged.", + }); + } + const endpoint = `${desk.desk}/x402/tasks`; + let signed: { payload: unknown } | undefined; + let amount = 0n; + + const client = new x402Client(); + const account = walletProvider.toSigner(); + registerExactEvmScheme(client, { + signer: { + ...account, + readContract: (call: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: call.address, + abi: call.abi as never, + functionName: call.functionName as never, + args: call.args as never, + }), + }, + }); + // Pay only BrainChain's desk, in USDC, within the per-task cap. + client.registerPolicy((_version, requirements) => + requirements.filter( + r => + r.network === desk.network && + sameAddress(r.asset, desk.usdc) && + sameAddress(r.payTo, desk.payTo) && + BigInt(r.amount) <= this.maxPrice && + r.maxTimeoutSeconds <= MAX_TIMEOUT_SECONDS, + ), + ); + // Reserved before signing, so parallel posts cannot overshoot the budget. + client.onBeforePaymentCreation(async ({ selectedRequirements }) => { + const price = BigInt(selectedRequirements.amount); + if (this.spent + price > this.budget) { + return { + abort: true, + reason: `This provider's budget of ${usdc(this.budget)} would be exceeded`, + }; + } + this.spent += price; + amount = price; + }); + client.onAfterPaymentCreation(async ({ paymentPayload }) => { + signed = paymentPayload; + }); + client.onPaymentCreationFailure(async () => { + if (signed) return; + this.spent -= amount; + amount = 0n; + }); + + const receipt = (order: string) => ({ + order, + price: usdc(amount), + statusUrl: `${endpoint}/${order}`, + }); + const orderOf = (payment: { payload: unknown }) => + keccak256(toBytes(canonical(payment.payload))); + + let response: Response; + try { + response = await wrapFetchWithPayment(fetch, client)(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args), + signal: AbortSignal.timeout(45_000), + }); + } catch (error) { + if (signed) { + return JSON.stringify({ + ...receipt(orderOf(signed)), + possiblyCharged: true, + next: `The payment was signed but the desk did not confirm the order (${message(error)}). Read the order with get_task; do not post again.`, + }); + } + this.spent -= amount; + return JSON.stringify({ error: `${message(error)}. Nothing was charged.` }); + } + + const reply = ((await response.json().catch(() => undefined)) ?? {}) as Record; + if (response.ok && signed) { + const order = typeof reply.order === "string" ? reply.order : orderOf(signed); + return JSON.stringify({ + ...receipt(order), + ...(typeof reply.state === "string" ? { state: reply.state } : {}), + next: "Read the order with get_task, which waits for progress; results usually arrive within minutes.", + }); + } + // The desk refuses a brief (400) or a payment (402) before settling; any + // other answer to a signed payment may have charged it. + if (signed && response.status !== 400 && response.status !== 402) { + const order = typeof reply.order === "string" ? reply.order : orderOf(signed); + return JSON.stringify({ + ...receipt(order), + possiblyCharged: true, + next: `The desk answered ${response.status}. Read the order with get_task; do not post again.`, + }); + } + this.spent -= amount; + const reason = + response.status === 402 + ? await this.refusal(walletProvider, desk, amount, response) + : typeof reply.error === "string" + ? reply.error + : `The desk answered ${response.status}.`; + return JSON.stringify({ error: `${reason} Nothing was charged.` }); + } + + /** + * Reads an order from post_task. + * + * @param walletProvider - The wallet whose network names the desk + * @param args - The order id + * @returns JSON with the order's state, Jev's grade, the result once accepted, and what to do next + */ + @CreateAction({ + name: "get_task", + description: `Read a BrainChain order from post_task: the payment, the task's status, whether Jev accepted the work, the result text once it did, and the refund if it did not. + +Inputs: +- order: the order id returned by post_task +- waitSeconds: optional, how long to wait for the order to finish before answering (default 45) + +Waits for progress before answering. Call again until done is true.`, + schema: GetTaskSchema, + }) + async getTask( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const desk = this.deskFor(walletProvider.getNetwork()); + if (!desk) { + return JSON.stringify({ + error: "BrainChain orders are read on Base mainnet and Base Sepolia only.", + }); + } + // A model cannot pause between calls, so the action waits for progress itself. + const deadline = Date.now() + (args.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000; + for (;;) { + const view = await this.readOrder(desk, args.order); + if (view.done || Date.now() + POLL_SECONDS * 1000 > deadline) return JSON.stringify(view); + await new Promise(resolve => setTimeout(resolve, POLL_SECONDS * 1000)); + } + } + + /** + * Checks if the action provider supports the given network. + * + * @param network - The network to check + * @returns True on Base mainnet and Base Sepolia + */ + supportsNetwork = (network: Network) => this.deskFor(network) !== undefined; + + /** + * Reads an order once. + * + * @param desk - The desk the order was paid on + * @param order - The order id + * @returns The order with what its state means and when to read it again + */ + private async readOrder(desk: Desk, order: string): Promise> { + let response: Response; + try { + response = await fetch(`${desk.desk}/x402/tasks/${order}`, { + signal: AbortSignal.timeout(20_000), + }); + } catch (error) { + return { + order, + error: `Could not reach the desk (${message(error)})`, + done: false, + retryAfterSeconds: 30, + }; + } + if (response.status === 404) { + return { + order, + error: "The desk has no order with this id", + done: false, + next: "If post_task said possiblyCharged, the payment may still be settling: read the order again in a minute.", + retryAfterSeconds: 60, + }; + } + if (!response.ok) { + return { + order, + error: `The desk answered ${response.status}`, + done: false, + retryAfterSeconds: 60, + }; + } + const view = (await response.json()) as Record; + // Only results served by the desk itself are fetched; anything else stays a link. + if (typeof view.result === "string" && view.result.startsWith(`${desk.desk}/records/`)) { + try { + const text = await fetch(view.result, { signal: AbortSignal.timeout(20_000) }); + if (text.ok) { + const full = await text.text(); + view.resultText = + full.length <= RESULT_LIMIT + ? full + : `${full.slice(0, RESULT_LIMIT)}\n\n[Cut at ${RESULT_LIMIT} of ${full.length} characters; the full result is at ${view.result}]`; + } + } catch { + view.resultError = `The result could not be fetched; it is at ${view.result}`; + } + } + return { ...view, ...guidance(view, desk) }; + } + + /** + * Explains why the desk refused a signed payment. + * + * @param walletProvider - The wallet that signed the payment + * @param desk - The desk that refused it + * @param price - What the payment was for, in USDC base units + * @param response - The desk's 402 answer + * @returns A short balance when the wallet's USDC explains the refusal, else the desk's reason + */ + private async refusal( + walletProvider: EvmWalletProvider, + desk: Desk, + price: bigint, + response: Response, + ): Promise { + const { address } = walletProvider.toSigner(); + const balance = await walletProvider + .readContract({ + address: desk.usdc, + abi: erc20Abi, + functionName: "balanceOf", + args: [address], + }) + .catch(() => undefined); + if (typeof balance === "bigint" && balance < price) { + return `This wallet (${address}) holds ${usdc(balance)} on ${desk.chain}, and a task costs ${usdc(price)}. Add USDC to it and post again.`; + } + const required = response.headers.get("payment-required"); + let error: unknown; + try { + error = required + ? JSON.parse(Buffer.from(required, "base64").toString("utf8")).error + : undefined; + } catch { + error = undefined; + } + return `The desk refused the payment${typeof error === "string" ? ` (${error})` : ""}.`; + } + + /** + * The desk that takes payment on a network. + * + * @param network - The wallet's network + * @returns The desk, or undefined where BrainChain takes no payment + */ + private deskFor(network: Network): Desk | undefined { + if (network.protocolFamily !== "evm" || !network.networkId) return undefined; + return BRAINCHAIN_NETWORKS[network.networkId as BrainChainNetworkId]; + } +} + +export const brainchainActionProvider = (config?: BrainChainConfig) => + new BrainChainActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/brainchain/constants.ts b/typescript/agentkit/src/action-providers/brainchain/constants.ts new file mode 100644 index 000000000..443264a51 --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/constants.ts @@ -0,0 +1,34 @@ +/** + * Where each supported network pays for BrainChain tasks: the payment desk, + * the USDC contract and the address the desk takes payment to. Base Sepolia + * pays the BrainChain testnet desk in test USDC. + */ +export const BRAINCHAIN_NETWORKS = { + "base-mainnet": { + desk: "https://brain.usebrainchain.com", + network: "eip155:8453", + usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E", + chain: "Base", + finalAfter: "about six hours", + }, + "base-sepolia": { + desk: "https://brain.testnet.usebrainchain.com", + network: "eip155:84532", + usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1", + chain: "Base Sepolia", + finalAfter: "about an hour", + }, +} as const; + +export type BrainChainNetworkId = keyof typeof BRAINCHAIN_NETWORKS; + +/** USDC has 6 decimals, so 1 USDC is 1,000,000 base units. */ +export const USDC_UNITS = 1_000_000; + +/** Longest a result is returned inline; the rest stays at its public link. */ +export const RESULT_LIMIT = 20_000; + +/** The desk refuses payment authorisations that stay valid longer than this. */ +export const MAX_TIMEOUT_SECONDS = 600; diff --git a/typescript/agentkit/src/action-providers/brainchain/index.ts b/typescript/agentkit/src/action-providers/brainchain/index.ts new file mode 100644 index 000000000..86cee3f36 --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/index.ts @@ -0,0 +1,2 @@ +export * from "./brainchainActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/brainchain/schemas.ts b/typescript/agentkit/src/action-providers/brainchain/schemas.ts new file mode 100644 index 000000000..a316c5ff7 --- /dev/null +++ b/typescript/agentkit/src/action-providers/brainchain/schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const PostTaskSchema = z + .object({ + title: z.string().min(1).max(120).describe("One line naming the deliverable"), + brief: z + .string() + .min(1) + .max(32_000) + .describe( + "Everything a model needs to do the work alone: the goal, every input, the output format. Briefs are public, so never include credentials or private code.", + ), + levels: z + .array(z.string().min(1).max(200)) + .min(2) + .max(3) + .describe( + "Two or three quality levels, best first, each checkable from the result. The work is paid only if it reaches the second level or better.", + ), + }) + .strip() + .describe("Pay for a task on BrainChain and get an order id"); + +export const GetTaskSchema = z + .object({ + order: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/, "An order id is 0x followed by 64 hex characters") + .describe("The order id returned by post_task"), + waitSeconds: z + .number() + .int() + .min(0) + .max(120) + .optional() + .describe( + "How long to wait for the order to finish before answering, in seconds (default 45, 0 to read once)", + ), + }) + .strip() + .describe("Read a BrainChain order: status, grade and result"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..5c6403a56 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -7,6 +7,7 @@ export * from "./across"; export * from "./alchemy"; export * from "./baseAccount"; export * from "./basename"; +export * from "./brainchain"; export * from "./cdp"; export * from "./clanker"; export * from "./compound";