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 typescript/.changeset/portfolio-rebalance-action-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a portfolio rebalance action provider with a read-only plan_rebalance action that values a wallet's Base holdings in USD and plans the minimum set of swaps to reach a target allocation.
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from "./pyth";
export * from "./moonwell";
export * from "./morpho";
export * from "./opensea";
export * from "./portfolioRebalance";
export * from "./spl";
export * from "./superfluid";
export * from "./sushi";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Portfolio Rebalance Action Provider

This directory contains the `PortfolioRebalanceActionProvider` implementation, which plans
the minimum set of swaps required to move a wallet from its current token allocation to a
requested target allocation on **Base mainnet**.

This is a **portfolio-level rebalancer, not a DEX router.** It reads all known holdings,
values them in USD, measures each token's drift from the requested target weights, and
computes the smallest set of source→sink swaps to restore the target. It does **not**
implement a routing engine and does **not** compete with single-swap best-execution
providers — a future execution action is intended to **compose** the existing in-tree
`zeroX` / `enso` swap actions rather than replace them.

## Actions

| Action | Description |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plan_rebalance` | **Read-only.** Reads balances, prices them in USD, computes drift from the target allocation, and returns the planned swaps. Never executes any transaction. |

### `plan_rebalance`

**Inputs:**

- `targets`: list of `{ token, weightBps }`, where `token` is a supported symbol (or 0x
address) and `weightBps` is the target weight in basis points. Weights **must sum to
exactly 10000** (100%).
- `rebalanceThresholdBps` _(optional, default 100)_: minimum absolute drift in basis points
before a token is included in the plan. Drift below this is treated as dust and ignored.

**Output:** stringified JSON containing the current weights, per-token drift, the planned
swaps (`from`, `to`, `amountUsd`, `estSellAmount`), the total USD value, and whether a
rebalance is needed.

## Planning algorithm

Drift is computed per token as `currentWeightBps − targetWeightBps`. Tokens above target
(beyond the threshold) are **sources**; tokens below target are **sinks**. The planner uses
a **greedy largest-surplus → largest-deficit** match in USD space: it repeatedly pairs the
biggest remaining surplus with the biggest remaining deficit until all deficits are filled.

This greedy approach minimizes the number of swap legs and is fully explainable. A
holistic/global optimizer could in some cases reduce total notional further; greedy is the
v1 choice for predictability and is sufficient for typical baskets.

## Pricing

USD prices come from the keyless [DefiLlama price API](https://coins.llama.fi). A held token
with no available price is reported as a warning and excluded from the valuation.

## Supported tokens (v1)

USDC, WETH, CBBTC, CBETH, EURC, DAI, AERO — all on Base mainnet.

## Network support

Base mainnet only (`evm`, chain ID `8453`).

## Adding to AgentKit

The factory is re-exported from `src/action-providers/index.ts` as
`portfolioRebalanceActionProvider`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* The network ID this action provider supports in v1 (Base mainnet).
*/
export const SUPPORTED_NETWORK_ID = "base-mainnet";

/**
* The chain ID this action provider supports in v1 (Base mainnet).
*/
export const BASE_CHAIN_ID = "8453";

/**
* Base URL for the DefiLlama price API (keyless).
*/
export const DEFILLAMA_PRICES_URL = "https://coins.llama.fi";

/**
* The DefiLlama chain prefix used to key Base mainnet token prices.
*/
export const DEFILLAMA_CHAIN_PREFIX = "base";

/**
* Default minimum absolute drift (in basis points) before a token is rebalanced.
*/
export const DEFAULT_REBALANCE_THRESHOLD_BPS = 100;

/**
* Metadata describing a token in the supported registry.
*/
export interface TokenInfo {
/** The token's display symbol. */
symbol: string;
/** The token's contract address on Base mainnet. */
address: string;
/** The token's ERC-20 decimals. */
decimals: number;
}

/**
* The v1 registry of tokens the portfolio rebalancer understands on Base mainnet.
* Balances are read for every token in this registry so that holdings outside the
* requested target set are still valued and treated as rebalance sources.
*/
export const BASE_TOKENS: Record<string, TokenInfo> = {
USDC: { symbol: "USDC", address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6 },
WETH: { symbol: "WETH", address: "0x4200000000000000000000000000000000000006", decimals: 18 },
CBBTC: { symbol: "CBBTC", address: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", decimals: 8 },
CBETH: { symbol: "CBETH", address: "0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22", decimals: 18 },
EURC: { symbol: "EURC", address: "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", decimals: 6 },
DAI: { symbol: "DAI", address: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", decimals: 18 },
AERO: { symbol: "AERO", address: "0x940181a94A35A4569E4529A3CDfB74e38FD98631", decimals: 18 },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./portfolioRebalanceActionProvider";
export * from "./schemas";
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { EvmWalletProvider } from "../../wallet-providers";
import { BASE_TOKENS } from "./constants";
import { portfolioRebalanceActionProvider } from "./portfolioRebalanceActionProvider";

const WALLET_ADDRESS = "0x1234567890123456789012345678901234567890";

const USDC = BASE_TOKENS.USDC.address.toLowerCase();
const WETH = BASE_TOKENS.WETH.address.toLowerCase();
const CBBTC = BASE_TOKENS.CBBTC.address.toLowerCase();
const AERO = BASE_TOKENS.AERO.address.toLowerCase();

describe("PortfolioRebalanceActionProvider", () => {
const fetchMock = jest.fn();
global.fetch = fetchMock;

const provider = portfolioRebalanceActionProvider();
let walletProvider: jest.Mocked<EvmWalletProvider>;

/**
* Queues a DefiLlama-style price response for the given address->price map.
*
* @param prices - Map of lowercased token address to USD price.
*/
const mockPrices = (prices: Record<string, number>) => {
const coins: Record<string, { price: number; decimals: number; symbol: string }> = {};
for (const [address, price] of Object.entries(prices)) {
coins[`base:${address}`] = { price, decimals: 18, symbol: "TKN" };
}
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ coins }) });
};

beforeEach(() => {
jest.resetAllMocks();
global.fetch = fetchMock;

walletProvider = {
getAddress: jest.fn().mockReturnValue(WALLET_ADDRESS),
getNetwork: jest
.fn()
.mockReturnValue({ protocolFamily: "evm", networkId: "base-mainnet", chainId: "8453" }),
readContract: jest.fn(),
sendTransaction: jest.fn(),
waitForTransactionReceipt: jest.fn(),
} as unknown as jest.Mocked<EvmWalletProvider>;
});

/**
* Configures the wallet's readContract mock to return raw balances by token address.
*
* @param balances - Map of lowercased token address to raw bigint balance.
*/
const mockBalances = (balances: Record<string, bigint>) => {
walletProvider.readContract.mockImplementation(async params => {
const address = (params as { address: string }).address;
return (balances[address.toLowerCase()] ?? 0n) as never;
});
};

describe("plan_rebalance", () => {
it("plans the minimum set of swaps from an overweight token into the deficits", async () => {
// 5000 USDC ($5000) + 1 WETH ($2000) = $7000 total.
mockBalances({
[USDC]: 5_000_000_000n, // 5000 * 1e6
[WETH]: 1_000_000_000_000_000_000n, // 1 * 1e18
});
mockPrices({ [USDC]: 1, [WETH]: 2000, [CBBTC]: 60000 });

const result = await provider.planRebalance(walletProvider, {
targets: [
{ token: "USDC", weightBps: 5000 },
{ token: "WETH", weightBps: 3000 },
{ token: "CBBTC", weightBps: 2000 },
],
rebalanceThresholdBps: 100,
});

const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.totalUsd).toBe(7000);
expect(parsed.rebalanceNeeded).toBe(true);

// USDC is overweight ($5000 vs $3500 target) and is the only source.
// Greedy fills the largest deficit (CBBTC $1400) first, then WETH ($100).
expect(parsed.swaps).toHaveLength(2);
expect(parsed.swaps[0]).toMatchObject({ from: "USDC", to: "CBBTC", amountUsd: 1400 });
expect(parsed.swaps[1]).toMatchObject({ from: "USDC", to: "WETH", amountUsd: 100 });

// Read-only: never executes.
expect(walletProvider.sendTransaction).not.toHaveBeenCalled();
});

it("returns no-op when drift is within the threshold", async () => {
// Current weights ~71.43% USDC / 28.57% WETH; target matches.
mockBalances({
[USDC]: 5_000_000_000n,
[WETH]: 1_000_000_000_000_000_000n,
});
mockPrices({ [USDC]: 1, [WETH]: 2000 });

const result = await provider.planRebalance(walletProvider, {
targets: [
{ token: "USDC", weightBps: 7143 },
{ token: "WETH", weightBps: 2857 },
],
rebalanceThresholdBps: 100,
});

const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.rebalanceNeeded).toBe(false);
expect(parsed.swaps).toHaveLength(0);
expect(parsed.note).toContain("within the drift threshold");
expect(walletProvider.sendTransaction).not.toHaveBeenCalled();
});

it("rejects target weights that do not sum to 10000", async () => {
const result = await provider.planRebalance(walletProvider, {
targets: [
{ token: "USDC", weightBps: 5000 },
{ token: "WETH", weightBps: 4000 },
],
rebalanceThresholdBps: 100,
});

const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
expect(parsed.error).toContain("must sum to exactly 10000");
expect(walletProvider.readContract).not.toHaveBeenCalled();
});

it("returns an error for an unsupported target token", async () => {
const result = await provider.planRebalance(walletProvider, {
targets: [
{ token: "FOO", weightBps: 5000 },
{ token: "USDC", weightBps: 5000 },
],
rebalanceThresholdBps: 100,
});

const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
expect(parsed.error).toContain("Unsupported token");
});

it("warns about and excludes held tokens with no available price", async () => {
mockBalances({
[USDC]: 5_000_000_000n,
[AERO]: 1_000_000_000_000_000_000n, // held but unpriced
});
mockPrices({ [USDC]: 1 }); // no AERO price

const result = await provider.planRebalance(walletProvider, {
targets: [{ token: "USDC", weightBps: 10000 }],
rebalanceThresholdBps: 100,
});

const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.totalUsd).toBe(5000);
expect(parsed.warnings?.[0]).toContain("AERO");
});

it("returns an error string (not a throw) when balance reads fail", async () => {
walletProvider.readContract.mockRejectedValue(new Error("rpc down"));

const result = await provider.planRebalance(walletProvider, {
targets: [{ token: "USDC", weightBps: 10000 }],
rebalanceThresholdBps: 100,
});

expect(result).toContain("Error planning rebalance");
expect(result).toContain("rpc down");
});
});

describe("supportsNetwork", () => {
it("returns true only for Base mainnet (evm, chainId 8453)", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "8453" })).toBe(true);
});

it("returns false for other EVM chains", () => {
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "1" })).toBe(false);
expect(provider.supportsNetwork({ protocolFamily: "evm", chainId: "84532" })).toBe(false);
});

it("returns false for non-EVM networks", () => {
expect(provider.supportsNetwork({ protocolFamily: "svm", chainId: "8453" })).toBe(false);
});
});
});
Loading
Loading