Skip to content

Add portfolioRebalance action provider (read-only plan_rebalance)#4

Open
zaryab2000 wants to merge 1 commit into
feat/swapToTargetAllocationfrom
claude/compassionate-mayer-geuH8
Open

Add portfolioRebalance action provider (read-only plan_rebalance)#4
zaryab2000 wants to merge 1 commit into
feat/swapToTargetAllocationfrom
claude/compassionate-mayer-geuH8

Conversation

@zaryab2000

Copy link
Copy Markdown
Owner

Summary

Adds a new portfolioRebalance ActionProvider for Base mainnet with a single, read-only action: plan_rebalance.

It reads the wallet's known token balances, prices them in USD via the keyless DefiLlama price API, measures each holding's drift from a requested target allocation (weights in basis points), and computes the minimum set of swaps to reach the target using a greedy largest-surplus → largest-deficit match. It returns a plan and never executes any transaction.

This is the PRD's recommended safest first slice (read-only advisor). The execution action (rebalance_portfolio) is intentionally deferred to a follow-up.

Differentiation (PRD §0.6 — important)

This is a portfolio rebalancer, not a DEX router. It operates one layer up — at the portfolio level: read all balances → value in USD → compute drift from target weights → plan the minimum set of swaps. It does not implement a new routing engine and does not compete with single-swap best-execution PRs (EZ-Path coinbase#1219/coinbase#1226, paladinTrust coinbase#1245); the future execution action is designed to compose the existing in-tree zeroX/enso swap actions, not replace them.

What's included

  • portfolioRebalanceActionProvider.tsplan_rebalance action (Promise<string>, errors returned not thrown)
  • schemas.ts — plain z.object().describe() on every field, no .strip()
  • constants.ts — Base token registry, DefiLlama price config
  • utils.ts — valuation, drift, greedy min-swap planner, pricing, balance reads
  • portfolioRebalanceActionProvider.test.ts — 9 unit tests (mocked wallet + fetch)
  • index.ts, README.md
  • Factory re-exported from src/action-providers/index.ts
  • Changeset (patch, past tense)

Behavior & edge cases

  • supportsNetwork: true only for Base mainnet (evm, chainId 8453)
  • Target weights must sum to exactly 10000 bps → otherwise returns an error
  • Drift below rebalanceThresholdBps (default 100) is ignored as dust → no-op plan
  • Held token with no available USD price → reported as a warning and excluded from valuation
  • plan_rebalance performs zero state-changing calls (asserted in tests)

Checks (all green locally)

  • pnpm build
  • pnpm test ✅ (9/9 for this provider)
  • pnpm run lint
  • pnpm run format

Notes

This PR targets the internal feat/swapToTargetAllocation branch. The upstream PRE-FINAL-PR steps (strip docs-internal/ + fork tooling, rebase on latest upstream main, signed commits, tracking issue) are not part of this diff and remain to be done before any PR to coinbase/agentkit.

https://claude.ai/code/session_018pxKMbq6hAs4UJwWTUvBgH


Generated by Claude Code

Adds a portfolio-level rebalancer for Base mainnet. The read-only
plan_rebalance action reads the wallet's known token balances, prices
them in USD via the keyless DefiLlama API, measures drift from the
requested target weights, and computes the minimum set of swaps to
reach the target using a greedy largest-surplus to largest-deficit
match. It returns a plan and never executes any transaction.

Includes schemas, constants, utils, unit tests, README, and a
changeset; factory re-exported from action-providers/index.ts.
@github-actions github-actions Bot added documentation Improvements or additions to documentation typescript action provider labels Jun 6, 2026
@zaryab2000

Copy link
Copy Markdown
Owner Author

@claude start review

@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

Claude finished @zaryab2000's task in 2m 28s —— View job


Code Review: portfolioRebalance ActionProvider

Tasks:

  • Load review tools
  • Read all changed files
  • Review ActionProvider implementation
  • Review schemas and constants
  • Review utils (pricing, valuation, planner)
  • Review tests
  • Review index/exports/changeset
  • Post final review findings

Overall Assessment

Solid first-slice submission. The architecture is clean, all conventions from CLAUDE.md are followed, and the read-only guard is consistently enforced throughout. Found a few correctness bugs, one important inconsistency in error-return format, and a handful of polish items worth addressing before upstream submission.


Bugs / Correctness

1. Outer catch returns a plain string, not JSON — callers will blow up on JSON.parse

portfolioRebalanceActionProvider.ts:168

} catch (error) {
  return `Error planning rebalance: ${error}`;  // ← plain string, not JSON
}

Every other error path in this file returns JSON.stringify({ success: false, error: ... }). The outer catch breaks that contract — any caller that does JSON.parse(result) (including the test at line 171 which calls result.toContain("Error") without parsing) will be inconsistent. The test passes only because it doesn't JSON.parse this branch. Should be:

return JSON.stringify({ success: false, error: `Error planning rebalance: ${error}` });

Fix this →


2. estSellAmount has unbounded floating-point precision

utils.ts:248

const estSellAmount = fromPrice && fromPrice > 0 ? (amount / fromPrice).toString() : "0";

(1400 / 60000).toString()"0.023333333333333334". This makes the output noisy and hard to consume. Round to the token's decimal precision (or at minimum 8 significant figures): e.g. (amount / fromPrice).toFixed(8).

Fix this →


3. readBalances makes 7 serial RPC calls instead of parallel

utils.ts:109-119

for (const token of tokens) {       // sequential await
  const raw = (await walletProvider.readContract(...)) as bigint;
  balances.push({ token, raw });
}

7 sequential RPC round-trips on every plan_rebalance call. Use Promise.all:

