From 02779e8bc2e791dbe7c8b546ce3582d4708529d9 Mon Sep 17 00:00:00 2001 From: Dominik Harz Date: Thu, 5 Mar 2026 19:17:20 +0000 Subject: [PATCH 1/2] feat: add BOB Gateway BTC offramp integration --- .../.changeset/bob-gateway-action-provider.md | 5 + typescript/agentkit/README.md | 17 + .../action-providers/bob-gateway/README.md | 64 +++ .../bobGatewayActionProvider.test.ts | 488 ++++++++++++++++++ .../bob-gateway/bobGatewayActionProvider.ts | 385 ++++++++++++++ .../bob-gateway/gatewayClient.test.ts | 466 +++++++++++++++++ .../bob-gateway/gatewayClient.ts | 282 ++++++++++ .../src/action-providers/bob-gateway/index.ts | 3 + .../action-providers/bob-gateway/schemas.ts | 54 ++ .../agentkit/src/action-providers/index.ts | 1 + 10 files changed, 1765 insertions(+) create mode 100644 typescript/.changeset/bob-gateway-action-provider.md create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/README.md create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.test.ts create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.ts create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/index.ts create mode 100644 typescript/agentkit/src/action-providers/bob-gateway/schemas.ts diff --git a/typescript/.changeset/bob-gateway-action-provider.md b/typescript/.changeset/bob-gateway-action-provider.md new file mode 100644 index 000000000..74b09fa92 --- /dev/null +++ b/typescript/.changeset/bob-gateway-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Add BOB Gateway action provider for swapping between EVM tokens and native BTC via the BOB Gateway protocol diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index c047fe89d..abde602e2 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -204,6 +204,23 @@ const agent = createReactAgent({
+BOB Gateway + + + + + + + + + + + + + +
swap_to_btcSwaps EVM tokens (e.g., USDC, WBTC) to native BTC on Bitcoin via BOB Gateway.
swap_from_btcSwaps native BTC to EVM tokens — returns a Bitcoin deposit address for the user to send BTC to.
get_ordersChecks the status of a BOB Gateway swap or deposit order.
+
+
Clanker diff --git a/typescript/agentkit/src/action-providers/bob-gateway/README.md b/typescript/agentkit/src/action-providers/bob-gateway/README.md new file mode 100644 index 000000000..f2be0ff80 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/README.md @@ -0,0 +1,64 @@ +# BOB Gateway Action Provider + +Swap EVM tokens to native BTC via [BOB Gateway](https://docs.gobob.xyz/gateway/overview). + +## Directory Structure + +``` +bob-gateway/ +├── bobGatewayActionProvider.ts # Main provider with three actions +├── bobGatewayActionProvider.test.ts # Tests for the action provider +├── gatewayClient.ts # API client wrapping BOB Gateway REST endpoints +├── gatewayClient.test.ts # Tests for the API client +├── schemas.ts # Zod input schemas +├── index.ts # Exports +└── README.md +``` + +## Actions + +- `get_supported_routes` - List available EVM to BTC swap routes with resolved token symbols and contract addresses +- `swap_to_btc` - Swap an ERC-20 token to native BTC. Checks balance, approves spending, executes the swap, and registers the transaction with the gateway +- `get_orders` - Check order status by order ID, Bitcoin tx ID, or EVM tx hash. Omit the ID to list all orders for the connected wallet + +## Configuration + +```typescript +import { bobGatewayActionProvider } from "@coinbase/agentkit"; + +// Default (mainnet) +const provider = bobGatewayActionProvider(); + +// Custom base URL and affiliate tracking +const provider = bobGatewayActionProvider({ + baseUrl: "https://gateway-api-mainnet.gobob.xyz", + affiliateId: "your-affiliate-id", +}); +``` + +## Network Support + +The provider accepts any EVM network. The source chain is resolved dynamically from the wallet's network ID by matching against chains returned by the Gateway API's `/v1/get-routes` endpoint (e.g. `base-mainnet` resolves to `base`). + +If a requested token or chain is not supported, the error message lists available options with resolved token symbols. + +## Slippage + +Slippage is specified in basis points (default 300 = 3%, max 1000 = 10%). The value is passed to the Gateway API which enforces it during quoting. + +## Amounts + +All amounts use whole (human-readable) units: `"100"` for 100 USDC, `"0.5"` for 0.5 WBTC. The provider fetches token decimals on-chain and converts internally. + +## Gateway API Endpoints Used + +| Endpoint | Method | Used by | +|---|---|---| +| `/v1/get-routes` | GET | `get_supported_routes`, `swap_to_btc` | +| `/v1/get-quote` | GET | `swap_to_btc` | +| `/v1/create-order` | POST | `swap_to_btc` | +| `/v1/register-tx` | PATCH | `swap_to_btc` | +| `/v1/get-order/{id}` | GET | `get_orders` (single order) | +| `/v1/get-orders/{address}` | GET | `get_orders` (all wallet orders) | + +See the [BOB Gateway API reference](https://docs.gobob.xyz/api-reference/overview) for details. diff --git a/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.test.ts b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.test.ts new file mode 100644 index 000000000..9864de8e6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.test.ts @@ -0,0 +1,488 @@ +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve, retryWithExponentialBackoff } from "../../utils"; +import { BobGatewayActionProvider } from "./bobGatewayActionProvider"; +import { GatewayClient } from "./gatewayClient"; +import { SwapToBtcSchema, GetOrdersSchema, DEFAULT_SLIPPAGE_BPS } from "./schemas"; + +const MOCK_ADDRESS = "0x1234567890123456789012345678901234567890"; +const MOCK_TOKEN_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const MOCK_BTC_ADDRESS = "bc1qafk4yhqvj4wep57m62dgrmutldusqde8adh20d"; +const MOCK_TX_HASH = + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" as `0x${string}`; +const MOCK_ORDER_ID = "550e8400-e29b-41d4-a716-446655440000"; + +jest.mock("../../utils"); +jest.mock("./gatewayClient"); + +const mockApprove = approve as jest.MockedFunction; +const mockRetry = retryWithExponentialBackoff as jest.MockedFunction< + typeof retryWithExponentialBackoff +>; + +describe("BobGateway Action Provider", () => { + let provider: BobGatewayActionProvider; + let mockWallet: jest.Mocked; + let mockClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockClient = new GatewayClient() as jest.Mocked; + + provider = new BobGatewayActionProvider({ client: mockClient }); + + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS), + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: jest + .fn() + .mockResolvedValue({ status: "success", blockNumber: 1234567 }), + readContract: jest + .fn() + .mockImplementation(async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") return 6; + if (functionName === "balanceOf") return BigInt("200000000"); // 200 USDC + if (functionName === "symbol") return "USDC"; + return undefined; + }), + } as unknown as jest.Mocked; + + mockClient.getRoutes = jest.fn().mockResolvedValue([ + { srcChain: "base", srcToken: MOCK_TOKEN_ADDRESS, dstChain: "bitcoin", dstToken: "BTC" }, + { srcChain: "bitcoin", srcToken: "BTC", dstChain: "base", dstToken: MOCK_TOKEN_ADDRESS }, + ]); + + mockApprove.mockResolvedValue("Successfully approved"); + mockRetry.mockImplementation(async (fn: () => Promise) => fn()); + }); + + describe("getSupportedRoutes", () => { + it("should return only EVM to BTC routes with resolved token symbols", async () => { + const result = await provider.getSupportedRoutes(mockWallet, {}); + + expect(result).toContain("Supported BOB Gateway routes"); + expect(result).toContain("base: USDC"); + expect(result).toContain(MOCK_TOKEN_ADDRESS); + expect(result).toContain("→ BTC"); + expect(result).not.toContain("bitcoin: BTC →"); + }); + + it("should return empty message when no EVM to BTC routes exist", async () => { + // No routes at all + mockClient.getRoutes = jest.fn().mockResolvedValue([]); + expect(await provider.getSupportedRoutes(mockWallet, {})).toContain("No supported routes"); + + // Only BTC → EVM routes (no actionable routes) + mockClient.getRoutes = jest + .fn() + .mockResolvedValue([ + { srcChain: "bitcoin", srcToken: "BTC", dstChain: "base", dstToken: MOCK_TOKEN_ADDRESS }, + ]); + expect(await provider.getSupportedRoutes(mockWallet, {})).toContain("No supported routes"); + }); + + it("should handle API errors gracefully", async () => { + mockClient.getRoutes = jest.fn().mockRejectedValue(new Error("Network error")); + + expect(await provider.getSupportedRoutes(mockWallet, {})).toContain( + "Error fetching supported routes", + ); + }); + + it("should only resolve symbols for tokens on the wallet's chain", async () => { + const otherChainToken = "0x1111111111111111111111111111111111111111"; + mockClient.getRoutes = jest + .fn() + .mockResolvedValue([ + { srcChain: "ethereum", srcToken: otherChainToken, dstChain: "bitcoin", dstToken: "BTC" }, + ]); + + const result = await provider.getSupportedRoutes(mockWallet, {}); + + expect(result).toContain(otherChainToken); + expect(result).not.toContain("USDC"); + }); + }); + + describe("swapToBtc", () => { + const MOCK_EVM_ORDER = { + orderId: MOCK_ORDER_ID, + tx: { + to: "0x0000000000000000000000000000000000000001" as `0x${string}`, + data: "0xabcdef" as `0x${string}`, + value: 0n, + }, + type: "offramp" as const, + expectedBtcOutput: "95000", + }; + + beforeEach(() => { + mockClient.createEvmOrder = jest.fn().mockResolvedValue(MOCK_EVM_ORDER); + mockClient.registerTx = jest.fn().mockResolvedValue(undefined); + }); + + it("should execute full swap flow and format result with amounts", async () => { + const result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("Successfully initiated BTC swap"); + expect(result).toContain("Sent: 100 USDC"); + expect(result).toContain("Expected: 0.00095 BTC"); + expect(result).toContain(MOCK_ORDER_ID); + expect(result).toContain(MOCK_BTC_ADDRESS); + + // Verify amount conversion: 100 USDC (6 decimals) = 100000000 atomic + expect(mockClient.createEvmOrder).toHaveBeenCalledWith( + expect.objectContaining({ + srcChain: "base", + amount: "100000000", + slippage: String(DEFAULT_SLIPPAGE_BPS), + }), + ); + expect(mockApprove).toHaveBeenCalled(); + expect(mockRetry).toHaveBeenCalledWith(expect.any(Function), 3, 1000); + }); + + it("should skip approve for layerZero orders", async () => { + mockClient.createEvmOrder = jest.fn().mockResolvedValue({ + ...MOCK_EVM_ORDER, + type: "layerZero", + tx: { ...MOCK_EVM_ORDER.tx, value: 50000n }, + }); + + await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(mockApprove).not.toHaveBeenCalled(); + }); + + it("should reject insufficient balance before creating order", async () => { + mockWallet.readContract.mockImplementation( + async ({ functionName }: { functionName: string }) => { + if (functionName === "decimals") return 6; + if (functionName === "balanceOf") return BigInt("50000000"); // 50 USDC + return undefined; + }, + ); + + const result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("Insufficient token balance"); + expect(mockClient.createEvmOrder).not.toHaveBeenCalled(); + }); + + it("should resolve chain dynamically from routes and fall back to heuristic", async () => { + // Dynamic resolution: "arbitrum-mainnet" → "arbitrum" via routes + mockClient.getRoutes = jest.fn().mockResolvedValue([ + { + srcChain: "arbitrum", + srcToken: MOCK_TOKEN_ADDRESS, + dstChain: "bitcoin", + dstToken: "BTC", + }, + ]); + mockWallet.getNetwork.mockReturnValue({ + protocolFamily: "evm", + networkId: "arbitrum-mainnet", + }); + + let result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + expect(mockClient.createEvmOrder).toHaveBeenCalledWith( + expect.objectContaining({ srcChain: "arbitrum" }), + ); + + // Heuristic fallback when routes API fails: "base-mainnet" → "base" + jest.clearAllMocks(); + mockClient.createEvmOrder = jest.fn().mockResolvedValue(MOCK_EVM_ORDER); + mockClient.registerTx = jest.fn().mockResolvedValue(undefined); + mockClient.getRoutes = jest.fn().mockRejectedValue(new Error("Network error")); + mockWallet.getNetwork.mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + }); + + result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + expect(result).toContain("Successfully"); + expect(mockClient.createEvmOrder).toHaveBeenCalledWith( + expect.objectContaining({ srcChain: "base" }), + ); + }); + + it("should reject unresolved chain and suggest get_supported_routes", async () => { + mockWallet.getNetwork.mockReturnValue({ + protocolFamily: "evm", + networkId: "unknown-network", + }); + + const result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("Could not determine source chain"); + expect(result).toContain("get_supported_routes"); + }); + + it("should handle partial failures: revert, register failure, approval failure", async () => { + // Transaction revert + mockWallet.waitForTransactionReceipt.mockResolvedValueOnce({ + status: "reverted", + blockNumber: 1234567, + } as never); + + let result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + expect(result).toContain("transaction reverted"); + expect(result).toContain(MOCK_ORDER_ID); + + // Register failure (on-chain succeeded but gateway registration failed) + mockWallet.waitForTransactionReceipt.mockResolvedValue({ + status: "success", + blockNumber: 1234567, + } as never); + mockRetry.mockRejectedValueOnce(new Error("Gateway timeout")); + + result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + expect(result).toContain("succeeded on-chain but failed to register"); + expect(result).toContain(MOCK_ORDER_ID); + expect(result).toContain(MOCK_TX_HASH); + + // Approval failure (stops before sending tx) + mockApprove.mockResolvedValueOnce("Error approving tokens: insufficient balance"); + + result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + expect(result).toContain("Error approving token"); + }); + + it("should not leak stack traces in error messages", async () => { + const errorWithStack = new Error("Something failed"); + errorWithStack.stack = "Error: Something failed\n at /internal/path/file.ts:123"; + mockClient.createEvmOrder = jest.fn().mockRejectedValue(errorWithStack); + + const result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("Something failed"); + expect(result).not.toContain("/internal/path"); + }); + + it("should reject unsupported token and show available pairs with symbols", async () => { + const result = await provider.swapToBtc(mockWallet, { + amount: "100", + tokenAddress: "0x0000000000000000000000000000000000000099", + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("not supported"); + expect(result).toContain("Available pairs"); + expect(result).toContain("USDC"); + expect(result).toContain(MOCK_TOKEN_ADDRESS); + expect(mockClient.createEvmOrder).not.toHaveBeenCalled(); + }); + + it("should reject zero amount", async () => { + const result = await provider.swapToBtc(mockWallet, { + amount: "0", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 300, + }); + + expect(result).toContain("Amount must be greater than 0"); + }); + }); + + describe("getOrders", () => { + it("should fetch a single order by ID", async () => { + mockClient.getOrderStatus = jest.fn().mockResolvedValue({ + timestamp: 1700000000, + status: "inProgress", + srcInfo: { chain: "bitcoin", token: "BTC", amount: "1000000", txHash: "btctx123" }, + dstInfo: { chain: "bob", token: "0xtoken", amount: "990000", txHash: null }, + estimatedTimeSecs: 600, + }); + + const result = await provider.getOrders(mockWallet, { orderId: MOCK_ORDER_ID }); + expect(result).toContain("inProgress"); + expect(result).toContain("btctx123"); + expect(result).toContain("600 seconds"); + expect(mockClient.getOrderStatus).toHaveBeenCalledWith(MOCK_ORDER_ID); + }); + + it("should fetch all orders for the wallet when no orderId given", async () => { + mockClient.getOrdersByAddress = jest.fn().mockResolvedValue([ + { + timestamp: 1700000000, + status: "success", + srcInfo: { chain: "base", token: "USDC", amount: "100", txHash: "0xabc" }, + dstInfo: { chain: "bitcoin", token: "BTC", amount: "0.001", txHash: "btctx1" }, + estimatedTimeSecs: null, + }, + { + timestamp: 1700000100, + status: "inProgress", + srcInfo: { chain: "base", token: "WBTC", amount: "0.5", txHash: "0xdef" }, + dstInfo: { chain: "bitcoin", token: "BTC", amount: "0.49", txHash: null }, + estimatedTimeSecs: 300, + }, + ]); + + const result = await provider.getOrders(mockWallet, {}); + expect(result).toContain("orders (2)"); + expect(result).toContain("success"); + expect(result).toContain("inProgress"); + expect(mockClient.getOrdersByAddress).toHaveBeenCalledWith(MOCK_ADDRESS); + }); + + it("should return empty message when no orders exist", async () => { + mockClient.getOrdersByAddress = jest.fn().mockResolvedValue([]); + const result = await provider.getOrders(mockWallet, {}); + expect(result).toContain("No BOB Gateway orders found"); + }); + + it("should handle errors gracefully", async () => { + mockClient.getOrderStatus = jest.fn().mockRejectedValue(new Error("not found")); + const result = await provider.getOrders(mockWallet, { orderId: MOCK_ORDER_ID }); + expect(result).toContain("Error"); + }); + }); + + describe("supportsNetwork", () => { + it("should accept EVM networks and reject non-EVM", () => { + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe( + true, + ); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "unknown" })).toBe(true); + expect(provider.supportsNetwork({ protocolFamily: "solana", networkId: "mainnet" })).toBe( + false, + ); + }); + }); + + describe("schema validation", () => { + it("should validate BTC address formats", () => { + const valid = [ + MOCK_BTC_ADDRESS, + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297", + ]; + for (const btcAddress of valid) { + expect( + SwapToBtcSchema.safeParse({ + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress, + }).success, + ).toBe(true); + } + + for (const btcAddress of ["not-valid", MOCK_ADDRESS, ""]) { + expect( + SwapToBtcSchema.safeParse({ + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress, + }).success, + ).toBe(false); + } + }); + + it("should validate amount strings and reject non-numeric values", () => { + for (const amount of ["100", "0.01", "1000000", "0.00001"]) { + expect( + SwapToBtcSchema.safeParse({ + amount, + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + }).success, + ).toBe(true); + } + + for (const amount of ["NaN", "Infinity", "-1", "1e18", "", "abc", "1.2.3"]) { + expect( + SwapToBtcSchema.safeParse({ + amount, + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + }).success, + ).toBe(false); + } + }); + + it("should accept optional orderId and reject empty strings", () => { + expect(GetOrdersSchema.safeParse({}).success).toBe(true); + expect(GetOrdersSchema.safeParse({ orderId: MOCK_ORDER_ID }).success).toBe(true); + expect(GetOrdersSchema.safeParse({ orderId: MOCK_TX_HASH }).success).toBe(true); + expect(GetOrdersSchema.safeParse({ orderId: "" }).success).toBe(false); + }); + + it("should reject slippage above 1000 bps (10%)", () => { + expect( + SwapToBtcSchema.safeParse({ + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 1001, + }).success, + ).toBe(false); + expect( + SwapToBtcSchema.safeParse({ + amount: "100", + tokenAddress: MOCK_TOKEN_ADDRESS, + btcAddress: MOCK_BTC_ADDRESS, + maxSlippage: 1000, + }).success, + ).toBe(true); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts new file mode 100644 index 000000000..2ce4eb44e --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts @@ -0,0 +1,385 @@ +import { z } from "zod"; +import { Hex, parseUnits, erc20Abi, formatUnits } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { approve, retryWithExponentialBackoff } from "../../utils"; +import { Network } from "../../network"; +import { + GatewayClient, + BOB_GATEWAY_BASE_URL, + RouteInfo, + GatewayOrderStatus, +} from "./gatewayClient"; +import { SwapToBtcSchema, GetOrdersSchema, GetSupportedRoutesSchema } from "./schemas"; + +/** Number of decimal places for Bitcoin amounts. */ +const BTC_DECIMALS = 8; + +/** Number of retry attempts for registering transactions with the Gateway. */ +const REGISTER_TX_RETRIES = 3; + +/** Initial delay in milliseconds between retry attempts. */ +const REGISTER_TX_RETRY_DELAY_MS = 1000; + +/** + * Extracts a safe error message string, stripping stack traces. + * + * @param error - The caught error value + * @returns The error message string only + */ +function safeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +/** + * Resolves a wallet network ID to a BOB Gateway chain slug by matching + * against chains available in the routes API. + * + * @param networkId - The wallet's network ID (e.g., "base-mainnet") + * @param routes - Available routes from the Gateway API + * @returns The matching chain slug (e.g., "base") or undefined + */ +function resolveChainSlug(networkId: string | undefined, routes: RouteInfo[]): string | undefined { + if (!networkId) return undefined; + + const chainSlugs = [...new Set(routes.flatMap(r => [r.srcChain, r.dstChain]))]; + + if (chainSlugs.includes(networkId)) return networkId; + + const stripped = networkId.replace(/-[^-]+$/, ""); + if (stripped !== networkId && chainSlugs.includes(stripped)) return stripped; + + return undefined; +} + +export interface BobGatewayActionProviderConfig { + baseUrl?: string; + affiliateId?: string; + client?: GatewayClient; +} + +/** + * BobGatewayActionProvider enables swapping between EVM tokens and BTC via BOB Gateway. + */ +export class BobGatewayActionProvider extends ActionProvider { + readonly #client: GatewayClient; + + /** + * Creates a new BobGatewayActionProvider instance. + * + * @param config - Optional configuration for the Gateway client + */ + constructor(config: BobGatewayActionProviderConfig = {}) { + super("bob_gateway", []); + this.#client = + config.client ?? + new GatewayClient(config.baseUrl ?? BOB_GATEWAY_BASE_URL, config.affiliateId); + } + + /** + * Returns the supported EVM to BTC swap routes from BOB Gateway with resolved token symbols. + * + * @param walletProvider - The wallet provider for on-chain symbol lookups + * @param _args - Unused (no input required) + * @returns A formatted list of supported routes + */ + @CreateAction({ + name: "get_supported_routes", + description: `Get available EVM to BTC swap routes for BOB Gateway, including token symbols and contract addresses. + +Use this to discover which tokens and chains can be swapped to BTC before calling swap_to_btc.`, + schema: GetSupportedRoutesSchema, + }) + async getSupportedRoutes( + walletProvider: EvmWalletProvider, + _args: z.infer, + ): Promise { + try { + const routes = await this.#client.getRoutes(); + const evmToBtcRoutes = routes.filter(r => r.dstChain === "bitcoin"); + + if (evmToBtcRoutes.length === 0) { + return "No supported routes found."; + } + + const network = walletProvider.getNetwork(); + const walletChain = resolveChainSlug(network.networkId, routes); + + const labels = await Promise.all( + evmToBtcRoutes.map(async r => { + const srcLabel = + r.srcChain === walletChain + ? await this.resolveTokenLabel(walletProvider, r.srcToken) + : r.srcToken; + return ` ${r.srcChain}: ${srcLabel} → BTC`; + }), + ); + + return `Supported BOB Gateway routes:\n${labels.join("\n")}`; + } catch (error) { + return `Error fetching supported routes: ${safeErrorMessage(error)}`; + } + } + + /** + * Swaps EVM tokens to native BTC on Bitcoin via BOB Gateway. + * + * @param walletProvider - The wallet provider to execute the swap from + * @param args - The input arguments for the swap action + * @returns A message with order details or an error description + */ + @CreateAction({ + name: "swap_to_btc", + description: `Swap EVM tokens to native BTC on Bitcoin via BOB Gateway. + +Use get_supported_routes to discover available tokens and chains. Checks token balance, approves spending, executes the swap, and registers with the gateway. Use get_orders to track progress.`, + schema: SwapToBtcSchema, + }) + async swapToBtc( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + if (Number(args.amount) <= 0) { + return "Error: Amount must be greater than 0"; + } + + try { + const address = walletProvider.getAddress(); + const network = walletProvider.getNetwork(); + + let routes: RouteInfo[] | null = null; + try { + routes = await this.#client.getRoutes(); + } catch { + // If routes fetch fails, fall back to heuristic chain resolution + } + + const srcChain = routes + ? resolveChainSlug(network.networkId, routes) + : network.networkId?.replace(/-[^-]+$/, ""); + + if (!srcChain) { + return `Error: Could not determine source chain from wallet network '${network.networkId}'. Use get_supported_routes to see available chains.`; + } + + if (routes) { + const evmToBtcRoutes = routes.filter(r => r.dstChain === "bitcoin"); + const routeError = await this.validateRoute( + walletProvider, + evmToBtcRoutes, + srcChain, + args.tokenAddress, + "bitcoin", + "BTC", + ); + if (routeError) return routeError; + } + + const decimals = (await walletProvider.readContract({ + address: args.tokenAddress as Hex, + abi: erc20Abi, + functionName: "decimals", + args: [], + })) as number; + const atomicAmount = parseUnits(args.amount, decimals); + + const balance = (await walletProvider.readContract({ + address: args.tokenAddress as Hex, + abi: erc20Abi, + functionName: "balanceOf", + args: [address as Hex], + })) as bigint; + + if (balance < atomicAmount) { + return `Error: Insufficient token balance. Have ${formatUnits(balance, decimals)}, need ${args.amount}.`; + } + + const tokenLabel = await this.resolveTokenLabel(walletProvider, args.tokenAddress); + + const order = await this.#client.createEvmOrder({ + srcChain, + dstChain: "bitcoin", + srcToken: args.tokenAddress, + dstToken: "BTC", + amount: atomicAmount.toString(), + sender: address, + recipient: args.btcAddress, + slippage: String(args.maxSlippage), + }); + + const expectedBtc = formatUnits(BigInt(order.expectedBtcOutput), BTC_DECIMALS); + + if (order.type === "offramp") { + const approvalResult = await approve( + walletProvider, + args.tokenAddress, + order.tx.to, + atomicAmount, + ); + if (approvalResult.startsWith("Error")) { + return `Error approving token for BOB Gateway: ${approvalResult}`; + } + } + + const txHash = await walletProvider.sendTransaction({ + to: order.tx.to as Hex, + data: order.tx.data as Hex, + value: order.tx.value, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + if (receipt.status === "reverted") { + return `Error: On-chain transaction reverted. Tx: ${txHash}, Order: ${order.orderId}`; + } + + try { + await retryWithExponentialBackoff( + () => this.#client.registerTx(order.orderId, txHash, order.type), + REGISTER_TX_RETRIES, + REGISTER_TX_RETRY_DELAY_MS, + ); + } catch (registerError) { + return `Warning: Swap transaction succeeded on-chain but failed to register with BOB Gateway.\n Order ID: ${order.orderId}\n Tx: ${txHash}\n Error: ${safeErrorMessage(registerError)}\n Contact BOB Gateway support with the order ID and tx hash.`; + } + + return `Successfully initiated BTC swap via BOB Gateway.\n Sent: ${args.amount} ${tokenLabel}\n Expected: ${expectedBtc} BTC\n Order ID: ${order.orderId}\n Tx: ${txHash}\n Recipient: ${args.btcAddress}\n Use get_orders with order ID "${order.orderId}" to track progress.`; + } catch (error) { + return `Error executing BOB Gateway swap: ${safeErrorMessage(error)}`; + } + } + + /** + * Checks the status of BOB Gateway swap orders. + * Fetches a single order by ID or all orders for the connected wallet. + * + * @param walletProvider - The wallet provider for address lookup + * @param args - Optional order ID to fetch a specific order + * @returns A message with order status details or an error description + */ + @CreateAction({ + name: "get_orders", + description: + "Check BOB Gateway order status. Pass an order ID to check a specific order, or omit to list all orders for the connected wallet.", + schema: GetOrdersSchema, + }) + async getOrders( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + if (args.orderId) { + const status = await this.#client.getOrderStatus(args.orderId); + return this.formatOrderStatus(status); + } + + const address = walletProvider.getAddress(); + const orders = await this.#client.getOrdersByAddress(address); + + if (orders.length === 0) { + return "No BOB Gateway orders found for this wallet."; + } + + return `BOB Gateway orders (${orders.length}):\n${orders.map(o => this.formatOrderStatus(o)).join("\n\n")}`; + } catch (error) { + return `Error fetching BOB Gateway order status: ${safeErrorMessage(error)}`; + } + } + + /** + * Formats a single order status into a readable string. + */ + private formatOrderStatus(status: GatewayOrderStatus): string { + const date = new Date(status.timestamp * 1000).toISOString(); + let result = ` Created: ${date}\n Status: ${status.status}\n Source: ${status.srcInfo.amount} ${status.srcInfo.token} (${status.srcInfo.chain})\n Destination: ${status.dstInfo.amount} ${status.dstInfo.token} (${status.dstInfo.chain})`; + + if (status.srcInfo.txHash) result += `\n Source tx: ${status.srcInfo.txHash}`; + if (status.dstInfo.txHash) result += `\n Dest tx: ${status.dstInfo.txHash}`; + if (status.estimatedTimeSecs) { + result += `\n Estimated time: ${status.estimatedTimeSecs} seconds`; + } + + return result; + } + + supportsNetwork = (network: Network) => network.protocolFamily === "evm"; + + /** + * Resolves an EVM token address to a human-readable label like "USDC (0x8335...2913)". + * Falls back to the raw address if the on-chain call fails. + * + * @param walletProvider - The wallet provider for on-chain lookups + * @param token - The token address or symbol to resolve + * @returns A label like "USDC (0x8335...2913)" or the raw token string + */ + private async resolveTokenLabel( + walletProvider: EvmWalletProvider, + token: string, + ): Promise { + if (!token.startsWith("0x")) return token; + try { + const symbol = (await walletProvider.readContract({ + address: token as Hex, + abi: erc20Abi, + functionName: "symbol", + args: [], + })) as string; + return `${symbol} (${token})`; + } catch { + return token; + } + } + + /** + * Validates that a route is supported by the Gateway API. + * Returns null if valid, or an error message listing available options with token symbols. + * + * @param walletProvider - The wallet provider for on-chain symbol lookups + * @param routes - Available routes from the Gateway API + * @param srcChain - Source chain slug + * @param srcToken - Source token address + * @param dstChain - Destination chain slug + * @param dstToken - Destination token address + * @returns null if valid, or an error message listing available options + */ + private async validateRoute( + walletProvider: EvmWalletProvider, + routes: RouteInfo[], + srcChain: string, + srcToken: string, + dstChain: string, + dstToken: string, + ): Promise { + const normalizeToken = (t: string) => t.toLowerCase(); + + const match = routes.find( + r => + r.srcChain === srcChain && + normalizeToken(r.srcToken) === normalizeToken(srcToken) && + r.dstChain === dstChain && + normalizeToken(r.dstToken) === normalizeToken(dstToken), + ); + + if (match) return null; + + const labels = await Promise.all( + routes.map(async r => { + const src = await this.resolveTokenLabel(walletProvider, r.srcToken); + const dst = await this.resolveTokenLabel(walletProvider, r.dstToken); + return `${r.srcChain}: ${src} → ${r.dstChain}: ${dst}`; + }), + ); + + const sameChainPair = routes.some(r => r.srcChain === srcChain && r.dstChain === dstChain); + + if (sameChainPair) { + return `Error: Token pair not supported on route ${srcChain} → ${dstChain}. Available pairs: ${labels.join(", ")}`; + } + + return `Error: Route ${srcChain} → ${dstChain} is not supported. Available pairs: ${labels.join(", ")}`; + } +} + +export const bobGatewayActionProvider = (config?: BobGatewayActionProviderConfig) => + new BobGatewayActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.test.ts b/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.test.ts new file mode 100644 index 000000000..bea68adab --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.test.ts @@ -0,0 +1,466 @@ +import { GatewayClient, BOB_GATEWAY_BASE_URL, QuoteParams } from "./gatewayClient"; + +const BASE_URL = "https://gateway-api-mainnet.gobob.xyz"; + +const MOCK_QUOTE_PARAMS: QuoteParams = { + srcChain: "base", + dstChain: "bitcoin", + srcToken: "0xtoken", + dstToken: "BTC", + amount: "100000000", + sender: "0xsender", + recipient: "bc1qtest", + slippage: "300", +}; + +const MOCK_OFFRAMP_QUOTE = { + offramp: { + inputAmount: { amount: "100000000", address: "0xtoken", chain: "base" }, + outputAmount: { amount: "95000", address: "BTC", chain: "bitcoin" }, + fees: { amount: "5000", address: "BTC", chain: "bitcoin" }, + srcChain: "base", + tokenAddress: "0xtoken", + sender: "0xsender", + recipient: "bc1qtest", + slippage: 300, + txTo: "0xsender", + }, +}; + +const MOCK_LZ_QUOTE = { + layerZero: { + inputAmount: { amount: "100000000", address: "0xtoken", chain: "base" }, + outputAmount: { amount: "95000", address: "BTC", chain: "bitcoin" }, + fees: { amount: "5000", address: "BTC", chain: "bitcoin" }, + tx: { + to: "0x0000000000000000000000000000000000000002", + data: "0x1234", + value: "50000", + }, + }, +}; + +const MOCK_OFFRAMP_ORDER = { + offramp: { + order_id: "offramp-order-123", + tx: { + to: "0x0000000000000000000000000000000000000001", + data: "0xabcd", + value: "0", + }, + }, +}; + +const MOCK_LZ_ORDER = { + layerZero: { + order_id: "lz-order-456", + tx: { + to: "0x0000000000000000000000000000000000000002", + data: "0x1234", + value: "50000", + }, + }, +}; + +/** + * Creates a mock successful fetch Response. + * + * @param data - The data to return from json() + * @returns A mock Response object + */ +function mockFetchResponse(data: unknown): Response { + return { + ok: true, + json: async () => data, + text: async () => JSON.stringify(data), + } as Response; +} + +/** + * Creates a mock error fetch Response. + * + * @param status - HTTP status code + * @param body - Error body text + * @returns A mock Response object + */ +function mockFetchError(status: number, body: string): Response { + return { + ok: false, + status, + text: async () => body, + } as Response; +} + +describe("GatewayClient", () => { + let client: GatewayClient; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchMock: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock = jest.spyOn(global, "fetch").mockImplementation(jest.fn()); + client = new GatewayClient(); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + describe("constructor", () => { + it("uses default base URL", () => { + expect(BOB_GATEWAY_BASE_URL).toBe(BASE_URL); + }); + }); + + describe("createEvmOrder", () => { + it("normalizes offramp quotes/orders correctly", async () => { + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_QUOTE)) + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_ORDER)); + + const result = await client.createEvmOrder(MOCK_QUOTE_PARAMS); + + expect(result).toEqual({ + orderId: "offramp-order-123", + tx: { + to: "0x0000000000000000000000000000000000000001", + data: "0xabcd", + value: BigInt("0"), + }, + type: "offramp", + expectedBtcOutput: "95000", + }); + }); + + it("normalizes layerZero quotes/orders correctly", async () => { + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_LZ_QUOTE)) + .mockResolvedValueOnce(mockFetchResponse(MOCK_LZ_ORDER)); + + const result = await client.createEvmOrder(MOCK_QUOTE_PARAMS); + + expect(result).toEqual({ + orderId: "lz-order-456", + tx: { + to: "0x0000000000000000000000000000000000000002", + data: "0x1234", + value: BigInt("50000"), + }, + type: "layerZero", + expectedBtcOutput: "95000", + }); + }); + + it("passes correct params to get-quote and create-order API endpoints", async () => { + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_QUOTE)) + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_ORDER)); + + await client.createEvmOrder(MOCK_QUOTE_PARAMS); + + // First call: GET /v1/get-quote with query params + const quoteCall = fetchMock.mock.calls[0]; + const quoteUrl = new URL(quoteCall[0]); + expect(quoteUrl.origin + quoteUrl.pathname).toBe(`${BASE_URL}/v1/get-quote`); + expect(quoteUrl.searchParams.get("srcChain")).toBe("base"); + expect(quoteUrl.searchParams.get("dstChain")).toBe("bitcoin"); + expect(quoteUrl.searchParams.get("srcToken")).toBe("0xtoken"); + expect(quoteUrl.searchParams.get("dstToken")).toBe("BTC"); + expect(quoteUrl.searchParams.get("amount")).toBe("100000000"); + expect(quoteUrl.searchParams.get("sender")).toBe("0xsender"); + expect(quoteUrl.searchParams.get("recipient")).toBe("bc1qtest"); + expect(quoteUrl.searchParams.get("slippage")).toBe("300"); + + // Second call: POST /v1/create-order with the quote as body + const orderCall = fetchMock.mock.calls[1]; + expect(orderCall[0]).toBe(`${BASE_URL}/v1/create-order`); + expect(orderCall[1]).toEqual( + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(MOCK_OFFRAMP_QUOTE), + }), + ); + }); + + it("includes affiliateId in quote URL when configured", async () => { + const clientWithAffiliate = new GatewayClient(BOB_GATEWAY_BASE_URL, "my-affiliate"); + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_QUOTE)) + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_ORDER)); + + await clientWithAffiliate.createEvmOrder(MOCK_QUOTE_PARAMS); + + const quoteUrl = new URL(fetchMock.mock.calls[0][0]); + expect(quoteUrl.searchParams.get("affiliateId")).toBe("my-affiliate"); + }); + + it("rejects quote with mismatched sender", async () => { + const tamperedQuote = { + offramp: { + ...MOCK_OFFRAMP_QUOTE.offramp, + sender: "0xattacker", + }, + }; + fetchMock + .mockResolvedValueOnce(mockFetchResponse(tamperedQuote)) + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_ORDER)); + + await expect(client.createEvmOrder(MOCK_QUOTE_PARAMS)).rejects.toThrow( + "Quote response does not match request", + ); + }); + }); + + describe("response validation", () => { + it("rejects negative tx.value in createEvmOrder", async () => { + const badOrder = { + offramp: { + order_id: "order-123", + tx: { to: "0x0000000000000000000000000000000000000001", data: "0xabcd", value: "-1" }, + }, + }; + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_QUOTE)) + .mockResolvedValueOnce(mockFetchResponse(badOrder)); + + await expect(client.createEvmOrder(MOCK_QUOTE_PARAMS)).rejects.toThrow( + "Invalid transaction value", + ); + }); + }); + + describe("registerTx", () => { + it("sends correct PATCH body for offramp type", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => "", + } as Response); + + await client.registerTx("order-123", "0xtxhash", "offramp"); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/v1/register-tx`, + expect.objectContaining({ + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + offramp: { order_id: "order-123", evm_txhash: "0xtxhash" }, + }), + }), + ); + }); + + it("sends correct PATCH body for layerZero type", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => "", + } as Response); + + await client.registerTx("order-456", "0xtxhash2", "layerZero"); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/v1/register-tx`, + expect.objectContaining({ + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + layerZero: { order_id: "order-456", evm_txhash: "0xtxhash2" }, + }), + }), + ); + }); + }); + + describe("getOrderStatus", () => { + const MOCK_SUCCESS_ORDER = { + timestamp: 1700000000, + srcInfo: { chain: "bitcoin", token: "BTC", amount: "1000000", txHash: "0xsrchash" }, + dstInfo: { chain: "bob", token: "0xtoken", amount: "990000", txHash: "0xdsthash" }, + status: "success", + estimatedTimeInSecs: null, + }; + + const MOCK_IN_PROGRESS_ORDER = { + timestamp: 1700000100, + srcInfo: { chain: "base", token: "0xusdc", amount: "500000", txHash: "0xsrc" }, + dstInfo: { chain: "bitcoin", token: "BTC", amount: "48000", txHash: null }, + status: { inProgress: { bump_fee_tx: null, refund_tx: null } }, + estimatedTimeInSecs: 1200, + }; + + it("normalizes a successful order", async () => { + fetchMock.mockResolvedValueOnce(mockFetchResponse(MOCK_SUCCESS_ORDER)); + + const result = await client.getOrderStatus("order-123"); + + expect(result).toEqual({ + timestamp: 1700000000, + status: "success", + srcInfo: { chain: "bitcoin", token: "BTC", amount: "1000000", txHash: "0xsrchash" }, + dstInfo: { chain: "bob", token: "0xtoken", amount: "990000", txHash: "0xdsthash" }, + estimatedTimeSecs: null, + }); + }); + + it("passes through object status as-is", async () => { + fetchMock.mockResolvedValueOnce(mockFetchResponse(MOCK_IN_PROGRESS_ORDER)); + + const result = await client.getOrderStatus("order-456"); + + expect(result.status).toEqual({ inProgress: { bump_fee_tx: null, refund_tx: null } }); + expect(result.estimatedTimeSecs).toBe(1200); + }); + + it("passes through string status as-is", async () => { + fetchMock.mockResolvedValueOnce( + mockFetchResponse({ ...MOCK_SUCCESS_ORDER, status: "refunded" }), + ); + + const result = await client.getOrderStatus("order-refund"); + expect(result.status).toBe("refunded"); + }); + + it("encodes orderId to prevent path traversal", async () => { + fetchMock.mockResolvedValueOnce(mockFetchResponse(MOCK_SUCCESS_ORDER)); + + await client.getOrderStatus("../../admin/secret"); + + const calledUrl = fetchMock.mock.calls[0][0]; + expect(calledUrl).toBe(`${BASE_URL}/v1/get-order/..%2F..%2Fadmin%2Fsecret`); + }); + + it("aborts fetch after timeout", async () => { + jest.useFakeTimers(); + + fetchMock.mockImplementationOnce( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal) { + signal.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + } + }), + ); + + const promise = client.getOrderStatus("order-123").catch((e: Error) => e); + + await jest.advanceTimersByTimeAsync(30_000); + + const result = await promise; + expect(result).toBeDefined(); + expect((result as DOMException).name).toBe("AbortError"); + + jest.useRealTimers(); + }, 10000); + + it("passes through unknown status strings from API", async () => { + fetchMock.mockResolvedValueOnce( + mockFetchResponse({ ...MOCK_SUCCESS_ORDER, status: "preminted" }), + ); + + const result = await client.getOrderStatus("order-x"); + expect(result.status).toBe("preminted"); + }); + + it("passes through unknown object status as-is", async () => { + const orderWithObjectStatus = { + ...MOCK_SUCCESS_ORDER, + status: { someNewStatus: { detail: "info" } }, + }; + fetchMock.mockResolvedValueOnce(mockFetchResponse(orderWithObjectStatus)); + + const result = await client.getOrderStatus("order-y"); + expect(result.status).toEqual({ someNewStatus: { detail: "info" } }); + }); + }); + + describe("getOrdersByAddress", () => { + it("fetches and normalizes all orders for an address", async () => { + const mockOrders = [ + { + timestamp: 1700000000, + srcInfo: { chain: "base", token: "USDC", amount: "100", txHash: "0xabc" }, + dstInfo: { chain: "bitcoin", token: "BTC", amount: "0.001", txHash: "btctx1" }, + status: "success", + estimatedTimeInSecs: null, + }, + { + timestamp: 1700000100, + srcInfo: { chain: "base", token: "WBTC", amount: "0.5", txHash: "0xdef" }, + dstInfo: { chain: "bitcoin", token: "BTC", amount: "0.49", txHash: null }, + status: "inProgress", + estimatedTimeInSecs: 300, + }, + ]; + fetchMock.mockResolvedValueOnce(mockFetchResponse(mockOrders)); + + const results = await client.getOrdersByAddress("0xuser123"); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe("success"); + expect(results[1].status).toBe("inProgress"); + expect(results[1].estimatedTimeSecs).toBe(300); + + const calledUrl = fetchMock.mock.calls[0][0]; + expect(calledUrl).toBe(`${BASE_URL}/v1/get-orders/0xuser123`); + }); + }); + + describe("getRoutes", () => { + const MOCK_ROUTES = [ + { srcChain: "base", srcToken: "0xusdc", dstChain: "bitcoin", dstToken: "BTC" }, + { srcChain: "bitcoin", srcToken: "BTC", dstChain: "bob", dstToken: "0xwbtc" }, + ]; + + it("fetches routes from the API", async () => { + fetchMock.mockResolvedValueOnce(mockFetchResponse(MOCK_ROUTES)); + + const routes = await client.getRoutes(); + + expect(routes).toEqual(MOCK_ROUTES); + const calledUrl = fetchMock.mock.calls[0][0]; + expect(calledUrl).toBe(`${BASE_URL}/v1/get-routes`); + }); + + it("caches routes on subsequent calls", async () => { + fetchMock.mockResolvedValueOnce(mockFetchResponse(MOCK_ROUTES)); + + await client.getRoutes(); + const routes2 = await client.getRoutes(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(routes2).toEqual(MOCK_ROUTES); + }); + }); + + describe("error handling", () => { + it("throws on non-ok HTTP response from GET", async () => { + fetchMock.mockResolvedValueOnce(mockFetchError(500, "Internal Server Error")); + + await expect(client.createEvmOrder(MOCK_QUOTE_PARAMS)).rejects.toThrow( + "Gateway API error (500): Internal Server Error", + ); + }); + + it("throws on non-ok HTTP response from POST", async () => { + fetchMock + .mockResolvedValueOnce(mockFetchResponse(MOCK_OFFRAMP_QUOTE)) + .mockResolvedValueOnce(mockFetchError(400, "Bad Request")); + + await expect(client.createEvmOrder(MOCK_QUOTE_PARAMS)).rejects.toThrow( + "Gateway API error (400): Bad Request", + ); + }); + + it("throws on non-ok HTTP response from PATCH", async () => { + fetchMock.mockResolvedValueOnce(mockFetchError(403, "Forbidden")); + + await expect(client.registerTx("order-123", "0xtxhash", "offramp")).rejects.toThrow( + "Gateway API error (403): Forbidden", + ); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.ts b/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.ts new file mode 100644 index 000000000..2196e1295 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/gatewayClient.ts @@ -0,0 +1,282 @@ +import { Hex } from "viem"; + +/** Parameters for requesting a quote from the Gateway API. */ +export interface QuoteParams { + srcChain: string; + dstChain: string; + srcToken: string; + dstToken: string; + amount: string; + sender?: string; + recipient: string; + slippage: string; +} + +/** Normalized EVM order, used by swap_to_btc after quote+order creation. */ +export interface GatewayEvmOrder { + orderId: string; + tx: { to: Hex; data: Hex; value: bigint }; + type: "offramp" | "layerZero"; + expectedBtcOutput: string; +} + +/** Chain transaction info returned by the Gateway API. */ +interface ChainTxInfo { + chain: string; + token: string; + amount: string; + txHash: string | null; +} + +/** Order status, used by get_orders. */ +export interface GatewayOrderStatus { + timestamp: number; + status: unknown; + srcInfo: ChainTxInfo; + dstInfo: ChainTxInfo; + estimatedTimeSecs: number | null; +} + +/** A supported route returned by the Gateway API. */ +export interface RouteInfo { + srcChain: string; + srcToken: string; + dstChain: string; + dstToken: string; +} + +const FETCH_TIMEOUT_MS = 30_000; +const MAX_RESPONSE_BYTES = 1_048_576; // 1 MB +const ROUTES_CACHE_TTL_MS = 300_000; // 5 minutes + +export const BOB_GATEWAY_BASE_URL = "https://gateway-api-mainnet.gobob.xyz"; + +/** + * Client for the BOB Gateway API that normalizes responses into flat, typed structures. + */ +export class GatewayClient { + private routesCache: { routes: RouteInfo[]; fetchedAt: number } | null = null; + + /** + * Creates a new GatewayClient instance. + * + * @param baseUrl - Base URL for the Gateway API + * @param affiliateId - Optional affiliate ID for tracking volume + */ + constructor( + private readonly baseUrl: string = BOB_GATEWAY_BASE_URL, + private readonly affiliateId?: string, + ) {} + + /** + * Creates an EVM swap order (offramp or layerZero) by fetching a quote and creating an order. + * + * @param params - Quote parameters for the swap + * @returns Normalized EVM order with transaction data + */ + async createEvmOrder(params: QuoteParams): Promise { + const quote = await this.get("/v1/get-quote", params); + + const type = "offramp" in quote ? ("offramp" as const) : ("layerZero" as const); + const q = quote[type]; + + // Validate quote echoes back our request params (offramp echoes sender/recipient) + if (type === "offramp") { + if (q.sender !== params.sender || q.recipient !== params.recipient) { + throw new Error("Quote response does not match request parameters"); + } + } + + const order = await this.post("/v1/create-order", quote); + const inner = order[type]; + + const value = BigInt(inner.tx.value); + if (value < 0n) { + throw new Error("Invalid transaction value: must be non-negative"); + } + + return { + orderId: inner.order_id, + tx: { + to: inner.tx.to, + data: inner.tx.data, + value, + }, + type, + expectedBtcOutput: quote[type].outputAmount.amount, + }; + } + + /** + * Registers an on-chain transaction hash with the Gateway API. + * + * @param orderId - The order UUID to register against + * @param txHash - The EVM transaction hash + * @param type - The order type (offramp or layerZero) + */ + async registerTx(orderId: string, txHash: string, type: "offramp" | "layerZero"): Promise { + await this.patch("/v1/register-tx", { + [type]: { order_id: orderId, evm_txhash: txHash }, + }); + } + + /** + * Fetches the status of a single Gateway order by ID, BTC tx ID, or EVM tx hash. + * + * @param id - Order ID, Bitcoin tx ID, or EVM tx hash + * @returns Normalized order status + */ + async getOrderStatus(id: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const info: any = await this.get(`/v1/get-order/${encodeURIComponent(id)}`); + return this.normalizeOrderInfo(info); + } + + /** + * Fetches all orders for a given user address. + * + * @param userAddress - The EVM address to fetch orders for + * @returns Array of normalized order statuses + */ + async getOrdersByAddress(userAddress: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const orders: any[] = await this.get(`/v1/get-orders/${encodeURIComponent(userAddress)}`); + return orders.map(info => this.normalizeOrderInfo(info)); + } + + /** + * Normalizes a raw Gateway order info object into a typed status. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private normalizeOrderInfo(info: any): GatewayOrderStatus { + return { + timestamp: info.timestamp, + status: info.status, + srcInfo: { + chain: info.srcInfo.chain, + token: info.srcInfo.token, + amount: info.srcInfo.amount, + txHash: info.srcInfo.txHash ?? null, + }, + dstInfo: { + chain: info.dstInfo.chain, + token: info.dstInfo.token, + amount: info.dstInfo.amount, + txHash: info.dstInfo.txHash ?? null, + }, + estimatedTimeSecs: info.estimatedTimeInSecs ?? null, + }; + } + + /** + * Fetches supported routes from the Gateway API, with a 5-minute cache. + * + * @returns Array of supported routes + */ + async getRoutes(): Promise { + if (this.routesCache && Date.now() - this.routesCache.fetchedAt < ROUTES_CACHE_TTL_MS) { + return this.routesCache.routes; + } + + const routes: RouteInfo[] = await this.get("/v1/get-routes"); + this.routesCache = { routes, fetchedAt: Date.now() }; + return routes; + } + + /** + * Wraps fetch with an AbortController timeout. + * + * @param url - The URL to fetch + * @param init - Optional fetch init options + * @returns The fetch Response + */ + private async fetchWithTimeout(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + + /** + * Sends a GET request to the Gateway API. + * + * @param path - API endpoint path + * @param queryParams - Optional query parameters + * @returns Parsed JSON response + */ + private async get( + path: string, + queryParams?: Record | QuoteParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { + let url = `${this.baseUrl}${path}`; + if (queryParams) { + const entries = Object.entries(queryParams).filter(([, v]) => v != null && v !== "") as [ + string, + string, + ][]; + const params = new URLSearchParams(entries); + if (this.affiliateId) params.set("affiliateId", this.affiliateId); + url += `?${params}`; + } + return this.handleResponse(await this.fetchWithTimeout(url)); + } + + /** + * Sends a POST request to the Gateway API. + * + * @param path - API endpoint path + * @param body - Request body to serialize as JSON + * @returns Parsed JSON response + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async post(path: string, body: unknown): Promise { + return this.handleResponse( + await this.fetchWithTimeout(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + } + + /** + * Sends a PATCH request to the Gateway API. + * + * @param path - API endpoint path + * @param body - Request body to serialize as JSON + */ + private async patch(path: string, body: unknown): Promise { + const response = await this.fetchWithTimeout(`${this.baseUrl}${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const errorBody = (await response.text()).slice(0, 200); + throw new Error(`Gateway API error (${response.status}): ${errorBody}`); + } + } + + /** + * Checks response status and parses JSON, throwing on errors. + * + * @param response - Fetch response to handle + * @returns Parsed JSON response + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async handleResponse(response: Response): Promise { + if (!response.ok) { + const errorBody = (await response.text()).slice(0, 200); + throw new Error(`Gateway API error (${response.status}): ${errorBody}`); + } + const text = await response.text(); + if (text.length > MAX_RESPONSE_BYTES) { + throw new Error("Gateway API response exceeds size limit"); + } + return JSON.parse(text); + } +} diff --git a/typescript/agentkit/src/action-providers/bob-gateway/index.ts b/typescript/agentkit/src/action-providers/bob-gateway/index.ts new file mode 100644 index 000000000..0014ca3d9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/index.ts @@ -0,0 +1,3 @@ +export * from "./schemas"; +export * from "./gatewayClient"; +export * from "./bobGatewayActionProvider"; diff --git a/typescript/agentkit/src/action-providers/bob-gateway/schemas.ts b/typescript/agentkit/src/action-providers/bob-gateway/schemas.ts new file mode 100644 index 000000000..87b127613 --- /dev/null +++ b/typescript/agentkit/src/action-providers/bob-gateway/schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +/** Default slippage in basis points (300 = 3%). */ +export const DEFAULT_SLIPPAGE_BPS = 300; + +/** Maximum allowed slippage in basis points (1000 = 10%). */ +export const MAX_SLIPPAGE_BPS = 1000; + +/** Matches Bitcoin address formats: P2PKH (1...), P2SH (3...), Bech32 (bc1q...), Taproot (bc1p...). */ +export const BTC_ADDRESS_REGEX = + /^(1[1-9A-HJ-NP-Za-km-z]{25,34}|3[1-9A-HJ-NP-Za-km-z]{25,34}|bc1[qp][a-z0-9]{38,58})$/; + +export const SwapToBtcSchema = z + .object({ + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a positive decimal number") + .describe("Token amount in whole units (e.g., '100' for 100 USDC)"), + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid EVM address") + .describe("ERC20 token contract address on the source chain"), + btcAddress: z + .string() + .regex(BTC_ADDRESS_REGEX, "Invalid Bitcoin address") + .describe("Bitcoin address to receive BTC"), + maxSlippage: z + .number() + .min(1) + .max(MAX_SLIPPAGE_BPS) + .optional() + .default(DEFAULT_SLIPPAGE_BPS) + .describe(`Max slippage in basis points (default ${DEFAULT_SLIPPAGE_BPS} = 3%)`), + }) + .strip() + .describe("Swap EVM tokens to BTC via BOB Gateway"); + +export const GetSupportedRoutesSchema = z + .object({}) + .strip() + .describe("Get supported BOB Gateway swap routes"); + +export const GetOrdersSchema = z + .object({ + orderId: z + .string() + .min(1) + .optional() + .describe( + "Order ID, Bitcoin tx ID, or EVM tx hash. Omit to fetch all orders for the connected wallet", + ), + }) + .strip() + .describe("Get BOB Gateway order status"); diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index e0eccdeca..c4b909e59 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 "./bob-gateway"; export * from "./cdp"; export * from "./clanker"; export * from "./compound"; From 1143676dcdc74c1c6fbf14378607b782140dee23 Mon Sep 17 00:00:00 2001 From: Dominik Harz Date: Sat, 7 Mar 2026 18:54:05 +0000 Subject: [PATCH 2/2] fix: update BTC default token and add notice on required ETH in account --- typescript/agentkit/README.md | 8 ++++---- .../src/action-providers/bob-gateway/README.md | 4 ++++ .../bob-gateway/bobGatewayActionProvider.ts | 10 ++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index abde602e2..07d0a6ae3 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -207,12 +207,12 @@ const agent = createReactAgent({ BOB Gateway
- - + + - - + + diff --git a/typescript/agentkit/src/action-providers/bob-gateway/README.md b/typescript/agentkit/src/action-providers/bob-gateway/README.md index f2be0ff80..717af124e 100644 --- a/typescript/agentkit/src/action-providers/bob-gateway/README.md +++ b/typescript/agentkit/src/action-providers/bob-gateway/README.md @@ -36,6 +36,10 @@ const provider = bobGatewayActionProvider({ }); ``` +## ETH Requirement + +BOB Gateway offramp transactions require the sender to include a small amount of native ETH (typically ~0.0005 ETH) as `msg.value` to cover bridge/solver inclusion fees. If the wallet has no native ETH, the transaction will revert. Ensure the wallet is funded with ETH before calling `swap_to_btc`, or swap a small amount of an ERC-20 (e.g. USDC) to ETH first. + ## Network Support The provider accepts any EVM network. The source chain is resolved dynamically from the wallet's network ID by matching against chains returned by the Gateway API's `/v1/get-routes` endpoint (e.g. `base-mainnet` resolves to `base`). diff --git a/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts index 2ce4eb44e..7ab765113 100644 --- a/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/bob-gateway/bobGatewayActionProvider.ts @@ -202,7 +202,7 @@ Use get_supported_routes to discover available tokens and chains. Checks token b srcChain, dstChain: "bitcoin", srcToken: args.tokenAddress, - dstToken: "BTC", + dstToken: "0x0000000000000000000000000000000000000000", amount: atomicAmount.toString(), sender: address, recipient: args.btcAddress, @@ -351,7 +351,13 @@ Use get_supported_routes to discover available tokens and chains. Checks token b dstChain: string, dstToken: string, ): Promise { - const normalizeToken = (t: string) => t.toLowerCase(); + const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + const normalizeToken = (t: string) => { + const lower = t.toLowerCase(); + // Treat "btc" and the zero address as equivalent for bitcoin destinations + if (lower === "btc" || lower === ZERO_ADDRESS) return ZERO_ADDRESS; + return lower; + }; const match = routes.find( r =>
swap_to_btcSwaps EVM tokens (e.g., USDC, WBTC) to native BTC on Bitcoin via BOB Gateway.get_supported_routesLists available EVM-to-BTC swap routes with token symbols and contract addresses.
swap_from_btcSwaps native BTC to EVM tokens — returns a Bitcoin deposit address for the user to send BTC to.swap_to_btcSwaps EVM tokens (e.g., USDC, WBTC) to native BTC on Bitcoin via BOB Gateway.
get_orders