Subchapter 2.26
plugins/yo.mdMarkdown18 KBView on GitHub
[!IMPORTANT] Complete the short Base MCP onboarding flow defined in
SKILL.mdbefore calling anything here. The user’s wallet address — required for positions and writes — is fetched lazily viaget_walletswhen a flow needs it. This plugin is 100% on-chain: it makes no HTTP calls and needs no allowlist.
YO Protocol is an ERC-4626 yield aggregator with ERC-7540-style async redemption, live on Base (8453), Ethereum (1), and Arbitrum (42161). Both deposit and withdraw go through the YO Gateway (0xF1EeE0957267b1A474323Ff9CfF7719E964969FA, same address on every chain) — never direct vault calls. The Gateway handles slippage, partner attribution, and instant/async routing: a single redeem settles instantly when the vault has idle assets, otherwise it queues and a YO solver fulfills it on-chain within ~24h — the user never sends a second transaction.
This plugin builds unsigned calldata from a baked-in vault registry and submits it via Base MCP send_calls; all reads go through chain_rpc_request (eth_call). Everything is derived on-chain — no external service, no CLI, no allowlist. Live APY/yield is not on-chain data, so getVaults reports on-chain TVL and share price but not APY. Deposits are same-chain only — cross-chain/zap routing is out of scope; use the YO dapp for that.
Everything routes through Base MCP’s own onchain tools, so the plugin behaves identically on every surface (Claude Code, Cursor, Codex, Claude.ai, ChatGPT). No shell, no allowlist.
| Capability | Path | Surface notes |
|---|---|---|
getVaults (list, TVL, share price) | static registry + chain_rpc_request (eth_call → totalAssets/totalSupply) | Works everywhere. APY/yield unavailable (off-chain only). |
yoPositions (balances, pending redeems) | chain_rpc_request (eth_call) | Works everywhere; block-fresh. |
deposit | chain_rpc_request (read) → send_calls (write) | Works everywhere. Same-chain only. |
withdraw / redeem | chain_rpc_request (read) → send_calls (write) | Works everywhere. |
No shell-less or chat-only fallback is needed because chain_rpc_request and send_calls are Base MCP server tools available on all surfaces. Operate only on base, ethereum, arbitrum (the chains Base MCP supports where YO is deployed). YO also has deployments on chains Base MCP can’t reach (e.g. X Layer, Katana) — those are omitted; direct the user to the YO dapp for them.
All reads are chain_rpc_request{ chain, method:"eth_call", params:[{ to, data }, "latest"] }. Encode calldata by left-padding each argument to a 32-byte word and concatenating after the 4-byte selector (see Calldata reference). Always pass the chain name (base/ethereum/arbitrum), never the numeric ID.
The vault set is the baked-in Vault registry (verified on-chain). For live numbers, read each vault on its chain:
For each registry deployment {chain, share token, underlying decimals}:
totalAssets() → eth_call to=<share token> data="0x01e1d114" → TVL in underlying units
totalSupply() → eth_call to=<share token> data="0x18160ddd" → total shares
sharePrice = totalAssets / totalSupply (both raw; scale by underlying decimals to display)
Report: vault, chain(s), underlying, formatUnits(totalAssets, decimals) as TVL, sharePrice.APY/yield is not derivable on-chain, so don’t report it. If the user asks “vaults by APY,” say APY isn’t available from on-chain data and offer TVL/share-price instead, or point them at the YO dapp.
A user’s holdings, read on-chain — block-fresh. This mirrors the dapp (usePositionValues → useChainSharesBalances + useChainConvertToAssetsBatch): a logical position aggregates every deployment that shares a vault id across chains.
1. get_wallets → user
2. Build the deployment set from the registry: each row's { chain, share token, underlying symbol, underlying decimals }.
3. For each deployment, on its own chain:
balanceOf(user) eth_call to=<share token> data="0x70a08231"+pad32(user) → shares (skip if 0)
convertToAssets(shares) eth_call to=<share token> data="0x07a2d13a"+pad32(shares) → assets (gross, no fee)
4. Aggregate by (vault id + underlying symbol) — Σ assets across deployments with the SAME underlying.
• yoUSD on base + ethereum + arbitrum are all USDC → they SUM into one yoUSD position.
• yoUSDT (USDT) is a yoUSD secondary but a DIFFERENT underlying → report it as its own line, do NOT add to USDC.
(The dapp only merges them because it first converts each to USD; with no price feed, keep distinct tokens apart.)
5. Report each (vault, underlying) total as formatUnits(assets, underlying decimals) labelled in the underlying symbol
(e.g. "yoUSD: 7.188787 USDC", "yoUSD via yoUSDT: 0.651 USDT", "yoETH: 0.001822 WETH").
6. Pending redeems — for each deployment:
pendingRedeemRequest(user) eth_call to=<share token> data="0x53dc1dd3"+pad32(user) → (pendingAssets, pendingShares)
Flag non-zero pendingShares as "redeem in progress (~24h, no action needed)".Use convertToAssets, not previewRedeem, for displayed value — previewRedeem deducts the withdrawal fee and understates the holding. Report in underlying-asset units; do not attempt a USD conversion, and never use get_portfolio for YO vault tokens (its pricer mis-handles ERC-4626 shares — see Notes).
Same-chain deposit only. Approve the underlying to the Gateway, then call Gateway.deposit.
1. get_wallets → user
2. Resolve vault + underlying (address, decimals) from the registry for the requested chain.
amountIn = raw integer in the underlying's smallest unit (1 USDC = 1000000). Never scientific notation.
3. convertToShares(amountIn) eth_call to=<share token> data="0xc6e6f592"+pad32(amountIn) → expectedShares
minSharesOut = expectedShares * (10000 - slippageBps) / 10000 (default slippageBps = 50)
4. send_calls(chain, calls=[
{ to:<underlying>, value:"0x0", data:"0x095ea7b3"+pad32(Gateway)+pad32(amountIn) }, // approve
{ to:"0xF1EeE0957267b1A474323Ff9CfF7719E964969FA", value:"0x0",
data:"0x82b78ba7"+pad32(vault)+pad32(amountIn)+pad32(minSharesOut)+pad32(user)+pad32(partnerId) } // deposit
])
5. User approves → get_request_status(requestId) → confirmed.partnerId is uint32 — pass 0. For native ETH into yoETH there is no ERC-20 to approve: the underlying is WETH, so the user must hold WETH first (wrap, then the two-call batch above). This plugin is same-chain only — cross-chain/zap deposits are out of scope; use the YO dapp.
Approve the vault share token to the Gateway (first time only), then call Gateway.redeem.
1. get_wallets → user
2. balanceOf(user) on the share token → shares (for "withdraw all", redeem the full balance)
3. previewRedeem(shares) eth_call to=<share token> data="0x4cdad506"+pad32(shares) → expectedAssets (fee-adjusted)
minAssetsOut = expectedAssets * (10000 - slippageBps) / 10000 (default slippageBps = 50)
4. allowance(user, Gateway) on the share token → if < shares, prepend an approve in step 5
5. send_calls(chain, calls=[
// only if allowance < shares:
{ to:<vault>, value:"0x0", data:"0x095ea7b3"+pad32(Gateway)+pad32(maxUint256) }, // approve
{ to:"0xF1EeE0957267b1A474323Ff9CfF7719E964969FA", value:"0x0",
data:"0x99519ab8"+pad32(vault)+pad32(shares)+pad32(minAssetsOut)+pad32(user)+pad32(partnerId) } // redeem
])
6. User approves → get_request_status(requestId) → confirmed.After submission the redeem either settled instantly or queued. To check progress, poll pendingRedeemRequest(user) on the vault — when pendingShares == 0 and the user’s underlying balance has grown, it settled. No user-side claim transaction is needed for async redeems — a YO solver fulfills them on-chain.
Target tool: send_calls (EIP-5792 batch of unsigned { to, value, data } calls). The plugin produces raw calldata locally — there is no semantic swap/send step and no external service in the path; reads use chain_rpc_request and never touch send_calls.
Mapping into send_calls:
chain — the deployment’s chain name: base, ethereum, or arbitrum (never the numeric ID).calls[] — ordered, approval before action:
[ approve(underlying → Gateway, amountIn), Gateway.deposit(...) ][ approve(vault share → Gateway, maxUint256)?, Gateway.redeem(...) ] (approve omitted when allowance ≥ shares)to = target contract, value = "0x0" (no native value — yoETH uses WETH), data = selector + 32-byte-padded args.get_request_status(requestId) for confirmation. See ../references/approval-mode.md for the approval URL flow and ../references/batch-calls.md for batching.“Show me the YO vaults.”
chain_rpc_request totalAssets() + totalSupply() → TVL and share price.“What’s my position in yoUSD?”
get_wallets → user.base, ethereum, arbitrum (all USDC): balanceOf(user) (skip 0) → convertToAssets(shares); sum the three (same underlying).pendingRedeemRequest(user) on each → flag any in-flight redeem.“Deposit 1 USDC into yoUSD on Base.”
get_wallets → user; amountIn = 1000000.convertToShares(1000000) on yoUSD/base → expectedShares; minSharesOut = expectedShares*9950/10000.send_calls(chain="base", calls=[ approve(USDC → Gateway, 1000000), Gateway.deposit(yoUSD, 1000000, minSharesOut, user, 0) ]).get_request_status.“Withdraw all my yoUSD on Base.”
get_wallets → user.balanceOf(user) on yoUSD/base → shares; previewRedeem(shares) → expectedAssets; minAssetsOut = expectedAssets*9950/10000.allowance(user, Gateway) → approve needed?send_calls(chain="base", calls=[ approve(yoUSD → Gateway, max)?, Gateway.redeem(yoUSD, shares, minAssetsOut, user, 0) ]).get_request_status.minSharesOut and withdraws set minAssetsOut from a fresh on-chain quote with a default 50 bps tolerance; the call reverts rather than filling materially worse. Show the user the expected shares/assets before they approve. Never silently widen slippage — if a quote looks off, stop and surface it.send_calls broadcasts on-chain writes that cannot be undone. Verify vault address, amount, recipient (user), and chain before presenting the approval. A queued async redeem settles on its own (~24h) — don’t resubmit it as a second transaction.cast send, or browser-wallet helper. Do not sign or broadcast outside Base MCP.amountIn / shares are raw base-unit integer strings — never scientific notation.| Chain | ID | Base MCP chain |
|---|---|---|
| Base | 8453 | base |
| Ethereum | 1 | ethereum |
| Arbitrum | 42161 | arbitrum |
The same vault id is deployed at the same share-token address on every chain (e.g. yoUSD is 0x0000000f… on Base, Ethereum, and Arbitrum) — the differentiator is the chain you call it on.
Baked-in and verified on-chain (symbol, decimals, and asset() read live on each chain). One row per deployment; the share token is the ERC-4626 vault you read/redeem, the underlying is what you approve for deposits.
| Vault | Chain | Share token (vault) | Sh. dec | Underlying | Underlying address |
|---|---|---|---|---|---|
yoETH | base | 0x3A43AEC53490CB9Fa922847385D82fe25d0E9De7 | 18 | WETH | 0x4200000000000000000000000000000000000006 |
yoETH | ethereum | 0x3A43AEC53490CB9Fa922847385D82fe25d0E9De7 | 18 | WETH | 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 |
yoBTC | base | 0xbCbc8cb4D1e8ED048a6276a5E94A3e952660BcbC | 8 | cbBTC | 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf |
yoBTC | ethereum | 0xbCbc8cb4D1e8ED048a6276a5E94A3e952660BcbC | 8 | cbBTC | 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf |
yoUSD | base | 0x0000000f2eB9f69274678c76222B35eEc7588a65 | 6 | USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
yoUSD | ethereum | 0x0000000f2eB9f69274678c76222B35eEc7588a65 | 6 | USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
yoUSD | arbitrum | 0x0000000f2eB9f69274678c76222B35eEc7588a65 | 6 | USDC | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
yoEUR | base | 0x50c749aE210D3977ADC824AE11F3c7fd10c871e9 | 6 | EURC | 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 |
yoEUR | ethereum | 0x50c749aE210D3977ADC824AE11F3c7fd10c871e9 | 6 | EURC | 0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c |
yoloUSD | base | 0x5DD8BFa6C5C68D05d25EF6143E05C11E26c4cDB7 | 6 | USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
yoloUSD | ethereum | 0x5DD8BFa6C5C68D05d25EF6143E05C11E26c4cDB7 | 6 | USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
yoloUSD | arbitrum | 0x5DD8BFa6C5C68D05d25EF6143E05C11E26c4cDB7 | 6 | USDC | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
yoGOLD | ethereum | 0x586675A3a46B008d8408933cf42d8ff6c9CC61a1 | 6 | XAUt | 0x68749665FF8D2d112Fa859AA293F07A622782F38 |
yoUSDT | ethereum | 0xb9a7da9e90D3B428083BAe04b860faA6325b721e | 6 | USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 |
The share-token address is identical across a vault’s chains; only the underlying address differs per chain (e.g. WETH is 0x4200…0006 on Base but 0xC02a…56Cc2 on Ethereum). Deployments that share a vault id and the same underlying symbol sum into one position — yoUSD (USDC on base/eth/arb), yoETH (WETH on base/eth), yoBTC (cbBTC on base/eth), yoEUR (EURC on base/eth), yoloUSD (USDC on base/eth/arb). yoUSDT is a yoUSD secondary with a different underlying (USDT) — report it as its own USDT line, never added to the USDC total. yoUSD is also deployed on X Layer (196, USDTO) and Katana (747474, vbUSDC); Base MCP can’t reach those, so they’re omitted from on-chain totals — note that to the user and point them at the YO dapp.
Gateway: 0xF1EeE0957267b1A474323Ff9CfF7719E964969FA — verified deployed on Base, Ethereum, and Arbitrum (identical bytecode). It is the spender for all approvals: approve the underlying for deposits, the vault share token for withdraws.
[!CAUTION] Never report YO positions from
get_portfolio. Its pricer does not understand ERC-4626 vault shares and mis-prices them by 10–10,000×. Report positions in underlying-asset units fromconvertToAssets(shares)instead.get_portfoliois fine for plain ERC-20 holdings — just not YO vault tokens.
Encode args by left-padding each value to a 32-byte word and concatenating after the 4-byte selector.
Read selectors (eth_call against the share token):
| Function | Selector | Args | Returns |
|---|---|---|---|
balanceOf(address) | 0x70a08231 | user | shares |
convertToAssets(uint256) | 0x07a2d13a | shares | assets (gross, no fee) |
convertToShares(uint256) | 0xc6e6f592 | assets | shares |
previewRedeem(uint256) | 0x4cdad506 | shares | assets (fee-adjusted) |
pendingRedeemRequest(address) | 0x53dc1dd3 | user | (assets, pendingShares) |
allowance(address,address) | 0xdd62ed3e | owner, spender | current allowance |
totalAssets() | 0x01e1d114 | — | vault TVL (underlying units) |
totalSupply() | 0x18160ddd | — | total shares |
Write selectors (send_calls calldata):
| Function | Selector | Target |
|---|---|---|
ERC-20 approve(address,uint256) | 0x095ea7b3 | underlying or vault |
Gateway deposit(address,uint256,uint256,address,uint32) | 0x82b78ba7 | Gateway |
Gateway redeem(address,uint256,uint256,address,uint32) | 0x99519ab8 | Gateway |
[!WARNING]
partnerIdisuint32. Encoding it asuint256changes the selector to a function that does not exist on the Gateway and the call reverts. The signature must be exactly(address,uint256,uint256,address,uint32). PasspartnerId = 0when no partner ID is assigned.maxUint256=0xff…ff(64 f’s).