Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gblin-action-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a GBLIN action provider to buy, redeem, and read GBLIN — a collateral-backed treasury index on Base.
17 changes: 17 additions & 0 deletions typescript/agentkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,23 @@ const agent = createAgent({
</table>
</details>
<details>
<summary><strong>GBLIN</strong></summary>
<table width="100%">
<tr>
<td width="200"><code>buy_gblin</code></td>
<td width="768">Buys GBLIN, a collateral-backed treasury index on Base, with ETH. Derives a slippage-bounded minimum output from the on-chain quote.</td>
</tr>
<tr>
<td width="200"><code>sell_gblin_for_eth</code></td>
<td width="768">Redeems GBLIN back to ETH through the GBLIN Zap, approving the shares first if needed. Derives a slippage-bounded minimum output from the on-chain quote.</td>
</tr>
<tr>
<td width="200"><code>get_gblin_state</code></td>
<td width="768">Reads the ETH value of one GBLIN at NAV, the total supply, and whether the NAV is reliable.</td>
</tr>
</table>
</details>
<details>
<summary><strong>Messari</strong></summary>
<table width="100%">
<tr>
Expand Down
41 changes: 41 additions & 0 deletions typescript/agentkit/src/action-providers/gblin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# GBLIN Action Provider

Actions for interacting with [GBLIN](https://gblin.digital), a collateral-backed treasury index
on Base (cbBTC / WETH / USDC). The vault mints at NAV, redeems pro rata in kind and reduces the
weight of a basket asset on-chain when it draws down. It is intended for parking surplus agent
capital in managed crypto exposure — **not** a stablecoin and not financial advice.

| Contract | Address |
| --- | --- |
| Vault (share token) | [`0xc2181d975c05c8c724b334bcED0764c0b86B1D53`](https://basescan.org/address/0xc2181d975c05c8c724b334bcED0764c0b86B1D53#code) |
| Lens (quotes) | [`0xfCFea8027019E8551A1f09AD91532471F5D26f61`](https://basescan.org/address/0xfCFea8027019E8551A1f09AD91532471F5D26f61#code) |
| Zap (exit to ETH) | [`0x0E9D6Ceb6D313b021622C121Cda9C62e86e60200`](https://basescan.org/address/0x0E9D6Ceb6D313b021622C121Cda9C62e86e60200#code) |

## Actions

| Action | Description |
| --- | --- |
| `buy_gblin` | Buy GBLIN with ETH. Reads `quoteBuy` from the Lens and calls `buyGBLIN` on the vault with a slippage-bounded minimum output. |
| `sell_gblin_for_eth` | Redeem GBLIN back to ETH (e.g. to fund an x402 payment). Reads `quoteSell` from the Lens, approves the shares to the Zap if needed, and calls `GBLINZap.sellGBLINForEth`, which redeems in kind and sells every leg, all or nothing. |
| `get_gblin_state` | Read the ETH value of one GBLIN at NAV, the total supply, and whether the vault reports its NAV as reliable. |

Every state-changing action derives its minimum output from an on-chain quote, and both
refuse to trade while the vault reports its NAV as not reliable.

## Network support

Base mainnet (`base-mainnet`) only.

## Example

```typescript
import { gblinActionProvider } from "@coinbase/agentkit";

const provider = gblinActionProvider();
```

## Notes

- Fees: 0.10% on mint and a 0.50% yearly management fee accrued as new shares; redemption pays no protocol fee.
- The vault enforces a 20-second redemption cooldown after a mint for oneself.
- The vault is owned by a 48-hour timelock; its parameters can change through it.
113 changes: 113 additions & 0 deletions typescript/agentkit/src/action-providers/gblin/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* GBLIN vault (Global Balanced Liquidity Index) on Base mainnet. The vault is the ERC-20 share token.
* Verified source: https://basescan.org/address/0xc2181d975c05c8c724b334bcED0764c0b86B1D53#code
*/
export const GBLIN_ADDRESS = "0xc2181d975c05c8c724b334bcED0764c0b86B1D53";

/**
* GBLIN Lens: read-only quotes and state for the vault.
*/
export const GBLIN_LENS_ADDRESS = "0xfCFea8027019E8551A1f09AD91532471F5D26f61";

/**
* GBLIN Zap: redeems shares in kind and sells every basket leg for ETH in one transaction.
*/
export const GBLIN_ZAP_ADDRESS = "0x0E9D6Ceb6D313b021622C121Cda9C62e86e60200";

/**
* Uniswap V3 fee tier (0.05%) used to route every basket leg in the Zap exit.
*/
export const VENUE_FEE_TIER = 500;

export const GBLIN_ABI = [
{
type: "function",
name: "buyGBLIN",
stateMutability: "payable",
inputs: [{ name: "minOut", type: "uint256" }],
outputs: [],
},
{
type: "function",
name: "isNavReliable",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "bool" }],
},
{
type: "function",
name: "totalSupply",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "allowance",
stateMutability: "view",
inputs: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;

export const GBLIN_LENS_ABI = [
{
type: "function",
name: "quoteBuy",
stateMutability: "view",
inputs: [
{ name: "vault", type: "address" },
{ name: "ethValue", type: "uint256" },
],
outputs: [
{ name: "out", type: "uint256" },
{ name: "protocolFee", type: "uint256" },
{ name: "stabilityFee", type: "uint256" },
],
},
{
type: "function",
name: "quoteSell",
stateMutability: "view",
inputs: [
{ name: "vault", type: "address" },
{ name: "gblinAmount", type: "uint256" },
],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "basketLength",
stateMutability: "view",
inputs: [{ name: "vault", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
] as const;

export const GBLIN_ZAP_ABI = [
{
type: "function",
name: "sellGBLINForEth",
stateMutability: "nonpayable",
inputs: [
{ name: "shares", type: "uint256" },
{ name: "minEthOut", type: "uint256" },
{ name: "venueData", type: "bytes[]" },
{ name: "receiver", type: "address" },
],
outputs: [{ name: "ethOut", type: "uint256" }],
},
] as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { encodeAbiParameters, encodeFunctionData, parseEther, parseUnits } from "viem";
import { EvmWalletProvider } from "../../wallet-providers";
import { GblinActionProvider } from "./gblinActionProvider";
import {
GBLIN_ABI,
GBLIN_ADDRESS,
GBLIN_LENS_ADDRESS,
GBLIN_ZAP_ABI,
GBLIN_ZAP_ADDRESS,
} from "./constants";

const MOCK_TX_HASH = "0xabcdef1234567890";
const MOCK_RECEIPT = { status: "success", blockNumber: 1234567n };
const MOCK_ADDRESS = "0x9876543210987654321098765432109876543210";
const BPS = 10_000n;
const DEFAULT_SLIPPAGE_BPS = 100n;
const VENUE = encodeAbiParameters([{ type: "uint24" }], [500]);

describe("GBLIN Action Provider", () => {
const actionProvider = new GblinActionProvider();
let mockWallet: jest.Mocked<EvmWalletProvider>;

beforeEach(() => {
mockWallet = {
getAddress: jest.fn().mockReturnValue(MOCK_ADDRESS),
getNetwork: jest.fn().mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet" }),
sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH as `0x${string}`),
waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT),
readContract: jest.fn(),
} as unknown as jest.Mocked<EvmWalletProvider>;
});

describe("buyGblin", () => {
it("should buy GBLIN on the vault with a quote-derived minOut", async () => {
const expectedOut = parseUnits("0.03", 18);
const minOut = (expectedOut * (BPS - DEFAULT_SLIPPAGE_BPS)) / BPS;
mockWallet.readContract
.mockResolvedValueOnce(true) // isNavReliable
.mockResolvedValueOnce([expectedOut, 0n, 0n]); // Lens.quoteBuy

const response = await actionProvider.buyGblin(mockWallet, { ethAmount: "0.1" });

expect(mockWallet.readContract).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
address: GBLIN_LENS_ADDRESS,
functionName: "quoteBuy",
args: [GBLIN_ADDRESS, parseEther("0.1")],
}),
);
expect(mockWallet.sendTransaction).toHaveBeenCalledWith({
to: GBLIN_ADDRESS as `0x${string}`,
data: encodeFunctionData({
abi: GBLIN_ABI,
functionName: "buyGBLIN",
args: [minOut],
}),
value: parseEther("0.1"),
});
expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH);
expect(response).toContain(MOCK_TX_HASH);
});

it("should report a reverted purchase as an error", async () => {
mockWallet.readContract
.mockResolvedValueOnce(true)
.mockResolvedValueOnce([10n ** 16n, 0n, 0n]);
mockWallet.waitForTransactionReceipt.mockResolvedValueOnce({ status: "reverted" });
const response = await actionProvider.buyGblin(mockWallet, { ethAmount: "0.1" });
expect(response).toContain("reverted");
});

it("should reject a non-positive amount", async () => {
const response = await actionProvider.buyGblin(mockWallet, { ethAmount: "0" });
expect(response).toContain("must be greater than 0");
});

it("should not buy while the NAV is not reliable", async () => {
mockWallet.readContract.mockResolvedValueOnce(false);
const response = await actionProvider.buyGblin(mockWallet, { ethAmount: "0.1" });
expect(response).toContain("not reliable");
expect(mockWallet.sendTransaction).not.toHaveBeenCalled();
});

it("should handle a zero on-chain quote", async () => {
mockWallet.readContract.mockResolvedValueOnce(true).mockResolvedValueOnce([0n, 0n, 0n]);
const response = await actionProvider.buyGblin(mockWallet, { ethAmount: "0.1" });
expect(response).toContain("zero");
expect(mockWallet.sendTransaction).not.toHaveBeenCalled();
});
});

describe("sellGblinForEth", () => {
const shares = parseUnits("5", 18);
const expectedEth = parseEther("0.2");
const minEthOut = (expectedEth * (BPS - DEFAULT_SLIPPAGE_BPS)) / BPS;
const zapCall = {
to: GBLIN_ZAP_ADDRESS as `0x${string}`,
data: encodeFunctionData({
abi: GBLIN_ZAP_ABI,
functionName: "sellGBLINForEth",
args: [shares, minEthOut, [VENUE, VENUE, VENUE], MOCK_ADDRESS],
}),
};

it("should approve the Zap and redeem through it when the allowance is short", async () => {
mockWallet.readContract
.mockResolvedValueOnce(true) // isNavReliable
.mockResolvedValueOnce(expectedEth) // Lens.quoteSell
.mockResolvedValueOnce(3n) // Lens.basketLength
.mockResolvedValueOnce(0n); // allowance

const response = await actionProvider.sellGblinForEth(mockWallet, { gblinAmount: "5" });

expect(mockWallet.sendTransaction).toHaveBeenNthCalledWith(1, {
to: GBLIN_ADDRESS as `0x${string}`,
data: encodeFunctionData({
abi: GBLIN_ABI,
functionName: "approve",
args: [GBLIN_ZAP_ADDRESS, shares],
}),
});
expect(mockWallet.sendTransaction).toHaveBeenNthCalledWith(2, zapCall);
expect(response).toContain(MOCK_TX_HASH);
});

it("should skip the approval when the allowance already covers the shares", async () => {
mockWallet.readContract
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(expectedEth)
.mockResolvedValueOnce(3n)
.mockResolvedValueOnce(shares);

await actionProvider.sellGblinForEth(mockWallet, { gblinAmount: "5" });

expect(mockWallet.sendTransaction).toHaveBeenCalledTimes(1);
expect(mockWallet.sendTransaction).toHaveBeenCalledWith(zapCall);
});

it("should not sell while the NAV is not reliable", async () => {
mockWallet.readContract.mockResolvedValueOnce(false);
const response = await actionProvider.sellGblinForEth(mockWallet, { gblinAmount: "5" });
expect(response).toContain("not reliable");
expect(mockWallet.sendTransaction).not.toHaveBeenCalled();
});

it("should handle errors when redeeming", async () => {
mockWallet.readContract
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(expectedEth)
.mockResolvedValueOnce(3n)
.mockResolvedValueOnce(shares);
mockWallet.sendTransaction.mockRejectedValue(new Error("Failed"));
const response = await actionProvider.sellGblinForEth(mockWallet, { gblinAmount: "5" });
expect(response).toContain("Error redeeming GBLIN");
});
});

describe("getGblinState", () => {
it("should return live state as JSON", async () => {
mockWallet.readContract
.mockResolvedValueOnce(parseEther("0.04")) // Lens.quoteSell(1)
.mockResolvedValueOnce(parseUnits("0.5", 18)) // totalSupply
.mockResolvedValueOnce(true); // isNavReliable

const response = await actionProvider.getGblinState(mockWallet, {});
const parsed = JSON.parse(response);
expect(parsed.contract).toBe(GBLIN_ADDRESS);
expect(parsed.network).toBe("base-mainnet");
expect(parsed.ethValuePerGblin).toBe("0.04");
expect(parsed.totalSupply).toBe("0.5");
expect(parsed.navReliable).toBe(true);
});
});

describe("supportsNetwork", () => {
it("should return true for Base Mainnet", () => {
expect(
actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }),
).toBe(true);
});

it("should return false for other EVM networks", () => {
expect(actionProvider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum" })).toBe(
false,
);
});

it("should return false for non-EVM networks", () => {
expect(
actionProvider.supportsNetwork({ protocolFamily: "bitcoin", networkId: "base-mainnet" }),
).toBe(false);
});
});
});
Loading
Loading