const balances = await Promise.all(
  tokens.map(async token => ({
    token,
    raw: (await walletProvider.readContract({
      address: getAddress(token.address) as Hex,
      abi: erc20Abi,
      functionName: "balanceOf",
      args: [owner],
    })) as bigint,
  })),
);

Fix this →


4. computeAllocation weightBps may not sum to 10000 due to per-token rounding

utils.ts:183

weightBps: totalUsd > 0 ? Math.round((p.usdValue / totalUsd) * 10000) : 0,

Rounding each token independently can leave the total at 9999 or 10001 bps — the output reports a portfolio that doesn't add up to 100%, which looks like a bug to users. The standard fix is a largest-remainder pass, or simply a one-time residual correction on the largest-weighted token.


Schema / Validation Issues

5. rebalanceThresholdBps should use .optional() not .nullable()

schemas.ts:29-34

rebalanceThresholdBps: z.number().int().nullable().default(100)

.nullable() allows callers to explicitly pass { rebalanceThresholdBps: null } and receive the default — this is an uncommon pattern that accepts null as a sentinel for "use default". The idiomatic Zod approach is .optional().default(100), which makes the field truly optional and rejects an explicit null. The ?? DEFAULT_REBALANCE_THRESHOLD_BPS fallback in the provider is also dead code since .default(100) already guarantees a non-null value.

Fix this →


6. No range validation on weightBps — accepts 0 and negative values

schemas.ts:19-21

weightBps: z.number().int()

A caller could pass { token: "USDC", weightBps: -500 } and the code would not catch it at the schema layer. Add .min(1).max(10000):

weightBps: z.number().int().min(1).max(10000)
  .describe("...")

Fix this →


7. Empty targets validation belongs in the schema, not runtime

portfolioRebalanceActionProvider.ts:68-70

if (!args.targets || args.targets.length === 0) {
  return JSON.stringify({ success: false, error: "No target allocation was provided." });
}

The schema uses z.array(...) which allows empty arrays. Adding .min(1) to the schema moves this validation to the Zod layer (consistent with the rest of the codebase) and eliminates the manual guard:

targets: z.array(...).min(1).describe("...")

Test Gaps

8. Missing test for the targets: [] / empty array path

The success: false, error: "No target allocation was provided." branch (line 68–69) is not covered. Add a test for targets: [].

9. Missing test for address-based token lookup

resolveToken supports 0x... addresses but no test exercises this path through plan_rebalance. Should add a test with token: BASE_TOKENS.USDC.address.

10. Missing test for duplicate token in targets

The duplicate check at portfolioRebalanceActionProvider.ts:87-89 is not covered by any test.


Minor Style Notes

  • portfolioRebalanceActionProvider.ts:93\${error}`on a caught value gives"Error: message"(the Error class'stoString()), which is fine but error instanceof Error ? error.message : String(error)` is cleaner.
  • index.ts doesn't re-export constants.tsTokenInfo interface is not accessible from the package surface. For a library package this may matter if external code wants to type-check registry entries.
  • index.ts export alphabetical order: portfolioRebalance is inserted between opensea and spl in action-providers/index.ts ✅ — good.

Conventions Checklist (CLAUDE.md)

Check Status
Class extends ActionProvider<EvmWalletProvider>
super("portfolioRebalance", [])
@CreateAction with name, description, schema
Returns Promise<string>
Errors returned, not thrown ⚠️ outer catch returns plain string (Issue #1)
Zod v4 plain .describe(), no .strip()
supportsNetwork arrow-function property
chainId is a string "8453"
Factory export portfolioRebalanceActionProvider (camelCase)
Re-exported from src/action-providers/index.ts
README.md present
Test file present
Changeset: patch, past tense, correct package

Summary

Must fix before upstream PR: Issues #1 (error return inconsistency) and #2 (unbounded float precision in output). Issue #3 (parallel RPC) is a meaningful performance fix. Issues #4coinbase#7 are schema/correctness polish that upstream reviewers will likely flag.

Copy link
Copy Markdown
Owner Author

Code review — portfolioRebalance (read-only plan_rebalance)

CI: ✅ all 25 checks green (lint-typescript, format-typescript, test-agentkit-typescript on Node 18 & 20, plus the Python/lock jobs).

Correctness: No actionable bugs found. I ran finder passes over the planner, valuation/drift math, and null/edge handling, then verified each candidate:

  • Greedy planner loop — safe. amount = Math.min(surplus, deficit) zeroes at least one side each iteration (a − a === 0), so a pointer always advances; the loop is bounded by sources + sinks.
  • estSellAmount='0' for an unpriced source — unreachable. A source is overweight (currentUsd > targetUsd > 0), so it always has a price.
  • Plain-string error return in catchintentional, matches the house convention used by pyth/erc20 (errors returned, not thrown).
  • Token addresses/decimals — verified correct against erc20/constants.ts (USDC 6, WETH 18, CBBTC 8, CBETH 18, EURC 6, AERO 18) and Base DAI 0x50c5…0Cb 18.

Conventions: Zod schemas use plain .describe() on every field, no .strip(); action returns Promise<string>; supportsNetwork is an arrow property scoped to Base (chainId === "8453"); factory exported and re-exported from action-providers/index.ts; changeset present (patch). All good.

Optional polish (non-blocking)

utils.ts (planRebalance): estSellAmount is (amount / fromPrice).toString(), which can render long repeating decimals (e.g. "30.003000300030003"). Since it's a read-only estimate this is harmless, but rounding to the from token's decimals would make the plan output tidier. Happy to apply if you'd like.

LGTM otherwise. ✅


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action provider documentation Improvements or additions to documentation typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants