diff --git a/typescript/agentkit/src/action-providers/bolyra/README.md b/typescript/agentkit/src/action-providers/bolyra/README.md new file mode 100644 index 000000000..8b73a0838 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bolyra/README.md @@ -0,0 +1,61 @@ +# Bolyra Action Provider + +ZKP-based agent identity verification and scoped delegation for AgentKit. + +## What It Does + +Gives AgentKit agents the ability to prove their identity and permissions using zero-knowledge proofs. Complements ERC-8004 (on-chain registry identity) with off-chain portable identity that works across any network. + +## Actions + +| Action | Description | +|--------|-------------| +| `create_identity_proof` | Generate a ZKP credential proving this agent's identity and permissions | +| `verify_identity_proof` | Verify another agent's proof envelope and check required permissions | + +## Setup + +```bash +npm install @bolyra/sdk +``` + +## Usage + +```typescript +import { AgentKit } from "@coinbase/agentkit"; +import { bolyraActionProvider } from "@coinbase/agentkit/action-providers/bolyra"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [ + bolyraActionProvider({ operatorSecret: parseInt(process.env.OPERATOR_SECRET!) }), + ], +}); +``` + +## Permissions + +The 8-bit cumulative permission model: + +| Permission | Bit | Scope | +|-----------|-----|-------| +| `read_data` | 0 | Read access | +| `write_data` | 1 | Write access | +| `financial_small` | 2 | Spend < $100 | +| `financial_medium` | 3 | Spend < $10K (implies financial_small) | +| `financial_unlimited` | 4 | Unlimited (implies financial_medium + small) | +| `sign_on_behalf` | 5 | Sign as the operator | +| `sub_delegate` | 6 | Delegate to other agents | +| `access_pii` | 7 | Access personal data | + +Delegation can only narrow permissions, never expand. Enforced by the ZKP circuit. + +## Network Support + +All networks. Bolyra proofs are off-chain ZKP, network-agnostic. + +## Links + +- [@bolyra/sdk on npm](https://www.npmjs.com/package/@bolyra/sdk) +- [Bolyra GitHub](https://github.com/bolyra/bolyra) +- [Live demo](https://bolyra.ai/playground) diff --git a/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.test.ts b/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.test.ts new file mode 100644 index 000000000..daa81d57f --- /dev/null +++ b/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.test.ts @@ -0,0 +1,144 @@ +import { BolyraActionProvider } from "./bolyraActionProvider"; + +// Mock @bolyra/sdk +jest.mock("@bolyra/sdk", () => ({ + createHumanIdentity: jest.fn(() => ({ commitment: "0x1234" })), + createAgentCredential: jest.fn(() => ({ credential: "mock" })), + proveHandshake: jest.fn(() => ({ + humanProof: { pi_a: ["1", "2"], pi_b: [["3", "4"], ["5", "6"]], pi_c: ["7", "8"] }, + agentProof: { pi_a: ["9", "10"], pi_b: [["11", "12"], ["13", "14"]], pi_c: ["15", "16"] }, + sessionNonce: "12345", + })), + verifyHandshake: jest.fn(() => ({ + verified: true, + agentNullifier: "0xabcd", + humanNullifier: "0xef01", + scopeCommitment: "0x5678", + provenPermissions: ["read_data", "financial_small"], + })), + serializeEnvelope: jest.fn(() => '{"version":"1.0","humanProof":{},"agentProof":{},"sessionNonce":"12345"}'), + deserializeEnvelope: jest.fn((s: string) => JSON.parse(s)), +})); + +const mockWalletProvider = { + getAddress: jest.fn().mockResolvedValue("0x1234567890abcdef1234567890abcdef12345678"), + getNetwork: jest.fn().mockReturnValue({ networkId: "base-sepolia" }), +} as any; + +describe("BolyraActionProvider", () => { + let provider: BolyraActionProvider; + + beforeEach(() => { + provider = new BolyraActionProvider({ operatorSecret: BigInt(42) }); + jest.clearAllMocks(); + }); + + describe("constructor", () => { + it("should throw when operatorSecret is missing", () => { + expect(() => new BolyraActionProvider({} as any)).toThrow("requires operatorSecret"); + }); + + it("should accept bigint secret", () => { + const p = new BolyraActionProvider({ operatorSecret: BigInt(99) }); + expect(p).toBeDefined(); + }); + + it("should accept hex string secret", () => { + const p = new BolyraActionProvider({ operatorSecret: "0xff" }); + expect(p).toBeDefined(); + }); + }); + + describe("supportsNetwork", () => { + it("should support all networks (off-chain ZKP)", () => { + expect(provider.supportsNetwork({ networkId: "base-sepolia" })).toBe(true); + expect(provider.supportsNetwork({ networkId: "ethereum-mainnet" })).toBe(true); + expect(provider.supportsNetwork({})).toBe(true); + }); + }); + + describe("createIdentityProof", () => { + it("should create a proof with valid permissions", async () => { + const result = await provider.createIdentityProof(mockWalletProvider, { + permissions: ["read_data", "financial_small"], + expirySeconds: 3600, + }); + + expect(result).toContain("Identity proof created"); + expect(result).toContain("read_data, financial_small"); + expect(result).toContain("Expires:"); + }); + + it("should reject invalid permissions", async () => { + const result = await provider.createIdentityProof(mockWalletProvider, { + permissions: ["invalid_perm"], + expirySeconds: 3600, + }); + + expect(result).toContain("Invalid permissions"); + }); + }); + + describe("verifyIdentityProof", () => { + it("should verify a valid proof envelope", async () => { + const envelope = '{"version":"1.0","humanProof":{},"agentProof":{},"sessionNonce":"12345"}'; + const result = await provider.verifyIdentityProof(mockWalletProvider, { + proofEnvelope: envelope, + }); + + expect(result).toContain("Verification PASSED"); + expect(result).toContain("Agent nullifier"); + }); + + it("should report failure for invalid proof", async () => { + const sdk = require("@bolyra/sdk"); + sdk.verifyHandshake.mockReturnValueOnce({ verified: false }); + + const result = await provider.verifyIdentityProof(mockWalletProvider, { + proofEnvelope: '{"version":"1.0","humanProof":{},"agentProof":{},"sessionNonce":"bad"}', + }); + + expect(result).toContain("Verification FAILED"); + }); + + it("should pass when required permissions are present", async () => { + const envelope = '{"version":"1.0","humanProof":{},"agentProof":{},"sessionNonce":"12345"}'; + const result = await provider.verifyIdentityProof(mockWalletProvider, { + proofEnvelope: envelope, + requiredPermissions: ["read_data"], + }); + + expect(result).toContain("Verification PASSED"); + expect(result).toContain("Required permissions verified"); + }); + + it("should fail when required permissions are missing", async () => { + const envelope = '{"version":"1.0","humanProof":{},"agentProof":{},"sessionNonce":"12345"}'; + const result = await provider.verifyIdentityProof(mockWalletProvider, { + proofEnvelope: envelope, + requiredPermissions: ["financial_unlimited"], + }); + + expect(result).toContain("INSUFFICIENT PERMISSIONS"); + expect(result).toContain("Missing: financial_unlimited"); + }); + }); + + describe("SDK not installed", () => { + it("should return error when @bolyra/sdk is missing", async () => { + jest.resetModules(); + jest.mock("@bolyra/sdk", () => { + throw new Error("Cannot find module '@bolyra/sdk'"); + }); + const { BolyraActionProvider: FreshProvider } = require("./bolyraActionProvider"); + const p = new FreshProvider({ operatorSecret: BigInt(42) }); + + const result = await p.createIdentityProof(mockWalletProvider, { + permissions: ["read_data"], + expirySeconds: 3600, + }); + + expect(result).toContain("@bolyra/sdk is required"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.ts b/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.ts new file mode 100644 index 000000000..e91339dcb --- /dev/null +++ b/typescript/agentkit/src/action-providers/bolyra/bolyraActionProvider.ts @@ -0,0 +1,218 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { WalletProvider } from "../../wallet-providers"; +import { + CreateIdentityProofSchema, + VerifyIdentityProofSchema, +} from "./schemas"; + +/** + * Valid permission strings for the 8-bit cumulative bitmask. + * Higher tiers imply lower: financial_medium implies financial_small. + */ +const VALID_PERMISSIONS = [ + "read_data", + "write_data", + "financial_small", + "financial_medium", + "financial_unlimited", + "sign_on_behalf", + "sub_delegate", + "access_pii", +] as const; + +/** + * Configuration options for the Bolyra Action Provider. + */ +export interface BolyraActionProviderConfig { + /** + * Operator secret for credential issuance. Must be a non-zero bigint or + * hex string. Load from a secure store (env var, HSM, secrets manager). + * The provider throws at construction time if this is missing. + */ + operatorSecret: bigint | string; +} + +/** + * BolyraActionProvider gives AgentKit agents the ability to create and verify + * ZKP-based identity proofs with scoped permissions. + * + * Actions: + * - create_identity_proof: Generate a credential proving this agent's identity and permissions + * - verify_identity_proof: Verify another agent's proof envelope + * + * Complements the ERC-8004 provider: ERC-8004 handles on-chain registry identity, + * Bolyra handles off-chain ZKP portable identity with privacy-preserving proofs. + * + * Uses WalletProvider (generic, not EVM-specific) because Bolyra proofs are + * off-chain ZKP and network-agnostic. + * + * Requires: @bolyra/sdk (npm install @bolyra/sdk) + */ +export class BolyraActionProvider extends ActionProvider { + private operatorSecret: bigint; + + constructor(config: BolyraActionProviderConfig) { + super("bolyra", []); + if (!config?.operatorSecret) { + throw new Error( + "BolyraActionProvider requires operatorSecret. " + + "Load from env: bolyraActionProvider({ operatorSecret: BigInt(process.env.OPERATOR_SECRET!) })", + ); + } + this.operatorSecret = + typeof config.operatorSecret === "string" + ? BigInt(config.operatorSecret) + : BigInt(config.operatorSecret); + } + + /** + * Lazily load @bolyra/sdk to keep it an optional peer dependency. + */ + private async getSDK() { + try { + return await import("@bolyra/sdk"); + } catch { + throw new Error( + "@bolyra/sdk is required for Bolyra identity actions. Install with: npm install @bolyra/sdk", + ); + } + } + + /** + * Validate that all permission strings are recognized. + */ + private validatePermissions(permissions: string[]): void { + const invalid = permissions.filter( + p => !VALID_PERMISSIONS.includes(p as (typeof VALID_PERMISSIONS)[number]), + ); + if (invalid.length > 0) { + throw new Error( + `Invalid permissions: ${invalid.join(", ")}. Valid: ${VALID_PERMISSIONS.join(", ")}`, + ); + } + } + + @CreateAction({ + name: "create_identity_proof", + description: `Creates a ZKP identity proof for this agent with scoped permissions. +The proof demonstrates that this agent holds a valid operator-signed credential +with specific permissions, without revealing the operator's secret. + +Permissions use an 8-bit cumulative encoding: +- read_data, write_data: data access +- financial_small (<$100), financial_medium (<$10K), financial_unlimited: spending authority +- sign_on_behalf, sub_delegate, access_pii: elevated privileges + +Higher tiers imply lower: financial_medium automatically includes financial_small.`, + schema: CreateIdentityProofSchema, + }) + async createIdentityProof( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + this.validatePermissions(args.permissions); + + try { + const sdk = await this.getSDK(); + const address = await walletProvider.getAddress(); + + const human = sdk.createHumanIdentity(this.operatorSecret); + const expiry = Math.floor(Date.now() / 1000) + args.expirySeconds; + + const agent = sdk.createAgentCredential( + BigInt("0x" + address.slice(2, 18)), + this.operatorSecret, + args.permissions, + expiry, + ); + + const handshake = sdk.proveHandshake(human, agent); + const envelope = sdk.serializeEnvelope(handshake); + + return [ + `Identity proof created for agent ${address}.`, + `Permissions: ${args.permissions.join(", ")}`, + `Expires: ${new Date(expiry * 1000).toISOString()}`, + `Proof envelope (${envelope.length} bytes) ready for verification.`, + `Envelope: ${envelope}`, + ].join("\n"); + } catch (error) { + return `Failed to create identity proof: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: "verify_identity_proof", + description: `Verifies another agent's Bolyra identity proof envelope. +Checks that the proof is cryptographically valid, not expired, and optionally +that the agent holds specific required permissions. + +The proof envelope format is application/vnd.bolyra.proof+json.`, + schema: VerifyIdentityProofSchema, + }) + async verifyIdentityProof( + _walletProvider: WalletProvider, + args: z.infer, + ): Promise { + try { + const sdk = await this.getSDK(); + const envelope = sdk.deserializeEnvelope(args.proofEnvelope); + const result = sdk.verifyHandshake( + envelope.humanProof, + envelope.agentProof, + envelope.sessionNonce, + ); + + if (!result.verified) { + return "Verification FAILED: proof is invalid or expired."; + } + + const lines = [ + "Verification PASSED.", + `Agent nullifier: ${result.agentNullifier}`, + `Human nullifier: ${result.humanNullifier}`, + ]; + + if (args.requiredPermissions && args.requiredPermissions.length > 0) { + // Decode the scope commitment to extract proven permissions and check + // that all required permissions are present. + const provenPermissions = result.provenPermissions ?? []; + const missing = args.requiredPermissions.filter( + p => !provenPermissions.includes(p), + ); + if (missing.length > 0) { + return [ + "Verification PASSED (proof is valid) but INSUFFICIENT PERMISSIONS.", + `Required: ${args.requiredPermissions.join(", ")}`, + `Proven: ${provenPermissions.join(", ") || "(none decoded)"}`, + `Missing: ${missing.join(", ")}`, + ].join("\n"); + } + lines.push(`Required permissions verified: ${args.requiredPermissions.join(", ")}`); + } + + return lines.join("\n"); + } catch (error) { + return `Verification error: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Check if this provider supports the given network. + * Bolyra proofs are network-agnostic (off-chain ZKP), so all networks are supported. + */ + supportsNetwork(_network: unknown): boolean { + return true; + } +} + +/** + * Factory function for the Bolyra action provider. + * + * @param config - Optional configuration + * @returns A new BolyraActionProvider instance + */ +export const bolyraActionProvider = (config: BolyraActionProviderConfig) => + new BolyraActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/bolyra/index.ts b/typescript/agentkit/src/action-providers/bolyra/index.ts new file mode 100644 index 000000000..3477421b2 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bolyra/index.ts @@ -0,0 +1,9 @@ +export { + BolyraActionProvider, + bolyraActionProvider, +} from "./bolyraActionProvider"; +export type { BolyraActionProviderConfig } from "./bolyraActionProvider"; +export { + CreateIdentityProofSchema, + VerifyIdentityProofSchema, +} from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/bolyra/schemas.ts b/typescript/agentkit/src/action-providers/bolyra/schemas.ts new file mode 100644 index 000000000..02ded3001 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bolyra/schemas.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +/** + * Input schema for creating an identity proof. + */ +export const CreateIdentityProofSchema = z + .object({ + permissions: z + .array(z.string()) + .min(1) + .describe( + "Permissions to include in the credential (e.g., 'read_data', 'financial_small', 'write_data')", + ), + expirySeconds: z + .number() + .int() + .positive() + .default(3600) + .describe("How long the credential is valid, in seconds (default: 3600)"), + }) + .strip() + .describe("Creates a ZKP identity proof for this agent with scoped permissions."); + +/** + * Input schema for verifying another agent's identity proof. + */ +export const VerifyIdentityProofSchema = z + .object({ + proofEnvelope: z + .string() + .min(1) + .describe( + "The proof envelope JSON string (application/vnd.bolyra.proof+json) from another agent", + ), + requiredPermissions: z + .array(z.string()) + .optional() + .describe( + "Permissions the other agent must hold for verification to pass (optional — omit to just check validity)", + ), + }) + .strip() + .describe("Verifies another agent's Bolyra identity proof envelope."); + +/** + * Input schema for delegating capabilities to a sub-agent. + */ +export const DelegateCapabilitySchema = z + .object({ + delegateeId: z + .string() + .min(1) + .describe("Identifier (address or DID) of the agent receiving delegated permissions"), + permissions: z + .array(z.string()) + .min(1) + .describe( + "Permissions to delegate — must be a subset of the delegator's own permissions", + ), + expirySeconds: z + .number() + .int() + .positive() + .default(900) + .describe("How long the delegation is valid, in seconds (default: 900)"), + }) + .strip() + .describe( + "Delegates a narrowed set of permissions to another agent. The delegatee can never hold more permissions than the delegator.", + ); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..7b322e2ad 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 "./bolyra"; export * from "./cdp"; export * from "./clanker"; export * from "./compound";