From 27ac80f7070eb319429e37305c9ed46427d763f6 Mon Sep 17 00:00:00 2001 From: Tao Wang Date: Sun, 15 Mar 2026 01:28:42 -0700 Subject: [PATCH 1/3] Add fxusd skill for fxSAVE shortcut flow --- README.md | 1 + fxusd/SKILL.md | 96 +++++++++++++ fxusd/references/api.md | 132 ++++++++++++++++++ fxusd/scripts/fxusd_cli.py | 267 +++++++++++++++++++++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 fxusd/SKILL.md create mode 100644 fxusd/references/api.md create mode 100644 fxusd/scripts/fxusd_cli.py diff --git a/README.md b/README.md index 5e0e644b..1172ec4e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Bankr Skills equip builders with plug-and-play tools to build more powerful agen | yoink | [yoink](yoink/) | Social on-chain game. "Yoink" a token from the current holder. Uses Bankr for transaction execution. | | [Neynar](https://neynar.com) | [neynar](neynar/) | Full Farcaster API integration. Post casts, like, recast, follow users, search content, and manage Farcaster identities. | | [Hydrex](https://hydrex.fi) | [hydrex](hydrex/) | Liquidity pools on Base. Lock HYDX for voting power, vote on pool strategies, deposit single-sided liquidity into auto-managed vaults, and claim oHYDX rewards. | +| [f(x) Protocol](https://fxsave.up.railway.app/) | [fxusd](fxusd/) | Shortcut the fxSAVE flow on Base. Mint or redeem fxSAVE without manually bridging between Base and Ethereum mainnet. | ## Adding a Skill diff --git a/fxusd/SKILL.md b/fxusd/SKILL.md new file mode 100644 index 00000000..ba2c9543 --- /dev/null +++ b/fxusd/SKILL.md @@ -0,0 +1,96 @@ +--- +name: fxusd +description: Mint or redeem Base fxSAVE through a public shortcut flow, without manually bridging between Base and Ethereum mainnet. Use when the user wants to deposit fxUSD, USDC, or WETH into fxSAVE, redeem fxSAVE back into Base assets, preview the route, or build approval plus execution payloads from the public fxSAVE app backend. +--- + +# fxUSD + +Shortcut the `fxSAVE` flow on Base. + +**App:** https://fxsave.up.railway.app/ +**Repo:** https://github.com/huwangtao123/fxsave-dapp + +## Why use this skill + +Without the shortcut, using `fxSAVE` from Base means manually thinking through: + +```text +Base -> bridge to mainnet -> deposit or redeem -> bridge back -> Base +``` + +This skill keeps the user interaction simple and lets the app backend build the hidden Enso route. + +## Capabilities + +- Mint Base `fxSAVE` from `fxUSD`, `USDC`, or `WETH` +- Redeem Base `fxSAVE` back into Base assets +- Build the executable bundle payload +- Build the approval payload for the current source token +- Explain async cross-chain settlement clearly + +## Usage examples + +- `Deposit 10 fxUSD to fxSAVE` +- `Deposit 100 USDC to fxSAVE` +- `Redeem 50% of my fxSAVE to fxUSD` +- `Preview the route to mint fxSAVE from 1 WETH` + +## Public endpoints + +- Bundle builder: `POST https://fxsave.up.railway.app/api/fxsave/fxsave-bundle` +- Approval builder: `POST https://fxsave.up.railway.app/api/fxsave/fxsave-approve` + +## Workflow + +1. Determine direction. +- `mint`: Base asset -> Base `fxSAVE` +- `redeem`: Base `fxSAVE` -> Base asset + +2. Resolve tokens. +- For `mint`, provide the selected Base source token metadata. +- For `redeem`, provide the target Base asset metadata. +- Use the connected wallet address for both `fromAddress` and `receiver`. + +3. Build the bundle. +- Call the bundle endpoint. +- Treat `result.tx` as the canonical executable payload. +- Use `flow`, `warnings`, and `bridgingEstimates` to explain what happens. + +4. Check approval. +- Identify the actual source token for the current direction. +- Call the approval endpoint with the raw amount and source token address. +- Compare allowance before sending approval. +- Skip approval if allowance is already sufficient. + +5. Execute. +- Submit approval first when needed. +- Submit the main transaction second. +- Tell the user the Base transaction can confirm before the final bridged asset arrives. + +## Requirements + +- A Base wallet with the input asset and enough ETH for gas +- Access to the public fxSAVE app backend at `https://fxsave.up.railway.app` + +If the agent can sign and submit transactions, it can execute directly. +If not, use this skill for planning and pair it with a transaction execution skill such as `bankr`. + +## Safety rules + +- Do not describe this as same-chain instant settlement. +- Do not invent token decimals or addresses. +- Do not bypass the app backend by manually reconstructing Enso payloads unless the public route is broken. +- Surface route warnings to the user before execution. +- Stop if bundle generation fails instead of guessing a fallback route. + +## Supported defaults + +- `fxUSD` +- `USDC` +- `WETH` +- `fxSAVE` as the redeem source token + +## When to read more + +- Read [references/api.md](references/api.md) for request and response shapes. +- Use `scripts/fxusd_cli.py` for quick local or remote API checks. diff --git a/fxusd/references/api.md b/fxusd/references/api.md new file mode 100644 index 00000000..33f26ecd --- /dev/null +++ b/fxusd/references/api.md @@ -0,0 +1,132 @@ +# fxUSD API Reference + +Public app base URL: + +`https://fxsave.up.railway.app` + +## CLI helper + +Script: +- `scripts/fxusd_cli.py` + +Examples: + +```bash +python3 scripts/fxusd_cli.py mint \ + --from-address 0x... \ + --amount 10 \ + --source-token fxUSD +``` + +```bash +python3 scripts/fxusd_cli.py redeem \ + --from-address 0x... \ + --amount 1 \ + --target-token USDC +``` + +```bash +python3 scripts/fxusd_cli.py approval \ + --from-address 0x... \ + --amount 1 \ + --token fxSAVE +``` + +## Endpoint: `/api/fxsave/fxsave-bundle` + +Method: `POST` + +Purpose: +- Build an executable Enso shortcut bundle for `mint` or `redeem` + +### Mint request + +```json +{ + "amount": "1", + "direction": "mint", + "fromAddress": "0x...", + "receiver": "0x...", + "sourceTokenAddress": "0x55380fe7a1910dff29a47b622057ab4139da42c5", + "sourceTokenSymbol": "fxUSD", + "sourceTokenDecimals": 18 +} +``` + +### Redeem request + +```json +{ + "amount": "1", + "direction": "redeem", + "fromAddress": "0x...", + "receiver": "0x...", + "targetTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "targetTokenSymbol": "USDC", + "targetTokenDecimals": 6 +} +``` + +### Success response shape + +```json +{ + "flow": [], + "result": { + "tx": { + "to": "0x...", + "from": "0x...", + "data": "0x...", + "value": "0" + }, + "amountsOut": {}, + "minAmountsOut": {}, + "bridgingEstimates": [] + }, + "quotePlan": {}, + "warnings": [] +} +``` + +## Endpoint: `/api/fxsave/fxsave-approve` + +Method: `POST` + +Purpose: +- Build the approval tx payload for the current source token + +### Request + +```json +{ + "amount": "1000000000000000000", + "fromAddress": "0x...", + "tokenAddress": "0x273f20fa9fbe803e5d6959add9582dac240ec3be" +} +``` + +### Success response shape + +```json +{ + "result": { + "spender": "0x...", + "amount": "1000000000000000000", + "tx": { + "to": "0x...", + "data": "0x...", + "value": "0" + } + } +} +``` + +## Execution pattern + +1. Build the bundle. +2. Identify the source token for the current direction. +3. Build approval payload for that source token. +4. Compare allowance. +5. Submit approval if needed. +6. Submit `result.tx`. +7. Tell the user final settlement may lag the Base confirmation. diff --git a/fxusd/scripts/fxusd_cli.py b/fxusd/scripts/fxusd_cli.py new file mode 100644 index 00000000..100abf2c --- /dev/null +++ b/fxusd/scripts/fxusd_cli.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from typing import Any + + +DEFAULT_BASE_URL = "https://fxsave.up.railway.app" + + +@dataclass(frozen=True) +class Token: + symbol: str + address: str + decimals: int + + +TOKEN_REGISTRY = { + "fxUSD": Token("fxUSD", "0x55380fe7a1910dff29a47b622057ab4139da42c5", 18), + "USDC": Token("USDC", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 6), + "WETH": Token("WETH", "0x4200000000000000000000000000000000000006", 18), + "fxSAVE": Token("fxSAVE", "0x273f20fa9fbe803e5d6959add9582dac240ec3be", 18), +} + + +def parse_units(value: str, decimals: int) -> str: + normalized = value.strip() + + if not normalized: + raise ValueError("Amount is required.") + + if normalized.count(".") > 1: + raise ValueError(f"Invalid amount: {value}") + + whole, _, fraction = normalized.partition(".") + if not whole: + whole = "0" + + if not whole.isdigit() or (fraction and not fraction.isdigit()): + raise ValueError(f"Invalid amount: {value}") + + padded_fraction = (fraction + ("0" * decimals))[:decimals] + combined = f"{whole}{padded_fraction}".lstrip("0") + return combined or "0" + + +def http_post_json(base_url: str, path: str, payload: dict[str, Any]) -> tuple[int, Any]: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url=f"{base_url.rstrip('/')}{path}", + data=body, + headers={"content-type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(request) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + raw = error.read().decode("utf-8") + try: + return error.code, json.loads(raw) + except json.JSONDecodeError: + return error.code, {"error": raw} + + +def resolve_token(args: argparse.Namespace, prefix: str) -> Token: + preset_value = getattr(args, f"{prefix}_token", None) + custom_address = getattr(args, f"{prefix}_address", None) + custom_symbol = getattr(args, f"{prefix}_symbol", None) + custom_decimals = getattr(args, f"{prefix}_decimals", None) + + if preset_value and preset_value != "custom": + return TOKEN_REGISTRY[preset_value] + + if not custom_address or not custom_symbol or custom_decimals is None: + raise ValueError( + f"Custom {prefix} token requires --{prefix}-address, --{prefix}-symbol, and --{prefix}-decimals." + ) + + return Token(custom_symbol, custom_address, int(custom_decimals)) + + +def print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def mint_command(args: argparse.Namespace) -> int: + source_token = resolve_token(args, "source") + bundle_payload = { + "amount": args.amount, + "direction": "mint", + "fromAddress": args.from_address, + "receiver": args.receiver or args.from_address, + "sourceTokenAddress": source_token.address, + "sourceTokenSymbol": source_token.symbol, + "sourceTokenDecimals": source_token.decimals, + } + approval_payload = { + "amount": parse_units(args.amount, source_token.decimals), + "fromAddress": args.from_address, + "tokenAddress": source_token.address, + } + + bundle_status, bundle_response = http_post_json(args.base_url, "/api/fxsave/fxsave-bundle", bundle_payload) + approval_status, approval_response = http_post_json( + args.base_url, "/api/fxsave/fxsave-approve", approval_payload + ) + + result = { + "command": "mint", + "baseUrl": args.base_url, + "sourceToken": asdict(source_token), + "bundleRequest": bundle_payload, + "approvalRequest": approval_payload, + "bundleStatus": bundle_status, + "bundleResponse": bundle_response, + "approvalStatus": approval_status, + "approvalResponse": approval_response, + } + print_json(result) + return 0 if bundle_status == 200 and approval_status == 200 else 1 + + +def redeem_command(args: argparse.Namespace) -> int: + source_token = TOKEN_REGISTRY["fxSAVE"] + target_token = resolve_token(args, "target") + bundle_payload = { + "amount": args.amount, + "direction": "redeem", + "fromAddress": args.from_address, + "receiver": args.receiver or args.from_address, + "targetTokenAddress": target_token.address, + "targetTokenSymbol": target_token.symbol, + "targetTokenDecimals": target_token.decimals, + } + approval_payload = { + "amount": parse_units(args.amount, source_token.decimals), + "fromAddress": args.from_address, + "tokenAddress": source_token.address, + } + + bundle_status, bundle_response = http_post_json(args.base_url, "/api/fxsave/fxsave-bundle", bundle_payload) + approval_status, approval_response = http_post_json( + args.base_url, "/api/fxsave/fxsave-approve", approval_payload + ) + + result = { + "command": "redeem", + "baseUrl": args.base_url, + "sourceToken": asdict(source_token), + "targetToken": asdict(target_token), + "bundleRequest": bundle_payload, + "approvalRequest": approval_payload, + "bundleStatus": bundle_status, + "bundleResponse": bundle_response, + "approvalStatus": approval_status, + "approvalResponse": approval_response, + } + print_json(result) + return 0 if bundle_status == 200 and approval_status == 200 else 1 + + +def approval_command(args: argparse.Namespace) -> int: + token = resolve_token(args, "approval") + payload = { + "amount": parse_units(args.amount, token.decimals), + "fromAddress": args.from_address, + "tokenAddress": token.address, + } + status, response = http_post_json(args.base_url, "/api/fxsave/fxsave-approve", payload) + result = { + "command": "approval", + "baseUrl": args.base_url, + "token": asdict(token), + "request": payload, + "status": status, + "response": response, + } + print_json(result) + return 0 if status == 200 else 1 + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="App base URL. Default: %(default)s") + parser.add_argument("--from-address", required=True, help="Connected wallet address") + parser.add_argument("--receiver", help="Receiver address. Defaults to from-address") + parser.add_argument("--amount", required=True, help="Human-readable token amount, for example 1 or 0.5") + + +def add_token_arguments(parser: argparse.ArgumentParser, prefix: str, include_fxsave: bool = False) -> None: + preset_choices = ["fxUSD", "USDC", "WETH", "custom"] + if include_fxsave: + preset_choices.insert(0, "fxSAVE") + + parser.add_argument( + f"--{prefix}-token", + choices=preset_choices, + default=preset_choices[0], + help=f"Preset {prefix} token symbol or custom", + ) + parser.add_argument(f"--{prefix}-address", help=f"Custom {prefix} token address") + parser.add_argument(f"--{prefix}-symbol", help=f"Custom {prefix} token symbol") + parser.add_argument(f"--{prefix}-decimals", type=int, help=f"Custom {prefix} token decimals") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="CLI helper for the public fxUSD skill over the fxSAVE Enso website APIs.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + mint_parser = subparsers.add_parser("mint", help="Preview mint bundle and approval plan") + add_common_arguments(mint_parser) + add_token_arguments(mint_parser, "source") + mint_parser.set_defaults(handler=mint_command) + + redeem_parser = subparsers.add_parser("redeem", help="Preview redeem bundle and approval plan") + add_common_arguments(redeem_parser) + add_token_arguments(redeem_parser, "target") + redeem_parser.set_defaults(handler=redeem_command) + + approval_parser = subparsers.add_parser("approval", help="Build approval payload only") + add_common_arguments(approval_parser) + approval_parser.add_argument( + "--token", + choices=["fxSAVE", "fxUSD", "USDC", "WETH", "custom"], + default="fxSAVE", + help="Preset token symbol or custom", + ) + approval_parser.add_argument("--approval-address", help="Custom token address") + approval_parser.add_argument("--approval-symbol", help="Custom token symbol") + approval_parser.add_argument("--approval-decimals", type=int, help="Custom token decimals") + approval_parser.set_defaults(approval_token=None) + approval_parser.add_argument( + "--approval-token", + dest="approval_token", + choices=["fxSAVE", "fxUSD", "USDC", "WETH", "custom"], + help=argparse.SUPPRESS, + ) + approval_parser.set_defaults(handler=approval_command) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if getattr(args, "command", None) == "approval": + args.approval_token = args.approval_token or args.token + + try: + return args.handler(args) + except ValueError as error: + print(json.dumps({"error": str(error)}, indent=2), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From d1563bf0b7caf349793cf21154a99c521cb5f487 Mon Sep 17 00:00:00 2001 From: Tao Wang Date: Mon, 16 Mar 2026 22:45:34 -0700 Subject: [PATCH 2/3] Expand fxusd skill with Hydrex and Morpho modules --- README.md | 2 +- fxusd/SKILL.md | 327 +++++++++++--- fxusd/references/api.md | 75 +++- fxusd/references/hydrex.md | 104 +++++ fxusd/references/morpho.md | 75 ++++ fxusd/scripts/fxusd_cli.py | 4 +- fxusd/scripts/fxusd_hydrex.py | 803 ++++++++++++++++++++++++++++++++++ 7 files changed, 1307 insertions(+), 83 deletions(-) create mode 100644 fxusd/references/hydrex.md create mode 100644 fxusd/references/morpho.md create mode 100644 fxusd/scripts/fxusd_hydrex.py diff --git a/README.md b/README.md index 1172ec4e..18b7c133 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Bankr Skills equip builders with plug-and-play tools to build more powerful agen | yoink | [yoink](yoink/) | Social on-chain game. "Yoink" a token from the current holder. Uses Bankr for transaction execution. | | [Neynar](https://neynar.com) | [neynar](neynar/) | Full Farcaster API integration. Post casts, like, recast, follow users, search content, and manage Farcaster identities. | | [Hydrex](https://hydrex.fi) | [hydrex](hydrex/) | Liquidity pools on Base. Lock HYDX for voting power, vote on pool strategies, deposit single-sided liquidity into auto-managed vaults, and claim oHYDX rewards. | -| [f(x) Protocol](https://fxsave.up.railway.app/) | [fxusd](fxusd/) | Shortcut the fxSAVE flow on Base. Mint or redeem fxSAVE without manually bridging between Base and Ethereum mainnet. | +| [f(x) Protocol](https://fxsave.up.railway.app/) | [fxusd](fxusd/) | Deploy, unwind, or compare fxUSD strategies on Base. Covers fxSAVE mint and redeem, Hydrex single-sided liquidity, and Morpho planning with Bankr-ready Hydrex execution steps. | ## Adding a Skill diff --git a/fxusd/SKILL.md b/fxusd/SKILL.md index ba2c9543..bdceb06c 100644 --- a/fxusd/SKILL.md +++ b/fxusd/SKILL.md @@ -1,96 +1,299 @@ --- name: fxusd -description: Mint or redeem Base fxSAVE through a public shortcut flow, without manually bridging between Base and Ethereum mainnet. Use when the user wants to deposit fxUSD, USDC, or WETH into fxSAVE, redeem fxSAVE back into Base assets, preview the route, or build approval plus execution payloads from the public fxSAVE app backend. +description: Use when the user wants to deploy, unwind, or compare fxUSD-related yield strategies on Base. Covers minting and redeeming fxSAVE, discovering and managing Hydrex single-sided liquidity vaults, and planning Morpho supply or borrow workflows with protocol-specific risk controls. Uses the local fxSAVE app backend for shortcut bundles and emits Bankr-ready transaction steps for Hydrex. +metadata: + { + "clawdbot": + { + "emoji": "💵", + "homepage": "https://fxsave.up.railway.app/", + "requires": { "bins": ["bankr"] }, + }, + } --- -# fxUSD +# fxusd -Shortcut the `fxSAVE` flow on Base. +Version: `v0.4.0` -**App:** https://fxsave.up.railway.app/ -**Repo:** https://github.com/huwangtao123/fxsave-dapp +Use this skill when the user wants a simpler way to put `fxUSD` to work on Base. -## Why use this skill +The job of this skill is to hide unnecessary DeFi complexity. Instead of asking the user to think in terms of bridging, approvals, vault mechanics, borrow limits, or withdrawal edge cases, this skill turns a simple outcome into a protocol-specific execution plan. -Without the shortcut, using `fxSAVE` from Base means manually thinking through: +## Quick Start + +### Mint fxSAVE + +```text +Deposit 10 fxUSD to fxSAVE +``` + +```text +Deposit all my idle USDC to fxSAVE +``` + +### Redeem fxSAVE + +```text +Redeem 50% of my fxSAVE to fxUSD +``` + +```text +Redeem all my fxSAVE to USDC on Base +``` + +### Hydrex Single-Sided Liquidity + +```text +Find the best Hydrex single-sided vault for fxUSD +``` + +```text +Deposit 500 fxUSD into the safest Hydrex vault +``` + +```text +Withdraw my fxUSD/BNKR Hydrex vault position +``` + +### Morpho Supply / Borrow ```text -Base -> bridge to mainnet -> deposit or redeem -> bridge back -> Base +Supply 5000 fxUSD on Morpho ``` -This skill keeps the user interaction simple and lets the app backend build the hidden Enso route. +```text +Borrow fxUSD against my collateral on Morpho +``` -## Capabilities +## Core Capabilities -- Mint Base `fxSAVE` from `fxUSD`, `USDC`, or `WETH` +### fxSAVE Shortcut + +- Mint Base `fxSAVE` from Base assets such as `fxUSD`, `USDC`, and `WETH` - Redeem Base `fxSAVE` back into Base assets -- Build the executable bundle payload -- Build the approval payload for the current source token -- Explain async cross-chain settlement clearly +- Hide the manual `Base -> Ethereum mainnet -> bridge back` flow behind one Base-side action +- Use the local app backend to build the executable route -## Usage examples +**Reference**: [references/api.md](references/api.md) -- `Deposit 10 fxUSD to fxSAVE` -- `Deposit 100 USDC to fxSAVE` -- `Redeem 50% of my fxSAVE to fxUSD` -- `Preview the route to mint fxSAVE from 1 WETH` +### Hydrex Single-Sided Liquidity + +- Discover live Hydrex vaults that accept `fxUSD` or other supported deposit tokens +- Distinguish `stablecoin-farming` from `crypto-farming` +- Rank vaults with a conservative heuristic that considers APR, TVL, and risk class +- Produce execution-ready deposit and withdraw plans +- Emit Bankr-ready `/agent/submit` steps for Hydrex approval and main transactions + +**Reference**: [references/hydrex.md](references/hydrex.md) + +### Morpho Supply / Borrow Planning + +- Plan `fxUSD` supply, withdraw, borrow, and repay workflows +- Compare Morpho lending with simpler `fxSAVE` or Hydrex routes +- Treat borrow as a separate, higher-risk class from pure supply +- Require explicit collateral, buffer, and market-availability checks before borrow planning + +**Reference**: [references/morpho.md](references/morpho.md) + +## Execution Model + +### 1. fxSAVE + +Use when the user wants: +- `fxUSD / USDC / WETH -> fxSAVE` +- `fxSAVE -> Base assets` +- the simplest Base-side UX for a cross-chain yield route -## Public endpoints +Execution path: +1. Read [references/api.md](references/api.md). +2. Use `scripts/fxusd_cli.py` or the local app backend to build the bundle. +3. Build approval for the current source token. +4. Execute approval only if allowance is insufficient. +5. Execute the main transaction. +6. Explain that settlement is asynchronous because the route crosses Base and Ethereum mainnet. -- Bundle builder: `POST https://fxsave.up.railway.app/api/fxsave/fxsave-bundle` -- Approval builder: `POST https://fxsave.up.railway.app/api/fxsave/fxsave-approve` +### 2. Hydrex + +Use when the user wants: +- a single-sided liquidity vault for `fxUSD` +- to deposit idle `fxUSD` into an auto-managed vault +- to withdraw from a Hydrex vault +- to inspect current Hydrex vault exposure + +Execution path: +1. Read [references/hydrex.md](references/hydrex.md). +2. Use `scripts/fxusd_hydrex.py` to discover, rank, and plan the vault action. +3. Distinguish `stablecoin-farming` from `crypto-farming` before comparing APR. +4. Read live Base balance, allowance, or LP share state. +5. Emit execution-ready transactions and Bankr-ready submit steps. +6. Only proceed when the wallet actually has balance or LP shares for the chosen action. + +### 3. Morpho + +Use when the user wants: +- to supply `fxUSD` +- to withdraw supplied `fxUSD` +- to borrow using collateral +- to repay borrowed `fxUSD` +- to compare lending yield versus `fxSAVE` + +Execution path: +1. Read [references/morpho.md](references/morpho.md). +2. Verify live market availability first. +3. Distinguish conservative supply routes from higher-risk borrow routes. +4. Only plan borrow actions when collateral assumptions, liquidation buffer, and oracle risk are explicit. + +## Common Workflows + +### Simplest Stable Yield Route + +When the user wants the lowest operational complexity: + +1. Prefer `fxSAVE`. +2. Use the local app backend. +3. Make the bridge latency explicit. +4. Do not describe settlement as instant. + +### Stablecoin Liquidity Route + +When the user explicitly wants liquidity provision rather than a simpler yield wrapper: + +1. Use Hydrex discovery. +2. Prefer `stablecoin-farming` vaults such as `fxUSD/USDC` when the user wants lower pair volatility. +3. Explain that single-sided deposit does not guarantee single-token withdrawal. +4. Use Bankr-ready steps only after live balance and allowance checks pass. + +### Crypto Pair Farming Route + +When the vault pairs `fxUSD` with a volatile asset such as `BNKR`: + +1. Label it `crypto-farming`. +2. Do not compare it to `fxUSD/USDC` as if they have the same risk class. +3. Make pair volatility and withdrawal-shape risk explicit before execution. + +### Lending or Leverage Route + +When the user wants capital efficiency or borrowing: + +1. Use Morpho planning. +2. Prefer supply over borrow when the user has not explicitly asked for leverage. +3. If borrow is requested, preserve a conservative liquidation buffer. + +## Decision Guide + +Prefer `fxSAVE` when: +- the user wants the simplest Base-side flow +- the user does not want to manage vaults +- the user values lower operational complexity over optional extra yield + +Prefer Hydrex when: +- the user explicitly wants single-sided liquidity +- a live vault exists for the desired asset +- the user accepts that withdrawals may return a mixed token composition + +Prefer Morpho supply when: +- the user wants a more direct lending route +- the user does not need liquidity pool exposure + +Prefer Morpho borrow only when: +- the user explicitly wants leverage or capital efficiency +- collateral, liquidation buffer, and market risk are understood + +## Risk Controls + +These are mandatory guardrails. + +- Use a dedicated hot wallet or dedicated Bankr agent wallet for repeated execution. +- Verify chain and token addresses before every write action. +- Do not describe `fxSAVE` as a same-chain or instant route. +- On Hydrex, always explain withdrawal composition risk. +- On Hydrex, always distinguish `stablecoin-farming` from `crypto-farming`. +- On Morpho, stay well below maximum borrow limits and preserve a clear liquidation buffer. +- Do not rely blindly on third-party assembled transactions from aggregators or reward routers. +- For auto-compounding or repeated execution, require explicit user confirmation before widening blast radius. + +## Vulnerabilities and Failure Modes + +### fxSAVE + +- Bridge latency: source-chain success is not final settlement +- Quote drift: the final amount can move while the route is in flight +- Intermediate-chain residue: favorable execution can leave small balances on intermediate chains + +### Hydrex + +- Vault strategy risk: a managed vault can rebalance in ways the user does not expect +- Withdrawal composition risk: the exit can come back split across assets +- Pair-risk confusion: `fxUSD/USDC` and `fxUSD/BNKR` do not belong to the same risk class +- Incentive drift: APR can move quickly as emissions and TVL change + +### Morpho + +- Liquidation risk: borrowing is not the same as supplying to a conservative vault +- Market availability risk: a route that exists for `USDC` may not exist for `fxUSD` +- Oracle and curator risk: safety depends on the specific market and collateral design +- Automation risk: external transaction assembly must be treated as sensitive + +## Example Prompts + +### fxSAVE + +- `Deposit 10 fxUSD to fxSAVE` +- `Redeem 50% of my fxSAVE to fxUSD` +- `Compare fxSAVE and Morpho yield options for my fxUSD` +- `Use my Bankr wallet to mint fxSAVE from 100 fxUSD` +- `Use Bankr to redeem all my fxSAVE to USDC on Base` -## Workflow +### Hydrex -1. Determine direction. -- `mint`: Base asset -> Base `fxSAVE` -- `redeem`: Base `fxSAVE` -> Base asset +- `Find the best Hydrex single-sided vault for fxUSD` +- `Show me stablecoin-farming vaults for fxUSD on Hydrex` +- `Deposit all my idle fxUSD into Hydrex` +- `Withdraw my fxUSD/BNKR Hydrex vault position` +- `Use my Bankr wallet to withdraw my Hydrex vault shares` +- `Use Bankr to deposit 100 fxUSD into the safest Hydrex vault` +- `Use Bankr to withdraw 50% of my BNKR/fxUSD Hydrex position` -2. Resolve tokens. -- For `mint`, provide the selected Base source token metadata. -- For `redeem`, provide the target Base asset metadata. -- Use the connected wallet address for both `fromAddress` and `receiver`. +### Morpho -3. Build the bundle. -- Call the bundle endpoint. -- Treat `result.tx` as the canonical executable payload. -- Use `flow`, `warnings`, and `bridgingEstimates` to explain what happens. +- `Supply 5,000 fxUSD on Morpho` +- `Borrow fxUSD against my collateral on Morpho` +- `Compare Morpho supply with fxSAVE for my idle fxUSD` +- `Use Bankr to supply my idle fxUSD on Morpho` +- `Use Bankr to compare Morpho and fxSAVE for my Bankr wallet` -4. Check approval. -- Identify the actual source token for the current direction. -- Call the approval endpoint with the raw amount and source token address. -- Compare allowance before sending approval. -- Skip approval if allowance is already sufficient. +## Resources -5. Execute. -- Submit approval first when needed. -- Submit the main transaction second. -- Tell the user the Base transaction can confirm before the final bridged asset arrives. +- **fxSAVE app**: https://fxsave.up.railway.app/ +- **Hydrex Platform**: https://hydrex.fi +- **Morpho**: https://morpho.org/ -## Requirements +## Natural Language Guidance for Bankr -- A Base wallet with the input asset and enough ETH for gas -- Access to the public fxSAVE app backend at `https://fxsave.up.railway.app` +When the user wants execution through Bankr: -If the agent can sign and submit transactions, it can execute directly. -If not, use this skill for planning and pair it with a transaction execution skill such as `bankr`. +1. Resolve which module the request belongs to: `fxSAVE`, `Hydrex`, or `Morpho`. +2. Prefer wallet-aware language such as `Use my Bankr wallet...` when the user explicitly wants Bankr execution. +3. For `fxSAVE`, use the local app backend to build approval and main transaction plans first. +4. For `Hydrex`, use `scripts/fxusd_hydrex.py` and prefer the emitted `bankrReady.steps`. +5. Execute steps in order and wait for confirmation before moving to the next step. +6. If live balance, allowance, or LP shares are insufficient, stop and explain the blocker instead of forcing execution. -## Safety rules +Useful natural-language styles: -- Do not describe this as same-chain instant settlement. -- Do not invent token decimals or addresses. -- Do not bypass the app backend by manually reconstructing Enso payloads unless the public route is broken. -- Surface route warnings to the user before execution. -- Stop if bundle generation fails instead of guessing a fallback route. +- `Use my Bankr wallet to mint fxSAVE from 25 fxUSD` +- `Use Bankr to deposit all my idle fxUSD into the safest Hydrex stablecoin vault` +- `Use Bankr to withdraw my Hydrex fxUSD/BNKR position` +- `Use Bankr to compare Morpho supply with fxSAVE for my idle fxUSD` -## Supported defaults +## Detailed References -- `fxUSD` -- `USDC` -- `WETH` -- `fxSAVE` as the redeem source token +- **[fxSAVE Shortcut API](references/api.md)** — Bundle building, approval flow, and app backend usage +- **[Hydrex Single-Sided Liquidity](references/hydrex.md)** — Discovery, ranking, deposits, withdrawals, and Bankr-ready steps +- **[Morpho Planning](references/morpho.md)** — Supply, withdraw, borrow, repay, and risk controls -## When to read more +## Local Scripts -- Read [references/api.md](references/api.md) for request and response shapes. -- Use `scripts/fxusd_cli.py` for quick local or remote API checks. +- `scripts/fxusd_cli.py` — Preview `fxSAVE` mint, redeem, and approval plans +- `scripts/fxusd_hydrex.py` — Discover Hydrex vaults, classify risk, and emit execution-ready plus Bankr-ready Hydrex transactions diff --git a/fxusd/references/api.md b/fxusd/references/api.md index 33f26ecd..87ee7b30 100644 --- a/fxusd/references/api.md +++ b/fxusd/references/api.md @@ -1,8 +1,13 @@ -# fxUSD API Reference +# fxSAVE Shortcut API Reference -Public app base URL: +Version: `v0.3.0` -`https://fxsave.up.railway.app` +This file documents the executable `fxSAVE` module inside the broader `fxusd` skill. +Use it when the user wants the one-step Base shortcut for: + +- minting `fxSAVE` +- redeeming `fxSAVE` +- previewing the route before execution ## CLI helper @@ -14,7 +19,7 @@ Examples: ```bash python3 scripts/fxusd_cli.py mint \ --from-address 0x... \ - --amount 10 \ + --amount 1 \ --source-token fxUSD ``` @@ -37,7 +42,7 @@ python3 scripts/fxusd_cli.py approval \ Method: `POST` Purpose: -- Build an executable Enso shortcut bundle for `mint` or `redeem` +- Build an executable Enso bundle for `mint` or `redeem` ### Mint request @@ -47,7 +52,7 @@ Purpose: "direction": "mint", "fromAddress": "0x...", "receiver": "0x...", - "sourceTokenAddress": "0x55380fe7a1910dff29a47b622057ab4139da42c5", + "sourceTokenAddress": "0x...", "sourceTokenSymbol": "fxUSD", "sourceTokenDecimals": 18 } @@ -61,7 +66,7 @@ Purpose: "direction": "redeem", "fromAddress": "0x...", "receiver": "0x...", - "targetTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "targetTokenAddress": "0x...", "targetTokenSymbol": "USDC", "targetTokenDecimals": 6 } @@ -81,19 +86,29 @@ Purpose: }, "amountsOut": {}, "minAmountsOut": {}, - "bridgingEstimates": [] + "bridgingEstimates": [], + "route": [] }, "quotePlan": {}, "warnings": [] } ``` +### Important fields + +- `result.tx`: executable transaction payload +- `result.minAmountsOut`: conservative output estimate +- `result.amountsOut`: optimistic output estimate +- `result.bridgingEstimates`: settlement timing hints +- `flow`: human-readable step summary +- `warnings`: user-facing risk or timing notes + ## Endpoint: `/api/fxsave/fxsave-approve` Method: `POST` Purpose: -- Build the approval tx payload for the current source token +- Build approval tx payload for the current source token ### Request @@ -101,10 +116,12 @@ Purpose: { "amount": "1000000000000000000", "fromAddress": "0x...", - "tokenAddress": "0x273f20fa9fbe803e5d6959add9582dac240ec3be" + "tokenAddress": "0x..." } ``` +`amount` must be raw units, not human-readable decimal text. + ### Success response shape ```json @@ -121,12 +138,34 @@ Purpose: } ``` -## Execution pattern +## Agent execution pattern + +1. Normalize direction and token metadata. +2. Build the bundle. +3. Identify the source token for the current direction. +4. Build approval payload for that source token. +5. Compare allowance before approval. +6. Submit approval if needed. +7. Submit main tx from `result.tx`. +8. Tell the user the cross-chain settlement may lag the source-chain confirmation. + +## Production notes + +- In production, the app limits allowed origins and rate limits the public API routes. +- In production, custom tokens can be disabled. Default support is the configured Base presets only. +- Detailed upstream Enso request payloads are intended for local debugging and may be omitted in production responses. +- Final settlement is asynchronous. Base confirmation is not the same thing as final bridged arrival. + +## Current direction mapping + +### Mint + +- Input: Base asset +- Output: Base `fxSAVE` +- Source token for approval: selected Base asset + +### Redeem -1. Build the bundle. -2. Identify the source token for the current direction. -3. Build approval payload for that source token. -4. Compare allowance. -5. Submit approval if needed. -6. Submit `result.tx`. -7. Tell the user final settlement may lag the Base confirmation. +- Input: Base `fxSAVE` +- Output: Base asset +- Source token for approval: Base `fxSAVE` diff --git a/fxusd/references/hydrex.md b/fxusd/references/hydrex.md new file mode 100644 index 00000000..5ca0a95d --- /dev/null +++ b/fxusd/references/hydrex.md @@ -0,0 +1,104 @@ +# Hydrex Single-Sided Liquidity + +Use this module when the user wants to deploy `fxUSD` into a single-sided liquidity vault on Hydrex. + +Reference pattern: +- Hydrex skill: https://github.com/BankrBot/skills/blob/main/hydrex/SKILL.md + +## What to support + +- discover single-sided vaults that could accept `fxUSD` +- distinguish `stablecoin-farming` from `crypto-farming` +- compare vault options +- plan a deposit +- plan a withdrawal +- inspect rewards and vault exposure + +## Local planning tool + +Use `scripts/fxusd_hydrex.py` for live Hydrex discovery and execution planning. +For `deposit-plan`, `withdraw-plan`, and `position-reads`, the script now reads live Base state so the output is execution-ready instead of placeholder-only. +`deposit-plan` and `withdraw-plan` also emit `bankrReady.steps`, which match Bankr `POST /agent/submit` request bodies. + +Examples: + +```bash +python3 skill/scripts/fxusd_hydrex.py discover --deposit-token fxUSD +``` + +```bash +python3 skill/scripts/fxusd_hydrex.py recommend --deposit-token fxUSD --limit 3 +``` + +```bash +python3 skill/scripts/fxusd_hydrex.py deposit-plan \ + --from-address 0x... \ + --amount 100 \ + --deposit-token fxUSD +``` + +```bash +python3 skill/scripts/fxusd_hydrex.py withdraw-plan \ + --from-address 0x... \ + --deposit-token fxUSD \ + --vault-title "fxUSD/USDC" \ + --fraction 0.5 +``` + +```bash +python3 skill/scripts/fxusd_hydrex.py position-reads \ + --from-address 0x... \ + --deposit-token fxUSD \ + --vault-title "fxUSD/USDC" +``` + +The deposit planner can auto-select the best current vault when only `--deposit-token` is supplied and will read the live token balance plus allowance before returning the payload. Withdraw and position inspection should stay vault-specific. +The withdraw planner can auto-compute raw LP shares from a requested fraction by reading the current vault balance onchain. + +The discovery output should not treat all vaults as equivalent: +- `stablecoin-farming`: examples like `fxUSD/USDC`, where the pair is stable or highly correlated +- `crypto-farming`: examples like `fxUSD/BNKR`, where the pair is more directional and volatility risk is higher + +## Recommended user intents + +- `Find Hydrex single-sided vaults for fxUSD` +- `What Hydrex vaults can I use with fxUSD?` +- `Deposit 1,000 fxUSD into the best Hydrex single-sided vault` +- `Withdraw my Hydrex fxUSD position` +- `Check my Hydrex rewards` + +## Best execution strategy + +1. Start with live vault discovery. +2. Verify that the vault actually supports `fxUSD` right now. +3. Compare the expected yield with simpler alternatives such as `fxSAVE` or `Morpho supply`. +4. Only recommend deposit after confirming the user accepts vault and withdrawal-shape risk. + +If the dedicated `hydrex` skill is available, it is still useful for protocol-specific execution. This module now covers live discovery and planning directly inside `fxusd`. + +## Risk controls + +- Do not assume a live `fxUSD` vault exists. +- Do not describe the position as risk-free just because it is single-sided on entry. +- Call out whether the vault is stablecoin farming or crypto farming before comparing APR. +- Tell the user withdrawals can come back with mixed token composition. +- Check current reward incentives before framing the route as attractive. + +## Vulnerabilities and protocol risks + +- Vault strategy risk: the vault manager can rebalance into a position that behaves differently than the user expects. +- Withdrawal-shape risk: a single-sided deposit can still unwind into a two-asset mix. +- Reward drift: incentive APR can collapse quickly. +- Smart contract risk: vault deployer, deposit guard, and incentive systems add extra trust surface. + +## Decision rule + +Prefer Hydrex when: +- the user explicitly wants liquidity provision +- there is a confirmed `fxUSD`-compatible vault +- the yield advantage is meaningful versus simpler lending or `fxSAVE` + +Do not prefer Hydrex when: +- the user only wants the simplest low-friction stable yield route +- the user cannot tolerate mixed-asset exits +- live vault support for `fxUSD` is unclear diff --git a/fxusd/references/morpho.md b/fxusd/references/morpho.md new file mode 100644 index 00000000..78ce1b26 --- /dev/null +++ b/fxusd/references/morpho.md @@ -0,0 +1,75 @@ +# Morpho Lend / Borrow + +Use this module when the user wants to supply `fxUSD`, withdraw it later, or borrow against collateral through Morpho-style workflows. + +Reference pattern: +- Morpho Earn example: https://clawhub.ai/lyoungblood/morpho-earn + +## Important framing + +The referenced Morpho skill is a conservative earnings workflow example, not a blanket guarantee that every `fxUSD` market you might want is live. + +Treat `Morpho supply` and `Morpho borrow` as two different risk levels: + +- `supply`: generally simpler and closer to passive yield +- `borrow`: leverage and liquidation risk + +## What to support + +- compare Morpho supply versus `fxSAVE` or Hydrex +- plan a `supply fxUSD` action +- plan a `withdraw supplied fxUSD` action +- plan a `borrow fxUSD` action only after validating market and collateral conditions +- plan a `repay fxUSD` action + +## Recommended user intents + +- `Supply 5,000 fxUSD on Morpho` +- `Withdraw my supplied fxUSD from Morpho` +- `Borrow fxUSD against my collateral on Morpho` +- `Repay my Morpho fxUSD debt` +- `Compare Morpho supply yield with fxSAVE` + +## Best execution strategy + +For normal yield-seeking users: +1. start with supply-only analysis +2. compare net yield with `fxSAVE` +3. only recommend borrow flows if the user explicitly wants leverage + +For borrow flows: +1. verify live market availability first +2. verify the collateral asset and liquidation thresholds +3. recommend a conservative borrow size, not the protocol maximum +4. make liquidation risk explicit before execution + +## Risk controls + +- Do not assume a specific `fxUSD` market exists without current verification. +- Stay well below max LTV. A safer planning posture is to keep meaningful headroom instead of optimizing for maximum borrow. +- Treat oracle, curator, and market-parameter changes as live risks. +- If rewards are routed through third-party claim-and-swap paths, review that transaction path carefully. + +## Vulnerabilities and failure modes + +- Liquidation risk: borrowing is the sharpest edge in this skill set. +- Market availability risk: `USDC` examples do not automatically map to `fxUSD`. +- Parameter drift: borrow caps, collateral factors, and rewards can change. +- Oracle dependency: bad or lagging oracle conditions can damage otherwise reasonable leverage. + +## Decision rule + +Prefer Morpho supply when: +- the user wants simpler yield +- a live `fxUSD` market is confirmed +- the route is operationally simpler than Hydrex for the same capital + +Prefer Morpho borrow only when: +- the user explicitly wants leverage or capital efficiency +- collateral assumptions are explicit +- the user accepts liquidation risk + +Do not recommend a borrow plan when: +- current market support is unclear +- there is no explicit liquidation buffer +- the user has not asked for leverage diff --git a/fxusd/scripts/fxusd_cli.py b/fxusd/scripts/fxusd_cli.py index 100abf2c..5bc694e4 100644 --- a/fxusd/scripts/fxusd_cli.py +++ b/fxusd/scripts/fxusd_cli.py @@ -11,7 +11,7 @@ from typing import Any -DEFAULT_BASE_URL = "https://fxsave.up.railway.app" +DEFAULT_BASE_URL = "http://localhost:3000" @dataclass(frozen=True) @@ -212,7 +212,7 @@ def add_token_arguments(parser: argparse.ArgumentParser, prefix: str, include_fx def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="CLI helper for the public fxUSD skill over the fxSAVE Enso website APIs.", + description="CLI helper for the fxUSD skill over the fxSAVE Enso website APIs.", ) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/fxusd/scripts/fxusd_hydrex.py b/fxusd/scripts/fxusd_hydrex.py new file mode 100644 index 00000000..c2b21380 --- /dev/null +++ b/fxusd/scripts/fxusd_hydrex.py @@ -0,0 +1,803 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import sys +import time +import urllib.parse +import urllib.error +import urllib.request +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any + + +API_BASE = "https://api.hydrex.fi/strategies" +BASE_RPC_URL = os.environ.get("BASE_RPC_URL", "https://mainnet.base.org") +USER_AGENT = "fxusd-hydrex/0.1 (+https://github.com/huwangtao123/fxsave-dapp)" +DEPOSIT_GUARD = "0x9A0EBEc47c85fD30F1fdc90F57d2b178e84DC8d8" +VAULT_DEPLOYER = "0x7d11De61c219b70428Bb3199F0DD88bA9E76bfEE" +ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") + + +@dataclass(frozen=True) +class Token: + symbol: str + address: str + decimals: int + + +TOKEN_REGISTRY = { + "fxUSD": Token("fxUSD", "0x55380fe7a1910dff29a47b622057ab4139da42c5", 18), + "USDC": Token("USDC", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 6), + "WETH": Token("WETH", "0x4200000000000000000000000000000000000006", 18), + "BNKR": Token("BNKR", "0x22aF33FE49fD1Fa80c7149773dDe5890D3c76F3b", 18), + "HYDX": Token("HYDX", "0x00000e7efa313F4E11Bfff432471eD9423AC6B30", 18), +} +TOKEN_BY_ADDRESS = {token.address.lower(): token for token in TOKEN_REGISTRY.values()} +KNOWN_STABLE_SYMBOLS = { + "FXUSD", + "USDC", + "USDT", + "DAI", + "USDS", + "USDE", + "USD+", + "FRAX", + "PYUSD", + "SUSDE", +} + + +def parse_units(value: str, decimals: int) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Amount is required.") + if normalized.count(".") > 1: + raise ValueError(f"Invalid amount: {value}") + + whole, _, fraction = normalized.partition(".") + if not whole: + whole = "0" + if not whole.isdigit() or (fraction and not fraction.isdigit()): + raise ValueError(f"Invalid amount: {value}") + + padded_fraction = (fraction + ("0" * decimals))[:decimals] + combined = f"{whole}{padded_fraction}".lstrip("0") + return combined or "0" + + +def request_json(url: str) -> Any: + last_error: Exception | None = None + for delay in (0.0, 0.5, 1.0): + if delay: + time.sleep(delay) + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + last_error = error + if error.code != 429: + raise + if last_error is not None: + raise last_error + raise ValueError("Unexpected request failure.") + + +def post_json(url: str, payload: dict[str, Any]) -> Any: + encoded = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=encoded, + headers={ + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + + +def validate_address(value: str, field_name: str) -> str: + if not ADDRESS_RE.match(value): + raise ValueError(f"Invalid {field_name}: {value}") + return value + + +def parse_fraction(value: str) -> str: + try: + parsed = Decimal(value) + except InvalidOperation as error: + raise ValueError(f"Invalid fraction: {value}") from error + + if parsed <= 0 or parsed > 1: + raise ValueError("Fraction must be greater than 0 and less than or equal to 1.") + return format(parsed.normalize(), "f") + + +def fraction_to_parts(value: str) -> tuple[int, int]: + normalized = parse_fraction(value) + whole, _, fraction = normalized.partition(".") + numerator = int(f"{whole}{fraction}") if fraction else int(whole) + denominator = 10 ** len(fraction) if fraction else 1 + return numerator, denominator + + +def resolve_deposit_token(value: str | None) -> str | None: + if not value: + return None + + preset = TOKEN_REGISTRY.get(value) + if preset: + return preset.address + + return value + + +def pad_hex(value: str) -> str: + return value[2:].lower().rjust(64, "0") + + +def encode_uint256(value: int | str) -> str: + return hex(int(value))[2:].rjust(64, "0") + + +def encode_call(selector: str, words: list[str]) -> str: + return f"{selector}{''.join(words)}" + + +def build_approve_transaction(token: str, spender: str, amount: str) -> dict[str, Any]: + data = encode_call("0x095ea7b3", [pad_hex(spender), encode_uint256(amount)]) + return { + "to": token, + "chainId": 8453, + "value": "0", + "data": data, + } + + +def build_hydrex_deposit_transaction( + vault: str, + token: str, + amount: str, + user_address: str, +) -> dict[str, Any]: + data = encode_call( + "0x5d123e3f", + [ + pad_hex(vault), + pad_hex(VAULT_DEPLOYER), + pad_hex(token), + encode_uint256(amount), + encode_uint256(0), + pad_hex(user_address), + ], + ) + return { + "to": DEPOSIT_GUARD, + "chainId": 8453, + "value": "0", + "data": data, + } + + +def build_hydrex_withdraw_transaction( + vault: str, + shares: str, + user_address: str, +) -> dict[str, Any]: + data = encode_call( + "0x1a0e8cdf", + [ + pad_hex(vault), + pad_hex(VAULT_DEPLOYER), + encode_uint256(shares), + pad_hex(user_address), + encode_uint256(0), + encode_uint256(0), + ], + ) + return { + "to": DEPOSIT_GUARD, + "chainId": 8453, + "value": "0", + "data": data, + } + + +def build_bankr_submit_request(transaction: dict[str, Any], description: str) -> dict[str, Any]: + return { + "transaction": transaction, + "description": description, + "waitForConfirmation": True, + } + + +def build_bankr_steps(steps: list[tuple[str, dict[str, Any]]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for index, (description, transaction) in enumerate(steps, start=1): + result.append( + { + "step": index, + "description": description, + "request": build_bankr_submit_request(transaction, description), + } + ) + return result + + +def rpc_call(rpc_url: str, to: str, data: str) -> str: + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [ + { + "to": to, + "data": data, + }, + "latest", + ], + } + response = post_json(rpc_url, payload) + if "error" in response: + raise ValueError(f"RPC call failed: {response['error']}") + result = response.get("result") + if not isinstance(result, str) or not result.startswith("0x"): + raise ValueError("Unexpected RPC result.") + return result + + +def erc20_balance_of(rpc_url: str, contract: str, owner: str) -> int: + data = f"0x70a08231{pad_hex(owner)}" + return int(rpc_call(rpc_url, contract, data), 16) + + +def erc20_allowance(rpc_url: str, contract: str, owner: str, spender: str) -> int: + data = f"0xdd62ed3e{pad_hex(owner)}{pad_hex(spender)}" + return int(rpc_call(rpc_url, contract, data), 16) + + +def erc20_total_supply(rpc_url: str, contract: str) -> int: + data = "0x18160ddd" + return int(rpc_call(rpc_url, contract, data), 16) + + +def build_live_share_state(rpc_url: str, vault: str, user_address: str) -> dict[str, str]: + balance = erc20_balance_of(rpc_url, vault, user_address) + allowance = erc20_allowance(rpc_url, vault, user_address, DEPOSIT_GUARD) + total_supply = erc20_total_supply(rpc_url, vault) + return { + "rpcUrl": rpc_url, + "currentLpShares": str(balance), + "currentAllowance": str(allowance), + "currentTotalSupply": str(total_supply), + } + + +def fetch_strategies(deposit_token: str | None = None) -> list[dict[str, Any]]: + query = {"strategist": "ichi"} + if deposit_token: + query["depositTokens"] = deposit_token + + url = f"{API_BASE}?{urllib.parse.urlencode(query)}" + payload = request_json(url) + if not isinstance(payload, list): + raise ValueError("Unexpected Hydrex API response.") + return payload + + +def infer_pair_symbol(title: str | None, deposit_symbol: str | None) -> str | None: + if not title or "/" not in title: + return None + + left, right = [part.strip() for part in title.split("/", 1)] + if deposit_symbol and left.upper() == deposit_symbol.upper(): + return right + if deposit_symbol and right.upper() == deposit_symbol.upper(): + return left + return right + + +def classify_farming_type( + *, + title: str | None, + deposit_symbol: str | None, + tags: list[Any], +) -> tuple[str, str | None, str]: + pair_symbol = infer_pair_symbol(title, deposit_symbol) + normalized_pair = pair_symbol.upper() if pair_symbol else None + normalized_tags = {str(tag).lower() for tag in tags} + + if ( + "stable" in normalized_tags + or "correlated" in normalized_tags + or (normalized_pair is not None and normalized_pair in KNOWN_STABLE_SYMBOLS) + ): + return ( + "stablecoin-farming", + pair_symbol, + "Counter-asset is stable or highly correlated, so pair volatility risk is lower.", + ) + + return ( + "crypto-farming", + pair_symbol, + "Counter-asset is crypto or non-correlated, so pair volatility and withdrawal-shape risk are higher.", + ) + + +def normalize_strategy(strategy: dict[str, Any]) -> dict[str, Any]: + deposit_address = str(strategy.get("depositToken", "")) + token = TOKEN_BY_ADDRESS.get(deposit_address.lower()) + title = strategy.get("title") + tags = strategy.get("tags", []) + farming_type, pair_symbol, farming_reason = classify_farming_type( + title=title, + deposit_symbol=token.symbol if token else None, + tags=tags, + ) + return { + "address": strategy.get("address"), + "title": title, + "depositToken": deposit_address, + "depositTokenSymbol": token.symbol if token else deposit_address, + "depositTokenDecimals": token.decimals if token else None, + "pairTokenSymbol": pair_symbol, + "farmingType": farming_type, + "farmingRiskSummary": farming_reason, + "childApr": strategy.get("childApr"), + "tvlUsd": strategy.get("tvlUsd"), + "riskLevel": strategy.get("riskLevel"), + "riskDescription": strategy.get("riskDescription"), + "tags": tags, + "website": strategy.get("website"), + "type": strategy.get("type"), + } + + +def rank_strategy(strategy: dict[str, Any]) -> tuple[float, list[str]]: + apr = float(strategy.get("childApr") or 0) + tvl = float(strategy.get("tvlUsd") or 0) + risk_level = float(strategy.get("riskLevel") or 10) + tags = {str(tag).lower() for tag in strategy.get("tags", [])} + + score = 0.0 + reasons: list[str] = [] + + score += min(apr, 40) + if apr > 0: + reasons.append(f"APR {apr:.2f}%") + + if tvl > 0: + tvl_component = min(math.log10(tvl + 1) * 8, 28) + score += tvl_component + reasons.append(f"TVL ${tvl:,.2f}") + + stable_bonus = 0 + if "safe" in tags: + stable_bonus += 10 + if "stable" in tags: + stable_bonus += 8 + if "correlated" in tags: + stable_bonus += 6 + if stable_bonus: + score += stable_bonus + reasons.append("stable/correlated setup") + + if strategy.get("farmingType") == "stablecoin-farming": + score += 6 + reasons.append("stablecoin farming bonus") + else: + score -= 6 + reasons.append("crypto farming penalty") + + if "exotic" in tags: + score -= 10 + reasons.append("exotic pair penalty") + + score -= risk_level * 5 + reasons.append(f"risk level {risk_level:g}") + + if tvl < 1000: + score -= 8 + reasons.append("low TVL penalty") + + return score, reasons + + +def resolve_strategy( + strategies: list[dict[str, Any]], + *, + vault_address: str | None, + vault_title: str | None, +) -> dict[str, Any]: + if not vault_address and not vault_title: + raise ValueError("Provide --vault-address or --vault-title.") + + matches: list[dict[str, Any]] = [] + for strategy in strategies: + address_match = vault_address and str(strategy.get("address", "")).lower() == vault_address.lower() + title_match = vault_title and str(strategy.get("title", "")).lower() == vault_title.lower() + if address_match or title_match: + matches.append(strategy) + + if not matches: + raise ValueError("No matching Hydrex strategy found.") + if len(matches) > 1: + raise ValueError("Multiple strategies matched. Use the vault address for an exact match.") + return matches[0] + + +def build_risk_notes(strategy: dict[str, Any]) -> list[str]: + notes = [ + "Single-sided entry does not guarantee single-token exit.", + "Hydrex vault withdrawals can return a mixed token composition.", + "APR and TVL are live estimates and can change quickly.", + ] + if strategy.get("farmingType") == "stablecoin-farming": + notes.append("This is stablecoin farming, so pair volatility risk is lower than crypto-farming vaults.") + else: + notes.append("This is crypto farming, so pair volatility and withdrawal-shape risk are materially higher.") + risk_description = strategy.get("riskDescription") + if isinstance(risk_description, str) and risk_description: + notes.append(risk_description) + return notes + + +def print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def recommend_strategies(strategies: list[dict[str, Any]]) -> list[dict[str, Any]]: + ranked: list[dict[str, Any]] = [] + for strategy in strategies: + entry = normalize_strategy(strategy) + score, reasons = rank_strategy(entry) + entry["score"] = round(score, 2) + entry["reasons"] = reasons + ranked.append(entry) + ranked.sort(key=lambda item: item["score"], reverse=True) + return ranked + + +def discover_command(args: argparse.Namespace) -> int: + strategies = fetch_strategies(resolve_deposit_token(args.deposit_token)) + normalized = [normalize_strategy(strategy) for strategy in strategies] + summary = { + "stablecoinFarming": sum(1 for strategy in normalized if strategy["farmingType"] == "stablecoin-farming"), + "cryptoFarming": sum(1 for strategy in normalized if strategy["farmingType"] == "crypto-farming"), + } + print_json( + { + "command": "discover", + "count": len(normalized), + "depositTokenFilter": args.deposit_token, + "summary": summary, + "strategies": normalized, + } + ) + return 0 + + +def recommend_command(args: argparse.Namespace) -> int: + strategies = fetch_strategies(resolve_deposit_token(args.deposit_token)) + normalized = recommend_strategies(strategies) + print_json( + { + "command": "recommend", + "depositTokenFilter": args.deposit_token, + "top": normalized[: args.limit], + } + ) + return 0 + + +def deposit_plan_command(args: argparse.Namespace) -> int: + strategies = fetch_strategies(resolve_deposit_token(args.deposit_token)) + selection_mode = "exact" + selection_reasons: list[str] = [] + if args.vault_address or args.vault_title: + strategy = normalize_strategy( + resolve_strategy(strategies, vault_address=args.vault_address, vault_title=args.vault_title) + ) + else: + if not args.deposit_token: + raise ValueError("Provide --deposit-token when auto-selecting the best vault.") + ranked = recommend_strategies(strategies) + if not ranked: + raise ValueError("No Hydrex strategies found for the requested token.") + strategy = ranked[0] + selection_mode = "recommended" + selection_reasons = strategy.get("reasons", []) + + decimals = strategy["depositTokenDecimals"] + if decimals is None and args.token_decimals is None: + raise ValueError("Token decimals were unknown. Supply --token-decimals.") + resolved_decimals = decimals if decimals is not None else args.token_decimals + amount_raw = parse_units(args.amount, resolved_decimals) + user_address = validate_address(args.from_address, "from-address") + live_balance = erc20_balance_of(args.rpc_url, strategy["depositToken"], user_address) + live_allowance = erc20_allowance(args.rpc_url, strategy["depositToken"], user_address, DEPOSIT_GUARD) + needs_approval = live_allowance < int(amount_raw) + has_sufficient_balance = live_balance >= int(amount_raw) + approval: dict[str, str] | None = None + approval_transaction: dict[str, Any] | None = None + if needs_approval: + approval = { + "spender": DEPOSIT_GUARD, + "token": strategy["depositToken"], + "amount": amount_raw, + } + approval_transaction = build_approve_transaction(strategy["depositToken"], DEPOSIT_GUARD, amount_raw) + + deposit_transaction = build_hydrex_deposit_transaction( + strategy["address"], + strategy["depositToken"], + amount_raw, + user_address, + ) + bankr_steps: list[tuple[str, dict[str, Any]]] = [] + if approval_transaction is not None: + bankr_steps.append( + ( + f"Approve {strategy['depositTokenSymbol']} for Hydrex Deposit Guard", + approval_transaction, + ) + ) + bankr_steps.append( + ( + f"Deposit {args.amount} {strategy['depositTokenSymbol']} into Hydrex {strategy['title']}", + deposit_transaction, + ) + ) + + print_json( + { + "command": "deposit-plan", + "selectionMode": selection_mode, + "selectionReasons": selection_reasons, + "userAddress": user_address, + "strategy": strategy, + "liveState": { + "rpcUrl": args.rpc_url, + "currentTokenBalance": str(live_balance), + "currentAllowance": str(live_allowance), + }, + "executionReadiness": { + "hasSufficientBalance": has_sufficient_balance, + "needsApproval": needs_approval, + "readyToExecute": has_sufficient_balance, + }, + "approval": approval, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps(bankr_steps), + }, + "depositCall": { + "chainId": 8453, + "to": DEPOSIT_GUARD, + "function": "forwardDepositToICHIVault(address vault, address vaultDeployer, address token, uint256 amount, uint256 minimumShares, address userAddress)", + "args": { + "vault": strategy["address"], + "vaultDeployer": VAULT_DEPLOYER, + "token": strategy["depositToken"], + "amount": amount_raw, + "minimumShares": "0", + "userAddress": user_address, + }, + }, + "depositTransaction": deposit_transaction, + "riskNotes": build_risk_notes(strategy), + } + ) + return 0 + + +def withdraw_plan_command(args: argparse.Namespace) -> int: + strategies = fetch_strategies(resolve_deposit_token(args.deposit_token)) + strategy = normalize_strategy( + resolve_strategy(strategies, vault_address=args.vault_address, vault_title=args.vault_title) + ) + user_address = validate_address(args.from_address, "from-address") + live_state = build_live_share_state(args.rpc_url, strategy["address"], user_address) + + share_plan: dict[str, Any] + shares_raw: str + if args.shares: + shares_raw = args.shares + share_plan = {"mode": "shares", "shares": args.shares} + elif args.fraction: + numerator, denominator = fraction_to_parts(args.fraction) + computed_shares = (int(live_state["currentLpShares"]) * numerator) // denominator + shares_raw = str(computed_shares) + share_plan = { + "mode": "fraction", + "fraction": parse_fraction(args.fraction), + "currentLpShares": live_state["currentLpShares"], + "computedShares": shares_raw, + "instruction": "Computed from the live LP share balance on Base.", + } + else: + raise ValueError("Provide --shares or --fraction.") + + if int(shares_raw) <= 0: + raise ValueError("No withdrawable LP shares were found for this wallet and vault.") + + approval: dict[str, str] | None = None + approval_transaction: dict[str, Any] | None = None + if int(live_state["currentAllowance"]) < int(shares_raw): + approval = { + "spender": DEPOSIT_GUARD, + "token": strategy["address"], + "amount": shares_raw, + } + approval_transaction = build_approve_transaction(strategy["address"], DEPOSIT_GUARD, shares_raw) + + withdraw_transaction = build_hydrex_withdraw_transaction(strategy["address"], shares_raw, user_address) + bankr_steps: list[tuple[str, dict[str, Any]]] = [] + if approval_transaction is not None: + bankr_steps.append( + ( + f"Approve Hydrex vault shares for {strategy['title']} withdrawal", + approval_transaction, + ) + ) + bankr_steps.append( + ( + f"Withdraw from Hydrex {strategy['title']}", + withdraw_transaction, + ) + ) + + print_json( + { + "command": "withdraw-plan", + "userAddress": user_address, + "strategy": strategy, + "liveState": live_state, + "sharePlan": share_plan, + "reads": [ + { + "contract": strategy["address"], + "function": "balanceOf(address)", + "args": [user_address], + "purpose": "Get current LP share balance", + }, + { + "contract": strategy["address"], + "function": "allowance(address,address)", + "args": [user_address, DEPOSIT_GUARD], + "purpose": "Check LP token allowance", + }, + ], + "approval": approval, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps(bankr_steps), + }, + "withdrawCall": { + "chainId": 8453, + "to": DEPOSIT_GUARD, + "function": "forwardWithdrawFromICHIVault(address vault, address vaultDeployer, uint256 shares, address userAddress, uint256 minAmount0, uint256 minAmount1)", + "args": { + "vault": strategy["address"], + "vaultDeployer": VAULT_DEPLOYER, + "shares": shares_raw, + "userAddress": user_address, + "minAmount0": "0", + "minAmount1": "0", + }, + }, + "withdrawTransaction": withdraw_transaction, + "riskNotes": build_risk_notes(strategy), + } + ) + return 0 + + +def position_reads_command(args: argparse.Namespace) -> int: + strategies = fetch_strategies(resolve_deposit_token(args.deposit_token)) + strategy = normalize_strategy( + resolve_strategy(strategies, vault_address=args.vault_address, vault_title=args.vault_title) + ) + user_address = validate_address(args.from_address, "from-address") + live_state = build_live_share_state(args.rpc_url, strategy["address"], user_address) + + print_json( + { + "command": "position-reads", + "userAddress": user_address, + "strategy": strategy, + "liveState": live_state, + "reads": [ + { + "contract": strategy["address"], + "function": "balanceOf(address)", + "args": [user_address], + "purpose": "Current LP share balance", + }, + { + "contract": strategy["address"], + "function": "allowance(address,address)", + "args": [user_address, DEPOSIT_GUARD], + "purpose": "Current LP token allowance to Deposit Guard", + }, + { + "contract": strategy["address"], + "function": "totalSupply()", + "args": [], + "purpose": "Vault share supply context", + }, + ], + "riskNotes": build_risk_notes(strategy), + } + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Live Hydrex vault discovery and execution planning for the fxusd skill.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + discover_parser = subparsers.add_parser("discover", help="List live Hydrex single-sided strategies") + discover_parser.add_argument("--deposit-token", help="Token symbol or address filter, for example fxUSD") + discover_parser.set_defaults(handler=discover_command) + + recommend_parser = subparsers.add_parser("recommend", help="Rank Hydrex strategies for a deposit token") + recommend_parser.add_argument("--deposit-token", required=True, help="Token symbol or address, for example fxUSD") + recommend_parser.add_argument("--limit", type=int, default=5, help="Number of results to return") + recommend_parser.set_defaults(handler=recommend_command) + + deposit_parser = subparsers.add_parser("deposit-plan", help="Build a Hydrex deposit execution plan") + deposit_parser.add_argument("--from-address", required=True, help="User wallet address on Base") + deposit_parser.add_argument("--amount", required=True, help="Human-readable deposit amount") + deposit_parser.add_argument("--deposit-token", help="Optional live API token filter, for example fxUSD") + deposit_parser.add_argument("--vault-address", help="Exact vault address") + deposit_parser.add_argument("--vault-title", help="Exact vault title, for example fxUSD/USDC") + deposit_parser.add_argument("--token-decimals", type=int, help="Override decimals if token metadata is unknown") + deposit_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL for live token balance and allowance reads") + deposit_parser.set_defaults(handler=deposit_plan_command) + + withdraw_parser = subparsers.add_parser("withdraw-plan", help="Build a Hydrex withdrawal execution plan") + withdraw_parser.add_argument("--from-address", required=True, help="User wallet address on Base") + withdraw_parser.add_argument("--deposit-token", help="Optional live API token filter, for example fxUSD") + withdraw_parser.add_argument("--vault-address", help="Exact vault address") + withdraw_parser.add_argument("--vault-title", help="Exact vault title, for example fxUSD/USDC") + withdraw_parser.add_argument("--shares", help="Raw LP shares to withdraw") + withdraw_parser.add_argument("--fraction", help="Fraction of current LP shares to withdraw, for example 0.5") + withdraw_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL for live LP share reads") + withdraw_parser.set_defaults(handler=withdraw_plan_command) + + position_parser = subparsers.add_parser("position-reads", help="Build the read plan for a Hydrex position") + position_parser.add_argument("--from-address", required=True, help="User wallet address on Base") + position_parser.add_argument("--deposit-token", help="Optional live API token filter, for example fxUSD") + position_parser.add_argument("--vault-address", help="Exact vault address") + position_parser.add_argument("--vault-title", help="Exact vault title, for example fxUSD/USDC") + position_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL for live LP share reads") + position_parser.set_defaults(handler=position_reads_command) + + return parser + + +def main(argv: list[str]) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.handler(args) + except Exception as error: + print_json({"error": str(error)}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 32e4d6aa7af3371bf2b4689518d63fda45cbbecf Mon Sep 17 00:00:00 2001 From: Tao Wang Date: Tue, 17 Mar 2026 22:37:24 -0700 Subject: [PATCH 3/3] Expand fxusd Morpho monitoring and risk plans --- fxusd/SKILL.md | 46 +- fxusd/references/morpho.md | 161 ++- fxusd/scripts/fxusd_morpho.py | 2006 +++++++++++++++++++++++++++++++++ 3 files changed, 2199 insertions(+), 14 deletions(-) create mode 100644 fxusd/scripts/fxusd_morpho.py diff --git a/fxusd/SKILL.md b/fxusd/SKILL.md index bdceb06c..6525492e 100644 --- a/fxusd/SKILL.md +++ b/fxusd/SKILL.md @@ -14,7 +14,7 @@ metadata: # fxusd -Version: `v0.4.0` +Version: `v0.9.0` Use this skill when the user wants a simpler way to put `fxUSD` to work on Base. @@ -89,13 +89,25 @@ Borrow fxUSD against my collateral on Morpho ### Morpho Supply / Borrow Planning -- Plan `fxUSD` supply, withdraw, borrow, and repay workflows +- Discover live Base Morpho Blue markets for `fxUSD` +- Produce execution-ready supply and withdraw plans for `fxUSD` +- Produce manual-decision borrow plans with projected LTV checks +- Provide quick risk-check outputs for agents to monitor current LTV and liquidation distance +- Provide alert-only monitoring outputs with `ok / warning / critical` levels for repeated position checks +- Produce execution-ready `repay-plan` and `add-collateral-plan` outputs for risk reduction +- Suggest safer maximum borrow sizes from the current collateral position - Compare Morpho lending with simpler `fxSAVE` or Hydrex routes - Treat borrow as a separate, higher-risk class from pure supply - Require explicit collateral, buffer, and market-availability checks before borrow planning **Reference**: [references/morpho.md](references/morpho.md) +Common Morpho user use cases: +- Monitor current `fxUSD` borrow positions and alert when they drift into warning or critical territory +- Check whether a wallet can safely borrow more `fxUSD` against collateral such as `BNKR` or `wstETH` +- Reduce risk with a `repay-plan` when the user wants the most direct way to lower LTV +- Keep a borrow position open with `add-collateral-plan` when the user has spare collateral and wants a wider liquidation buffer + ## Execution Model ### 1. fxSAVE @@ -140,9 +152,12 @@ Use when the user wants: Execution path: 1. Read [references/morpho.md](references/morpho.md). -2. Verify live market availability first. +2. Use `scripts/fxusd_morpho.py` to discover live markets and wallet positions. 3. Distinguish conservative supply routes from higher-risk borrow routes. -4. Only plan borrow actions when collateral assumptions, liquidation buffer, and oracle risk are explicit. +4. Use execution-ready supply and withdraw plans for simpler flows. +5. Use `alert-check` for recurring monitoring before considering any automated response. +6. Use `repay-plan` or `add-collateral-plan` as the first response when a borrow position moves into warning or critical territory. +7. Only plan borrow actions when collateral assumptions, liquidation buffer, and oracle risk are explicit. ## Common Workflows @@ -179,6 +194,8 @@ When the user wants capital efficiency or borrowing: 1. Use Morpho planning. 2. Prefer supply over borrow when the user has not explicitly asked for leverage. 3. If borrow is requested, preserve a conservative liquidation buffer. +4. For ongoing borrow positions, use `alert-check` to surface warning or critical states before acting. +5. Prefer `repay-plan` first, and use `add-collateral-plan` when the user has spare collateral inventory and wants to keep the debt open. ## Decision Guide @@ -242,6 +259,10 @@ These are mandatory guardrails. - `Deposit 10 fxUSD to fxSAVE` - `Redeem 50% of my fxSAVE to fxUSD` - `Compare fxSAVE and Morpho yield options for my fxUSD` +- `Monitor my Morpho fxUSD positions and alert me if they become risky` +- `Run an alert-only Morpho risk check on my BNKR-backed fxUSD borrow` +- `Build a repay plan to lower risk on my BNKR-backed fxUSD borrow` +- `Build an add-collateral plan for my BNKR-backed fxUSD borrow` - `Use my Bankr wallet to mint fxSAVE from 100 fxUSD` - `Use Bankr to redeem all my fxSAVE to USDC on Base` @@ -258,7 +279,11 @@ These are mandatory guardrails. ### Morpho - `Supply 5,000 fxUSD on Morpho` +- `Find the safest Morpho market for supplying fxUSD` +- `Withdraw 50% of my supplied fxUSD from Morpho` - `Borrow fxUSD against my collateral on Morpho` +- `Check my Morpho LTV before borrowing more fxUSD` +- `Suggest a safe fxUSD borrow size using my BNKR collateral` - `Compare Morpho supply with fxSAVE for my idle fxUSD` - `Use Bankr to supply my idle fxUSD on Morpho` - `Use Bankr to compare Morpho and fxSAVE for my Bankr wallet` @@ -277,14 +302,20 @@ When the user wants execution through Bankr: 2. Prefer wallet-aware language such as `Use my Bankr wallet...` when the user explicitly wants Bankr execution. 3. For `fxSAVE`, use the local app backend to build approval and main transaction plans first. 4. For `Hydrex`, use `scripts/fxusd_hydrex.py` and prefer the emitted `bankrReady.steps`. -5. Execute steps in order and wait for confirmation before moving to the next step. -6. If live balance, allowance, or LP shares are insufficient, stop and explain the blocker instead of forcing execution. +5. For conservative Morpho lending, use `scripts/fxusd_morpho.py` and prefer supply or withdraw over borrow by default. +6. For Morpho borrow, use `risk-check` and `borrow-plan`, but keep the final borrow decision with the user. +7. Execute steps in order and wait for confirmation before moving to the next step. +8. If live balance, allowance, LP shares, supply shares, or collateral headroom are insufficient, stop and explain the blocker instead of forcing execution. Useful natural-language styles: - `Use my Bankr wallet to mint fxSAVE from 25 fxUSD` - `Use Bankr to deposit all my idle fxUSD into the safest Hydrex stablecoin vault` - `Use Bankr to withdraw my Hydrex fxUSD/BNKR position` +- `Use Bankr to supply 100 fxUSD to the safest Morpho market` +- `Use Bankr to withdraw all my supplied fxUSD from Morpho` +- `Check my Morpho LTV and tell me if borrowing 200 more fxUSD is still safe` +- `Tell me the safest additional fxUSD I can borrow against my BNKR collateral` - `Use Bankr to compare Morpho supply with fxSAVE for my idle fxUSD` ## Detailed References @@ -292,8 +323,11 @@ Useful natural-language styles: - **[fxSAVE Shortcut API](references/api.md)** — Bundle building, approval flow, and app backend usage - **[Hydrex Single-Sided Liquidity](references/hydrex.md)** — Discovery, ranking, deposits, withdrawals, and Bankr-ready steps - **[Morpho Planning](references/morpho.md)** — Supply, withdraw, borrow, repay, and risk controls + plus execution-ready supply/withdraw planning and quick LTV checks ## Local Scripts - `scripts/fxusd_cli.py` — Preview `fxSAVE` mint, redeem, and approval plans - `scripts/fxusd_hydrex.py` — Discover Hydrex vaults, classify risk, and emit execution-ready plus Bankr-ready Hydrex transactions +- `scripts/fxusd_morpho.py` — Discover Morpho Blue markets, inspect positions, emit execution-ready plus Bankr-ready supply/withdraw plans, and compute LTV-aware borrow plans + including safer maximum borrow-size suggestions diff --git a/fxusd/references/morpho.md b/fxusd/references/morpho.md index 78ce1b26..5fa3367d 100644 --- a/fxusd/references/morpho.md +++ b/fxusd/references/morpho.md @@ -1,9 +1,11 @@ # Morpho Lend / Borrow -Use this module when the user wants to supply `fxUSD`, withdraw it later, or borrow against collateral through Morpho-style workflows. +Use this module when the user wants to supply `fxUSD`, withdraw it later, or evaluate higher-risk borrow paths through Morpho Blue on Base. -Reference pattern: +Reference patterns: - Morpho Earn example: https://clawhub.ai/lyoungblood/morpho-earn +- Morpho official GraphQL API: https://docs.morpho.org/tools/offchain/api/graphql/ +- Morpho Blue contract interface: https://github.com/morpho-org/morpho-blue/blob/main/src/interfaces/IMorpho.sol ## Important framing @@ -14,48 +16,189 @@ Treat `Morpho supply` and `Morpho borrow` as two different risk levels: - `supply`: generally simpler and closer to passive yield - `borrow`: leverage and liquidation risk +In this skill, `supply` and `withdraw` are execution-ready planning flows. +`borrow` remains planning-only unless explicit collateral and liquidation assumptions are surfaced. + ## What to support -- compare Morpho supply versus `fxSAVE` or Hydrex -- plan a `supply fxUSD` action -- plan a `withdraw supplied fxUSD` action +- discover live `fxUSD` Morpho Blue markets on Base +- classify collateral quality so supply markets are not compared as if they have the same risk +- recommend safer supply markets with a conservative heuristic +- build a `supply fxUSD` execution plan +- build a `withdraw supplied fxUSD` execution plan - plan a `borrow fxUSD` action only after validating market and collateral conditions -- plan a `repay fxUSD` action +- compare Morpho supply versus `fxSAVE` or Hydrex + +## Current local script + +`scripts/fxusd_morpho.py` + +Supported commands: + +- `discover` +- `recommend` +- `position-reads` +- `risk-check` +- `alert-check` +- `supply-plan` +- `withdraw-plan` +- `repay-plan` +- `add-collateral-plan` +- `borrow-plan` +- `suggest-borrow-size` + +The current Morpho module emits: + +- live market discovery from Morpho GraphQL +- live Base token balance and allowance checks +- live onchain Morpho position reads +- Bankr-ready `/agent/submit` steps for `supply` and `withdraw` +- Bankr-ready `/agent/submit` steps for `repay` and `add-collateral` +- manual-decision `borrow-plan` output with projected LTV checks +- alert-only monitoring output with `ok / warning / critical` severity for repeated position checks + +## Quick LTV checks for agents + +When an agent wants to avoid liquidation risk, it should check these fields before designing or suggesting a borrow: + +- `currentLtvPercent` +- `maxLtvPercent` +- `recommendedMaxLtvPercent` +- `healthFactor` +- `priceVariationToLiquidationPrice` + +Fast heuristic: + +1. treat `maxLtvPercent` as the hard protocol edge +2. treat `recommendedMaxLtvPercent` as the practical operating ceiling +3. if `currentLtvPercent` or `projectedLtvPercent` rises above `recommendedMaxLtvPercent`, do not auto-borrow +4. if `healthFactor` trends toward `1`, or `priceVariationToLiquidationPrice` becomes small, repay or add collateral + +Helpful commands: + +```bash +python3 scripts/fxusd_morpho.py risk-check --from-address 0x... --loan-token fxUSD +``` + +```bash +python3 scripts/fxusd_morpho.py alert-check --from-address 0x... --collateral-token BNKR --fail-on warning +``` + +```bash +python3 scripts/fxusd_morpho.py borrow-plan --from-address 0x... --collateral-token wstETH --amount 100 +``` + +```bash +python3 scripts/fxusd_morpho.py suggest-borrow-size --from-address 0x... --collateral-token BNKR +``` + +```bash +python3 scripts/fxusd_morpho.py repay-plan --from-address 0x... --collateral-token BNKR --fraction 1 +``` + +```bash +python3 scripts/fxusd_morpho.py add-collateral-plan --from-address 0x... --collateral-token BNKR --amount 10 +``` + +For `BNKR` or other altcoin collateral, the skill uses a tighter `riskAdjustedMaxLtvPercent` than the generic recommendation. +That output is meant to be the practical ceiling for agent suggestions. + +## Alert-only monitoring + +`alert-check` is the safer monitoring entrypoint for agents and automations. + +It does not change the position. Instead, it returns: + +- `summary.highestLevel` +- per-position `alert.level` +- `alert.reasons` +- `alert.recommendedAction` +- `alert.recommendedRecheckIn` + +Default severity model: + +- `ok`: no immediate alert +- `warning`: position is approaching a practical ceiling +- `critical`: position needs active intervention, not passive monitoring + +Default checks include: + +- health factor +- distance to liquidation +- current buffer to LLTV +- current LTV versus the practical warning ceiling + +Helpful pattern for agents: + +1. run `alert-check` +2. if `highestLevel == ok`, continue monitoring +3. if `highestLevel == warning`, prepare a `repay-plan` or `add-collateral-plan` +4. if `highestLevel == critical`, stop any new borrow action and escalate immediately +5. after the risk-reducing action confirms, re-run `alert-check` + +This is especially important for `BNKR` or other altcoin collateral. Those positions should be monitored actively, not just at open. ## Recommended user intents - `Supply 5,000 fxUSD on Morpho` +- `Show me the safest Morpho market for supplying fxUSD` - `Withdraw my supplied fxUSD from Morpho` - `Borrow fxUSD against my collateral on Morpho` - `Repay my Morpho fxUSD debt` - `Compare Morpho supply yield with fxSAVE` +- `Check my Morpho LTV before borrowing more fxUSD` +- `Suggest a safe fxUSD borrow size using my BNKR collateral` +- `Repay 50% of my Morpho fxUSD debt` +- `Add 10 BNKR collateral to my Morpho fxUSD position` ## Best execution strategy For normal yield-seeking users: 1. start with supply-only analysis -2. compare net yield with `fxSAVE` -3. only recommend borrow flows if the user explicitly wants leverage +2. discover live `fxUSD` markets first +3. prefer listed markets with stronger collateral quality and deeper liquidity +4. compare net yield with `fxSAVE` +5. only recommend borrow flows if the user explicitly wants leverage For borrow flows: 1. verify live market availability first 2. verify the collateral asset and liquidation thresholds 3. recommend a conservative borrow size, not the protocol maximum 4. make liquidation risk explicit before execution +5. leave the final borrow decision to the user +6. for existing borrow positions, run `alert-check` on a timer before discussing any new borrow or withdrawal + +For risk reduction flows: +1. if the position is in `warning`, prefer `repay-plan` first +2. use `add-collateral-plan` when the user wants to keep the debt open and actually holds spare collateral +3. for full debt reduction, prefer share-based `repay-plan --fraction 1` +4. after execution, run `alert-check` again instead of assuming the position is safe + +For withdraw flows: +1. prefer share-based withdrawal planning instead of asset-based max guesses +2. check whether the wallet also has active borrow shares or posted collateral +3. if the position is no longer supply-only, stop and require manual review before execution ## Risk controls - Do not assume a specific `fxUSD` market exists without current verification. +- Do not compare markets with blue-chip collateral and tail-risk collateral as if they belong to the same safety tier. - Stay well below max LTV. A safer planning posture is to keep meaningful headroom instead of optimizing for maximum borrow. - Treat oracle, curator, and market-parameter changes as live risks. - If rewards are routed through third-party claim-and-swap paths, review that transaction path carefully. +- Do not auto-execute a withdraw from a position that also has active borrow shares or collateral without explicit review. +- Do not auto-execute a borrow plan just because the protocol would allow it; use the recommended LTV buffer as the operational ceiling. +- Do not assume an add-collateral plan is feasible without checking actual collateral token balance and allowance. +- For full repay, prefer share-based repayment planning to reduce borrow-share rounding risk. ## Vulnerabilities and failure modes - Liquidation risk: borrowing is the sharpest edge in this skill set. - Market availability risk: `USDC` examples do not automatically map to `fxUSD`. +- Collateral-quality confusion: `fxUSD/wstETH` and `fxUSD/BNKR` should not be treated like equivalent supply routes. - Parameter drift: borrow caps, collateral factors, and rewards can change. - Oracle dependency: bad or lagging oracle conditions can damage otherwise reasonable leverage. +- Share-vs-asset rounding: for withdraws, a full-position action is safer when based on shares, not guessed asset amounts. ## Decision rule @@ -63,11 +206,13 @@ Prefer Morpho supply when: - the user wants simpler yield - a live `fxUSD` market is confirmed - the route is operationally simpler than Hydrex for the same capital +- the collateral side of the market is acceptable for the user's risk tolerance Prefer Morpho borrow only when: - the user explicitly wants leverage or capital efficiency - collateral assumptions are explicit - the user accepts liquidation risk +- the projected LTV remains below the recommended safety buffer, not just below protocol max Do not recommend a borrow plan when: - current market support is unclear diff --git a/fxusd/scripts/fxusd_morpho.py b/fxusd/scripts/fxusd_morpho.py new file mode 100644 index 00000000..01a72d3c --- /dev/null +++ b/fxusd/scripts/fxusd_morpho.py @@ -0,0 +1,2006 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any + + +GRAPHQL_URL = "https://blue-api.morpho.org/graphql" +BASE_RPC_URL = os.environ.get("BASE_RPC_URL", "https://base.llamarpc.com") +USER_AGENT = "fxusd-morpho/0.1 (+https://github.com/huwangtao123/fxsave-dapp)" +MORPHO_BLUE_ADDRESS = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" +BASE_CHAIN_ID = 8453 +ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") + + +@dataclass(frozen=True) +class Token: + symbol: str + address: str + decimals: int + + +TOKEN_REGISTRY = { + "fxUSD": Token("fxUSD", "0x55380fe7a1910dff29a47b622057ab4139da42c5", 18), + "USDC": Token("USDC", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", 6), + "WETH": Token("WETH", "0x4200000000000000000000000000000000000006", 18), + "wstETH": Token("wstETH", "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", 18), + "cbBTC": Token("cbBTC", "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", 8), + "BNKR": Token("BNKR", "0x22aF33FE49fD1Fa80c7149773dDe5890D3c76F3b", 18), + "CLANKER": Token("CLANKER", "0x1bc0c42215582d5A085795f4baDbaC3ff36d1Bcb", 18), + "VVV": Token("VVV", "0xacfE6019Ed1A7Dc6f7B508C02d1b04ec88cC21bf", 18), + "VIRTUAL": Token("VIRTUAL", "0x0b3e328455c4059EEb9e3f84b5543F74E24e7E1b", 18), +} +STABLE_COLLATERAL_SYMBOLS = {"FXUSD", "USDC", "USDT", "DAI", "USDS"} +BLUECHIP_COLLATERAL_SYMBOLS = {"WETH", "wstETH", "cbBTC"} + + +DISCOVER_QUERY = """ +query Markets($chainId: Int!, $loanAsset: String!) { + markets(where: { chainId_in: [$chainId], loanAssetAddress_in: [$loanAsset] }) { + items { + uniqueKey + listed + lltv + irmAddress + morphoBlue { + address + chain { + id + network + } + } + loanAsset { + address + symbol + decimals + } + collateralAsset { + address + symbol + decimals + } + oracle { + address + } + state { + supplyApy + borrowApy + supplyAssets + supplyShares + supplyAssetsUsd + borrowAssets + borrowAssetsUsd + liquidityAssets + liquidityAssetsUsd + utilization + } + warnings { + type + level + } + } + } +} +""" + +MARKET_BY_KEY_QUERY = """ +query Market($key: String!, $chainId: Int!) { + marketByUniqueKey(uniqueKey: $key, chainId: $chainId) { + uniqueKey + listed + lltv + irmAddress + morphoBlue { + address + chain { + id + network + } + } + loanAsset { + address + symbol + decimals + } + collateralAsset { + address + symbol + decimals + } + oracle { + address + } + state { + supplyApy + borrowApy + supplyAssets + supplyShares + supplyAssetsUsd + borrowAssets + borrowAssetsUsd + liquidityAssets + liquidityAssetsUsd + utilization + } + warnings { + type + level + } + } +} +""" + +USER_POSITIONS_QUERY = """ +query Positions($chainId: Int!, $user: String!) { + marketPositions(where: { chainId_in: [$chainId], userAddress_in: [$user] }) { + items { + healthFactor + listed + priceVariationToLiquidationPrice + market { + uniqueKey + listed + lltv + irmAddress + morphoBlue { + address + } + loanAsset { + address + symbol + decimals + } + collateralAsset { + address + symbol + decimals + } + oracle { + address + } + state { + supplyApy + borrowApy + supplyAssets + supplyShares + supplyAssetsUsd + borrowAssets + borrowAssetsUsd + liquidityAssets + liquidityAssetsUsd + utilization + } + warnings { + type + level + } + } + state { + collateral + collateralUsd + supplyAssets + supplyAssetsUsd + supplyShares + borrowAssets + borrowAssetsUsd + borrowShares + } + } + } +} +""" + + +def request_graphql(query: str, variables: dict[str, Any]) -> Any: + last_error: Exception | None = None + payload = json.dumps({"query": query, "variables": variables}).encode("utf-8") + + for delay in (0.0, 0.4, 1.0): + if delay: + time.sleep(delay) + + request = urllib.request.Request( + GRAPHQL_URL, + data=payload, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + decoded = json.loads(response.read().decode("utf-8")) + if decoded.get("errors"): + raise ValueError(f"GraphQL error: {decoded['errors']}") + return decoded["data"] + except urllib.error.HTTPError as error: + last_error = error + if error.code != 429: + raw = error.read().decode("utf-8") + raise ValueError(f"GraphQL HTTP error {error.code}: {raw}") from error + + if last_error is not None: + raise last_error + raise ValueError("Unexpected GraphQL request failure.") + + +def post_json(url: str, payload: dict[str, Any]) -> Any: + encoded = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=encoded, + headers={ + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + + +def rpc_call(rpc_url: str, to: str, data: str) -> str: + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [ + { + "to": to, + "data": data, + }, + "latest", + ], + } + response = post_json(rpc_url, payload) + if "error" in response: + raise ValueError(f"RPC call failed: {response['error']}") + result = response.get("result") + if not isinstance(result, str) or not result.startswith("0x"): + raise ValueError("Unexpected RPC result.") + return result + + +def validate_address(value: str, field_name: str) -> str: + if not ADDRESS_RE.match(value): + raise ValueError(f"Invalid {field_name}: {value}") + return value + + +def parse_units(value: str, decimals: int) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Amount is required.") + if normalized.count(".") > 1: + raise ValueError(f"Invalid amount: {value}") + + whole, _, fraction = normalized.partition(".") + if not whole: + whole = "0" + if not whole.isdigit() or (fraction and not fraction.isdigit()): + raise ValueError(f"Invalid amount: {value}") + + padded_fraction = (fraction + ("0" * decimals))[:decimals] + combined = f"{whole}{padded_fraction}".lstrip("0") + return combined or "0" + + +def parse_fraction(value: str) -> str: + try: + parsed = Decimal(value) + except InvalidOperation as error: + raise ValueError(f"Invalid fraction: {value}") from error + + if parsed <= 0 or parsed > 1: + raise ValueError("Fraction must be greater than 0 and less than or equal to 1.") + return format(parsed.normalize(), "f") + + +def fraction_to_parts(value: str) -> tuple[int, int]: + normalized = parse_fraction(value) + whole, _, fraction = normalized.partition(".") + numerator = int(f"{whole}{fraction}") if fraction else int(whole) + denominator = 10 ** len(fraction) if fraction else 1 + return numerator, denominator + + +def pad_hex(value: str) -> str: + return value[2:].lower().rjust(64, "0") + + +def encode_uint256(value: int | str) -> str: + return hex(int(value))[2:].rjust(64, "0") + + +def encode_call(selector: str, words: list[str]) -> str: + return f"{selector}{''.join(words)}" + + +def encode_empty_bytes(offset_words: int) -> list[str]: + return [encode_uint256(offset_words * 32), encode_uint256(0)] + + +def build_approve_transaction(token: str, spender: str, amount: str) -> dict[str, Any]: + return { + "to": token, + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0x095ea7b3", [pad_hex(spender), encode_uint256(amount)]), + } + + +def build_bankr_submit_request(transaction: dict[str, Any], description: str) -> dict[str, Any]: + return { + "transaction": transaction, + "description": description, + "waitForConfirmation": True, + } + + +def build_bankr_steps(steps: list[tuple[str, dict[str, Any]]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for index, (description, transaction) in enumerate(steps, start=1): + result.append( + { + "step": index, + "description": description, + "request": build_bankr_submit_request(transaction, description), + } + ) + return result + + +def erc20_balance_of(rpc_url: str, contract: str, owner: str) -> int: + return int(rpc_call(rpc_url, contract, f"0x70a08231{pad_hex(owner)}"), 16) + + +def erc20_allowance(rpc_url: str, contract: str, owner: str, spender: str) -> int: + return int(rpc_call(rpc_url, contract, f"0xdd62ed3e{pad_hex(owner)}{pad_hex(spender)}"), 16) + + +def decode_words(hex_data: str, words: int) -> list[int]: + raw = hex_data[2:] + if len(raw) < words * 64: + raise ValueError("RPC response shorter than expected.") + result: list[int] = [] + for index in range(words): + start = index * 64 + result.append(int(raw[start : start + 64], 16)) + return result + + +def morpho_position(rpc_url: str, morpho: str, market_key: str, user_address: str) -> dict[str, str]: + data = f"0x93c52062{market_key[2:].rjust(64, '0')}{pad_hex(user_address)}" + supply_shares, borrow_shares, collateral = decode_words(rpc_call(rpc_url, morpho, data), 3) + return { + "supplyShares": str(supply_shares), + "borrowShares": str(borrow_shares), + "collateral": str(collateral), + } + + +def morpho_market_state(rpc_url: str, morpho: str, market_key: str) -> dict[str, str]: + data = f"0x5c60e39a{market_key[2:].rjust(64, '0')}" + total_supply_assets, total_supply_shares, total_borrow_assets, total_borrow_shares, last_update, fee = decode_words( + rpc_call(rpc_url, morpho, data), + 6, + ) + return { + "totalSupplyAssets": str(total_supply_assets), + "totalSupplyShares": str(total_supply_shares), + "totalBorrowAssets": str(total_borrow_assets), + "totalBorrowShares": str(total_borrow_shares), + "lastUpdate": str(last_update), + "fee": str(fee), + } + + +def estimate_assets_from_shares(shares: int, total_assets: int, total_shares: int, round_up: bool = False) -> int: + if shares <= 0 or total_assets <= 0 or total_shares <= 0: + return 0 + numerator = shares * total_assets + if round_up: + return (numerator + total_shares - 1) // total_shares + return numerator // total_shares + + +def resolve_loan_token(value: str | None) -> Token: + if not value: + return TOKEN_REGISTRY["fxUSD"] + preset = TOKEN_REGISTRY.get(value) + if preset: + return preset + for token in TOKEN_REGISTRY.values(): + if token.address.lower() == value.lower(): + return token + raise ValueError(f"Unknown loan token: {value}") + + +def classify_collateral(symbol: str | None) -> tuple[str, str]: + normalized = (symbol or "").upper() + if normalized in STABLE_COLLATERAL_SYMBOLS: + return ( + "stable-collateral", + "Collateral is stable or correlated, so borrower-side collateral volatility is lower.", + ) + if symbol in BLUECHIP_COLLATERAL_SYMBOLS or normalized in {item.upper() for item in BLUECHIP_COLLATERAL_SYMBOLS}: + return ( + "bluechip-collateral", + "Collateral is a blue-chip asset, which is generally safer than tail-risk altcoin collateral.", + ) + return ( + "altcoin-collateral", + "Collateral is a volatile or tail-risk asset, so borrower defaults and bad debt risk are higher.", + ) + + +def normalize_market(market: dict[str, Any]) -> dict[str, Any]: + state = market.get("state") or {} + collateral_asset = market.get("collateralAsset") or {} + loan_asset = market.get("loanAsset") or {} + morpho_blue = market.get("morphoBlue") or {} + oracle = market.get("oracle") or {} + warnings = market.get("warnings") or [] + collateral_symbol = collateral_asset.get("symbol") + risk_class, risk_summary = classify_collateral(collateral_symbol) + + return { + "uniqueKey": market.get("uniqueKey"), + "title": f"{loan_asset.get('symbol', 'asset')}/{collateral_symbol or 'unknown'}", + "listed": bool(market.get("listed")), + "morphoBlueAddress": morpho_blue.get("address") or MORPHO_BLUE_ADDRESS, + "loanAsset": { + "address": loan_asset.get("address"), + "symbol": loan_asset.get("symbol"), + "decimals": loan_asset.get("decimals"), + }, + "collateralAsset": { + "address": collateral_asset.get("address"), + "symbol": collateral_symbol, + "decimals": collateral_asset.get("decimals"), + }, + "oracleAddress": oracle.get("address"), + "irmAddress": market.get("irmAddress"), + "lltv": market.get("lltv"), + "lltvPercent": round((int(market.get("lltv") or 0) / 1e18) * 100, 2), + "collateralRiskClass": risk_class, + "collateralRiskSummary": risk_summary, + "state": { + "supplyApy": state.get("supplyApy") or 0, + "borrowApy": state.get("borrowApy") or 0, + "supplyAssets": state.get("supplyAssets") or "0", + "supplyShares": state.get("supplyShares") or "0", + "supplyAssetsUsd": state.get("supplyAssetsUsd") or 0, + "borrowAssets": state.get("borrowAssets") or "0", + "borrowAssetsUsd": state.get("borrowAssetsUsd") or 0, + "liquidityAssets": state.get("liquidityAssets") or "0", + "liquidityAssetsUsd": state.get("liquidityAssetsUsd") or 0, + "utilization": state.get("utilization") or 0, + }, + "warnings": warnings, + } + + +def rank_market(market: dict[str, Any]) -> tuple[float, list[str]]: + score = 0.0 + reasons: list[str] = [] + state = market["state"] + supply_apy_pct = float(state["supplyApy"]) * 100 + liquidity_usd = float(state["liquidityAssetsUsd"] or 0) + supply_assets_usd = float(state["supplyAssetsUsd"] or 0) + utilization = float(state["utilization"] or 0) + + if market["listed"]: + score += 35 + reasons.append("listed market") + else: + score -= 40 + reasons.append("unlisted market penalty") + + risk_class = market["collateralRiskClass"] + if risk_class == "stable-collateral": + score += 18 + reasons.append("stable collateral bonus") + elif risk_class == "bluechip-collateral": + score += 12 + reasons.append("blue-chip collateral bonus") + else: + score -= 10 + reasons.append("altcoin collateral penalty") + + score += min(supply_apy_pct, 8) + if supply_apy_pct > 0: + reasons.append(f"supply APY {supply_apy_pct:.2f}%") + + if liquidity_usd > 0: + score += min(math.log10(liquidity_usd + 1) * 6, 18) + reasons.append(f"liquidity ${liquidity_usd:,.2f}") + + if supply_assets_usd > 0: + score += min(math.log10(supply_assets_usd + 1) * 4, 12) + reasons.append(f"supply TVL ${supply_assets_usd:,.2f}") + + if utilization > 0.9: + score -= 10 + reasons.append("very high utilization penalty") + elif utilization > 0.75: + score -= 5 + reasons.append("high utilization penalty") + else: + score += 2 + reasons.append("healthy utilization") + + if market["warnings"]: + score -= 12 + reasons.append("warnings present") + + return score, reasons + + +def fetch_markets(loan_token: Token) -> list[dict[str, Any]]: + data = request_graphql(DISCOVER_QUERY, {"chainId": BASE_CHAIN_ID, "loanAsset": loan_token.address}) + items = data["markets"]["items"] + return [normalize_market(item) for item in items] + + +def fetch_market_by_key(market_key: str) -> dict[str, Any]: + data = request_graphql(MARKET_BY_KEY_QUERY, {"key": market_key, "chainId": BASE_CHAIN_ID}) + market = data.get("marketByUniqueKey") + if not market: + raise ValueError(f"No Morpho market found for key {market_key}.") + return normalize_market(market) + + +def fetch_user_positions(user_address: str, loan_token: Token) -> list[dict[str, Any]]: + data = request_graphql(USER_POSITIONS_QUERY, {"chainId": BASE_CHAIN_ID, "user": user_address}) + items = data["marketPositions"]["items"] + filtered: list[dict[str, Any]] = [] + for item in items: + market = normalize_market(item["market"]) + if market["loanAsset"]["address"].lower() != loan_token.address.lower(): + continue + filtered.append( + { + "market": market, + "listed": item.get("listed"), + "healthFactor": item.get("healthFactor"), + "priceVariationToLiquidationPrice": item.get("priceVariationToLiquidationPrice"), + "state": item.get("state") or {}, + } + ) + return filtered + + +def resolve_user_position( + *, + positions: list[dict[str, Any]], + market_key: str | None, + collateral_token: str | None, +) -> dict[str, Any]: + if market_key: + matches = [position for position in positions if position["market"]["uniqueKey"].lower() == market_key.lower()] + if not matches: + raise ValueError(f"No Morpho position matched market key {market_key}.") + return matches[0] + + if collateral_token: + normalized = collateral_token.lower() + matches = [ + position + for position in positions + if (position["market"]["collateralAsset"]["symbol"] or "").lower() == normalized + or (position["market"]["collateralAsset"]["address"] or "").lower() == normalized + ] + if not matches: + raise ValueError(f"No Morpho position matched collateral token {collateral_token}.") + if len(matches) > 1: + raise ValueError("Multiple Morpho positions matched. Use --market-key for an exact selection.") + return matches[0] + + active = [ + position + for position in positions + if float(position["state"].get("collateralUsd") or 0) > 0 or float(position["state"].get("borrowAssetsUsd") or 0) > 0 + ] + if len(active) != 1: + raise ValueError("Multiple or zero Morpho risk positions matched. Use --market-key or --collateral-token.") + return active[0] + + +def resolve_market( + *, + markets: list[dict[str, Any]], + market_key: str | None, + collateral_token: str | None, +) -> dict[str, Any]: + if market_key: + matches = [market for market in markets if market["uniqueKey"].lower() == market_key.lower()] + if not matches: + raise ValueError(f"No Morpho market matched unique key {market_key}.") + return matches[0] + + if collateral_token: + normalized = collateral_token.lower() + matches = [ + market + for market in markets + if (market["collateralAsset"]["symbol"] or "").lower() == normalized + or (market["collateralAsset"]["address"] or "").lower() == normalized + ] + if not matches: + raise ValueError(f"No Morpho market matched collateral token {collateral_token}.") + if len(matches) > 1: + raise ValueError("Multiple Morpho markets matched. Use --market-key for an exact selection.") + return matches[0] + + raise ValueError("Provide --market-key or --collateral-token.") + + +def recommended_markets(markets: list[dict[str, Any]]) -> list[dict[str, Any]]: + ranked: list[dict[str, Any]] = [] + for market in markets: + entry = dict(market) + score, reasons = rank_market(entry) + entry["score"] = round(score, 2) + entry["reasons"] = reasons + ranked.append(entry) + ranked.sort(key=lambda item: item["score"], reverse=True) + return ranked + + +def build_market_param_words(market: dict[str, Any]) -> list[str]: + return [ + pad_hex(market["loanAsset"]["address"]), + pad_hex(market["collateralAsset"]["address"]), + pad_hex(market["oracleAddress"]), + pad_hex(market["irmAddress"]), + encode_uint256(market["lltv"]), + ] + + +def build_supply_transaction(market: dict[str, Any], amount: str, on_behalf: str) -> dict[str, Any]: + words = build_market_param_words(market) + [ + encode_uint256(amount), + encode_uint256(0), + pad_hex(on_behalf), + *encode_empty_bytes(9), + ] + return { + "to": market["morphoBlueAddress"], + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0xa99aad89", words), + } + + +def build_withdraw_transaction(market: dict[str, Any], shares: str, on_behalf: str, receiver: str) -> dict[str, Any]: + words = build_market_param_words(market) + [ + encode_uint256(0), + encode_uint256(shares), + pad_hex(on_behalf), + pad_hex(receiver), + ] + return { + "to": market["morphoBlueAddress"], + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0x5c2bea49", words), + } + + +def build_borrow_transaction(market: dict[str, Any], amount: str, on_behalf: str, receiver: str) -> dict[str, Any]: + words = build_market_param_words(market) + [ + encode_uint256(amount), + encode_uint256(0), + pad_hex(on_behalf), + pad_hex(receiver), + ] + return { + "to": market["morphoBlueAddress"], + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0x50d8cd4b", words), + } + + +def build_repay_transaction(market: dict[str, Any], assets: str, shares: str, on_behalf: str) -> dict[str, Any]: + words = build_market_param_words(market) + [ + encode_uint256(assets), + encode_uint256(shares), + pad_hex(on_behalf), + *encode_empty_bytes(9), + ] + return { + "to": market["morphoBlueAddress"], + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0x20b76e81", words), + } + + +def build_supply_collateral_transaction(market: dict[str, Any], amount: str, on_behalf: str) -> dict[str, Any]: + words = build_market_param_words(market) + [ + encode_uint256(amount), + pad_hex(on_behalf), + *encode_empty_bytes(8), + ] + return { + "to": market["morphoBlueAddress"], + "chainId": BASE_CHAIN_ID, + "value": "0", + "data": encode_call("0x238d6579", words), + } + + +def recommended_max_ltv_percent(max_ltv_percent: float) -> float: + return round(max_ltv_percent * 0.8, 2) + + +def risk_adjusted_max_ltv_percent(market: dict[str, Any]) -> float: + max_ltv_percent = float(market["lltvPercent"]) + risk_class = market["collateralRiskClass"] + if risk_class == "stable-collateral": + return round(max_ltv_percent * 0.85, 2) + if risk_class == "bluechip-collateral": + return round(max_ltv_percent * 0.8, 2) + return round(max_ltv_percent * 0.6, 2) + + +def compute_risk_metrics( + *, + market: dict[str, Any], + graph_state: dict[str, Any], + health_factor: float | None, + price_variation_to_liquidation_price: float | None, + projected_additional_borrow_usd: float = 0.0, + projected_additional_collateral_usd: float = 0.0, +) -> dict[str, Any]: + collateral_usd = float(graph_state.get("collateralUsd") or 0) + current_borrow_usd = float(graph_state.get("borrowAssetsUsd") or 0) + max_ltv_percent = float(market["lltvPercent"]) + recommended_ltv_percent = recommended_max_ltv_percent(max_ltv_percent) + risk_adjusted_ltv_percent = risk_adjusted_max_ltv_percent(market) + + current_ltv_percent = None + projected_ltv_percent = None + protocol_headroom_usd = None + recommended_headroom_usd = None + risk_adjusted_headroom_usd = None + projected_buffer_to_lltv_percent = None + + if collateral_usd > 0: + current_ltv_percent = round((current_borrow_usd / collateral_usd) * 100, 4) + projected_borrow_usd = max(current_borrow_usd + projected_additional_borrow_usd, 0) + projected_collateral_usd = max(collateral_usd + projected_additional_collateral_usd, collateral_usd) + projected_ltv_percent = round((projected_borrow_usd / projected_collateral_usd) * 100, 4) + protocol_headroom_usd = round(max((collateral_usd * max_ltv_percent / 100) - current_borrow_usd, 0), 6) + recommended_headroom_usd = round( + max((collateral_usd * recommended_ltv_percent / 100) - current_borrow_usd, 0), + 6, + ) + risk_adjusted_headroom_usd = round( + max((collateral_usd * risk_adjusted_ltv_percent / 100) - current_borrow_usd, 0), + 6, + ) + projected_buffer_to_lltv_percent = round(max_ltv_percent - projected_ltv_percent, 4) + + status = "no-borrow-risk" + if collateral_usd <= 0: + status = "no-collateral" + elif current_ltv_percent is not None: + if current_ltv_percent >= max_ltv_percent or (health_factor is not None and health_factor <= 1): + status = "liquidation-risk" + elif current_ltv_percent >= recommended_ltv_percent: + status = "caution" + else: + status = "healthy" + + return { + "collateralUsd": collateral_usd, + "currentBorrowUsd": current_borrow_usd, + "currentLtvPercent": current_ltv_percent, + "projectedAdditionalBorrowUsd": projected_additional_borrow_usd, + "projectedAdditionalCollateralUsd": projected_additional_collateral_usd, + "projectedLtvPercent": projected_ltv_percent, + "maxLtvPercent": max_ltv_percent, + "recommendedMaxLtvPercent": recommended_ltv_percent, + "riskAdjustedMaxLtvPercent": risk_adjusted_ltv_percent, + "protocolHeadroomUsd": protocol_headroom_usd, + "recommendedHeadroomUsd": recommended_headroom_usd, + "riskAdjustedHeadroomUsd": risk_adjusted_headroom_usd, + "projectedBufferToLltvPercent": projected_buffer_to_lltv_percent, + "healthFactor": health_factor, + "priceVariationToLiquidationPrice": price_variation_to_liquidation_price, + "status": status, + } + + +def build_risk_notes(market: dict[str, Any], mode: str) -> list[str]: + notes = [ + "Morpho market parameters and APY are live and can change over time.", + "Supplying to a Morpho market is simpler than borrowing, but it still depends on collateral quality and liquidity.", + market["collateralRiskSummary"], + ] + if mode == "withdraw": + notes.append("Withdrawing from a market with active borrow or collateral positions can fail or reduce safety margins.") + if mode == "borrow": + notes.append("Borrowing should remain a manual decision path with explicit LTV and liquidation-buffer review.") + if mode == "repay": + notes.append("Repaying debt should lower LTV, but you should re-run alert-check after confirmation to verify the new safety margin.") + if mode == "add-collateral": + notes.append("Adding collateral should widen the liquidation buffer, but you should re-run alert-check after confirmation to verify the new safety margin.") + if market["warnings"]: + notes.append("Market warnings are present and should be reviewed before execution.") + return notes + + +def find_position_for_market(positions: list[dict[str, Any]], market_key: str) -> dict[str, Any] | None: + for position in positions: + if position["market"]["uniqueKey"].lower() == market_key.lower(): + return position + return None + + +def estimate_collateral_value_usd( + *, + market: dict[str, Any], + graph_state: dict[str, Any], + collateral_assets_raw: int, +) -> float | None: + current_collateral_raw = int(graph_state.get("collateral") or 0) + current_collateral_usd = float(graph_state.get("collateralUsd") or 0) + if current_collateral_raw <= 0 or current_collateral_usd <= 0 or collateral_assets_raw <= 0: + return None + return round((current_collateral_usd / current_collateral_raw) * collateral_assets_raw, 6) + + +def severity_rank(level: str) -> int: + order = { + "ok": 0, + "warning": 1, + "critical": 2, + } + return order.get(level, 0) + + +def liquidation_distance_percent(value: float | None) -> float | None: + if value is None: + return None + return round(abs(value) * 100, 4) + + +def monitoring_interval(level: str, risk_class: str) -> str: + if level == "critical": + return "15m" + if level == "warning": + return "1h" if risk_class == "altcoin-collateral" else "4h" + if risk_class == "altcoin-collateral": + return "6h" + if risk_class == "bluechip-collateral": + return "12h" + return "24h" + + +def evaluate_alert( + *, + market: dict[str, Any], + risk_metrics: dict[str, Any], + warning_health_factor: float, + critical_health_factor: float, + warning_liquidation_distance_percent: float, + critical_liquidation_distance_percent: float, + warning_buffer_to_lltv_percent: float, + critical_buffer_to_lltv_percent: float, +) -> dict[str, Any]: + risk_class = market["collateralRiskClass"] + current_ltv_percent = risk_metrics.get("currentLtvPercent") + max_ltv_percent = risk_metrics.get("maxLtvPercent") + recommended_ltv_percent = risk_metrics.get("recommendedMaxLtvPercent") + risk_adjusted_ltv_percent = risk_metrics.get("riskAdjustedMaxLtvPercent") + health_factor = risk_metrics.get("healthFactor") + buffer_to_lltv_percent = risk_metrics.get("projectedBufferToLltvPercent") + distance_percent = liquidation_distance_percent(risk_metrics.get("priceVariationToLiquidationPrice")) + status = risk_metrics.get("status") + + warning_ltv_percent = risk_adjusted_ltv_percent if risk_class == "altcoin-collateral" else recommended_ltv_percent + warning_reasons: list[str] = [] + critical_reasons: list[str] = [] + + if status == "liquidation-risk": + critical_reasons.append("Position is already flagged as liquidation-risk.") + if current_ltv_percent is not None and max_ltv_percent is not None and current_ltv_percent >= max_ltv_percent: + critical_reasons.append("Current LTV is at or above the protocol maximum.") + if health_factor is not None and health_factor <= critical_health_factor: + critical_reasons.append( + f"Health factor {health_factor:.3f} is at or below the critical threshold {critical_health_factor:.2f}." + ) + if buffer_to_lltv_percent is not None and buffer_to_lltv_percent <= critical_buffer_to_lltv_percent: + critical_reasons.append( + f"Buffer to LLTV is only {buffer_to_lltv_percent:.2f}%, below the critical threshold {critical_buffer_to_lltv_percent:.2f}%." + ) + if distance_percent is not None and distance_percent <= critical_liquidation_distance_percent: + critical_reasons.append( + f"Estimated distance to liquidation is only {distance_percent:.2f}%, below the critical threshold {critical_liquidation_distance_percent:.2f}%." + ) + + if current_ltv_percent is not None and warning_ltv_percent is not None and current_ltv_percent >= warning_ltv_percent: + warning_reasons.append( + f"Current LTV {current_ltv_percent:.2f}% is at or above the warning ceiling {warning_ltv_percent:.2f}%." + ) + if health_factor is not None and health_factor <= warning_health_factor: + warning_reasons.append( + f"Health factor {health_factor:.3f} is at or below the warning threshold {warning_health_factor:.2f}." + ) + if buffer_to_lltv_percent is not None and buffer_to_lltv_percent <= warning_buffer_to_lltv_percent: + warning_reasons.append( + f"Buffer to LLTV is {buffer_to_lltv_percent:.2f}%, below the warning threshold {warning_buffer_to_lltv_percent:.2f}%." + ) + if distance_percent is not None and distance_percent <= warning_liquidation_distance_percent: + warning_reasons.append( + f"Estimated distance to liquidation is {distance_percent:.2f}%, below the warning threshold {warning_liquidation_distance_percent:.2f}%." + ) + + level = "ok" + reasons: list[str] = [] + if critical_reasons: + level = "critical" + reasons = critical_reasons + elif warning_reasons: + level = "warning" + reasons = warning_reasons + + if level == "critical": + action = "Do not increase borrow. Consider repaying debt or adding collateral immediately." + elif level == "warning": + action = "Monitor closely and consider lowering LTV before conditions worsen." + else: + action = "No immediate alert. Keep monitoring the position, especially before borrowing more." + + return { + "triggered": level != "ok", + "level": level, + "warningLtvPercent": warning_ltv_percent, + "criticalLtvPercent": max_ltv_percent, + "estimatedDistanceToLiquidationPercent": distance_percent, + "reasons": reasons, + "recommendedAction": action, + "recommendedRecheckIn": monitoring_interval(level, risk_class), + } + + +def print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def discover_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + markets = fetch_markets(loan_token) + summary = { + "listed": sum(1 for market in markets if market["listed"]), + "unlisted": sum(1 for market in markets if not market["listed"]), + "stableCollateral": sum(1 for market in markets if market["collateralRiskClass"] == "stable-collateral"), + "bluechipCollateral": sum(1 for market in markets if market["collateralRiskClass"] == "bluechip-collateral"), + "altcoinCollateral": sum(1 for market in markets if market["collateralRiskClass"] == "altcoin-collateral"), + } + print_json( + { + "command": "discover", + "loanToken": loan_token.symbol, + "count": len(markets), + "summary": summary, + "markets": markets, + } + ) + return 0 + + +def recommend_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + markets = recommended_markets(fetch_markets(loan_token)) + print_json( + { + "command": "recommend", + "loanToken": loan_token.symbol, + "top": markets[: args.limit], + } + ) + return 0 + + +def position_reads_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + + onchain_positions: list[dict[str, Any]] = [] + for item in positions: + market = item["market"] + morpho_address = market["morphoBlueAddress"] + onchain_state = morpho_position(args.rpc_url, morpho_address, market["uniqueKey"], user_address) + onchain_market = morpho_market_state(args.rpc_url, morpho_address, market["uniqueKey"]) + supply_assets_est = estimate_assets_from_shares( + int(onchain_state["supplyShares"]), + int(onchain_market["totalSupplyAssets"]), + int(onchain_market["totalSupplyShares"]), + round_up=False, + ) + borrow_assets_est = estimate_assets_from_shares( + int(onchain_state["borrowShares"]), + int(onchain_market["totalBorrowAssets"]), + int(onchain_market["totalBorrowShares"]), + round_up=True, + ) + onchain_positions.append( + { + "market": market, + "graphState": item["state"], + "healthFactor": item["healthFactor"], + "priceVariationToLiquidationPrice": item["priceVariationToLiquidationPrice"], + "riskMetrics": compute_risk_metrics( + market=market, + graph_state=item["state"], + health_factor=item["healthFactor"], + price_variation_to_liquidation_price=item["priceVariationToLiquidationPrice"], + ), + "onchainPosition": onchain_state, + "onchainMarketState": onchain_market, + "estimatedSupplyAssets": str(supply_assets_est), + "estimatedBorrowAssets": str(borrow_assets_est), + } + ) + + print_json( + { + "command": "position-reads", + "userAddress": user_address, + "loanToken": loan_token.symbol, + "positionCount": len(onchain_positions), + "positions": onchain_positions, + } + ) + return 0 + + +def supply_plan_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + markets = fetch_markets(loan_token) + if args.market_key or args.collateral_token: + market = resolve_market(markets=markets, market_key=args.market_key, collateral_token=args.collateral_token) + selection_mode = "explicit" + selection_reasons: list[str] = [] + else: + ranked = recommended_markets(markets) + if not ranked: + raise ValueError(f"No live Morpho markets were found for {loan_token.symbol}.") + market = ranked[0] + selection_mode = "recommended" + selection_reasons = market.get("reasons", []) + + user_address = validate_address(args.from_address, "from-address") + amount_raw = parse_units(args.amount, int(market["loanAsset"]["decimals"])) + morpho_address = market["morphoBlueAddress"] + + current_balance = erc20_balance_of(args.rpc_url, market["loanAsset"]["address"], user_address) + current_allowance = erc20_allowance(args.rpc_url, market["loanAsset"]["address"], user_address, morpho_address) + current_position = morpho_position(args.rpc_url, morpho_address, market["uniqueKey"], user_address) + + has_sufficient_balance = current_balance >= int(amount_raw) + needs_approval = current_allowance < int(amount_raw) + + approval: dict[str, str] | None = None + approval_transaction: dict[str, Any] | None = None + if needs_approval: + approval = { + "spender": morpho_address, + "token": market["loanAsset"]["address"], + "amount": amount_raw, + } + approval_transaction = build_approve_transaction(market["loanAsset"]["address"], morpho_address, amount_raw) + + supply_transaction = build_supply_transaction(market, amount_raw, user_address) + bankr_steps: list[tuple[str, dict[str, Any]]] = [] + if approval_transaction is not None: + bankr_steps.append( + ( + f"Approve {market['loanAsset']['symbol']} for Morpho Blue", + approval_transaction, + ) + ) + bankr_steps.append( + ( + f"Supply {args.amount} {market['loanAsset']['symbol']} to Morpho {market['title']}", + supply_transaction, + ) + ) + + print_json( + { + "command": "supply-plan", + "selectionMode": selection_mode, + "selectionReasons": selection_reasons, + "userAddress": user_address, + "market": market, + "liveState": { + "rpcUrl": args.rpc_url, + "currentTokenBalance": str(current_balance), + "currentAllowance": str(current_allowance), + "currentPosition": current_position, + }, + "executionReadiness": { + "hasSufficientBalance": has_sufficient_balance, + "needsApproval": needs_approval, + "readyToExecute": has_sufficient_balance, + }, + "approval": approval, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps(bankr_steps), + }, + "supplyCall": { + "chainId": BASE_CHAIN_ID, + "to": morpho_address, + "function": "supply((address loanToken,address collateralToken,address oracle,address irm,uint256 lltv),uint256 assets,uint256 shares,address onBehalf,bytes data)", + "args": { + "marketParams": { + "loanToken": market["loanAsset"]["address"], + "collateralToken": market["collateralAsset"]["address"], + "oracle": market["oracleAddress"], + "irm": market["irmAddress"], + "lltv": market["lltv"], + }, + "assets": amount_raw, + "shares": "0", + "onBehalf": user_address, + "data": "0x", + }, + }, + "supplyTransaction": supply_transaction, + "riskNotes": build_risk_notes(market, "supply"), + } + ) + return 0 + + +def withdraw_plan_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + user_positions = fetch_user_positions(user_address, loan_token) + + if args.market_key or args.collateral_token: + market = resolve_market( + markets=[item["market"] for item in user_positions] or fetch_markets(loan_token), + market_key=args.market_key, + collateral_token=args.collateral_token, + ) + else: + positive_supply_positions = [item for item in user_positions if int(item["state"].get("supplyShares") or 0) > 0] + if len(positive_supply_positions) != 1: + raise ValueError("Multiple or zero Morpho supply positions matched. Use --market-key or --collateral-token.") + market = positive_supply_positions[0]["market"] + + morpho_address = market["morphoBlueAddress"] + onchain_position = morpho_position(args.rpc_url, morpho_address, market["uniqueKey"], user_address) + onchain_market = morpho_market_state(args.rpc_url, morpho_address, market["uniqueKey"]) + + current_supply_shares = int(onchain_position["supplyShares"]) + current_borrow_shares = int(onchain_position["borrowShares"]) + current_collateral = int(onchain_position["collateral"]) + if current_supply_shares <= 0: + raise ValueError("No withdrawable Morpho supply shares were found for this wallet and market.") + + numerator, denominator = fraction_to_parts(args.fraction) + computed_shares = (current_supply_shares * numerator) // denominator + if computed_shares <= 0: + raise ValueError("Computed withdraw shares were zero. Increase the fraction.") + + estimated_assets = estimate_assets_from_shares( + computed_shares, + int(onchain_market["totalSupplyAssets"]), + int(onchain_market["totalSupplyShares"]), + round_up=False, + ) + + withdraw_transaction = build_withdraw_transaction(market, str(computed_shares), user_address, user_address) + blocked_by_active_leverage = current_borrow_shares > 0 or current_collateral > 0 + + print_json( + { + "command": "withdraw-plan", + "userAddress": user_address, + "market": market, + "liveState": { + "rpcUrl": args.rpc_url, + "currentPosition": onchain_position, + "currentMarketState": onchain_market, + }, + "sharePlan": { + "mode": "fraction", + "fraction": parse_fraction(args.fraction), + "currentSupplyShares": str(current_supply_shares), + "computedShares": str(computed_shares), + "estimatedAssetsOut": str(estimated_assets), + "instruction": "Use shares for partial or full Morpho withdrawals to reduce asset/share rounding risk.", + }, + "executionReadiness": { + "hasSupplyPosition": current_supply_shares > 0, + "hasActiveBorrowShares": current_borrow_shares > 0, + "hasActiveCollateral": current_collateral > 0, + "requiresBorrowPositionReview": blocked_by_active_leverage, + "readyToExecute": not blocked_by_active_leverage, + }, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps( + [ + ( + f"Withdraw {parse_fraction(args.fraction)} of supplied {market['loanAsset']['symbol']} from Morpho {market['title']}", + withdraw_transaction, + ) + ] + ), + }, + "withdrawCall": { + "chainId": BASE_CHAIN_ID, + "to": morpho_address, + "function": "withdraw((address loanToken,address collateralToken,address oracle,address irm,uint256 lltv),uint256 assets,uint256 shares,address onBehalf,address receiver)", + "args": { + "marketParams": { + "loanToken": market["loanAsset"]["address"], + "collateralToken": market["collateralAsset"]["address"], + "oracle": market["oracleAddress"], + "irm": market["irmAddress"], + "lltv": market["lltv"], + }, + "assets": "0", + "shares": str(computed_shares), + "onBehalf": user_address, + "receiver": user_address, + }, + }, + "withdrawTransaction": withdraw_transaction, + "riskNotes": build_risk_notes(market, "withdraw"), + } + ) + return 0 + + +def risk_check_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + + if args.market_key or args.collateral_token: + positions = [resolve_user_position(positions=positions, market_key=args.market_key, collateral_token=args.collateral_token)] + + results: list[dict[str, Any]] = [] + for position in positions: + results.append( + { + "market": position["market"], + "graphState": position["state"], + "healthFactor": position["healthFactor"], + "priceVariationToLiquidationPrice": position["priceVariationToLiquidationPrice"], + "riskMetrics": compute_risk_metrics( + market=position["market"], + graph_state=position["state"], + health_factor=position["healthFactor"], + price_variation_to_liquidation_price=position["priceVariationToLiquidationPrice"], + ), + "quickChecks": [ + "Keep currentLtvPercent well below the protocol maxLtvPercent.", + "Use recommendedMaxLtvPercent as the fast operational ceiling for agent decisions.", + "If healthFactor trends toward 1 or priceVariationToLiquidationPrice shrinks, reduce borrow or add collateral.", + ], + } + ) + + print_json( + { + "command": "risk-check", + "userAddress": user_address, + "loanToken": loan_token.symbol, + "positions": results, + } + ) + return 0 + + +def alert_check_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + + if args.market_key or args.collateral_token: + positions = [resolve_user_position(positions=positions, market_key=args.market_key, collateral_token=args.collateral_token)] + + alerts: list[dict[str, Any]] = [] + highest_level = "ok" + for position in positions: + risk_metrics = compute_risk_metrics( + market=position["market"], + graph_state=position["state"], + health_factor=position["healthFactor"], + price_variation_to_liquidation_price=position["priceVariationToLiquidationPrice"], + ) + alert = evaluate_alert( + market=position["market"], + risk_metrics=risk_metrics, + warning_health_factor=args.warning_health_factor, + critical_health_factor=args.critical_health_factor, + warning_liquidation_distance_percent=args.warning_liquidation_distance_percent, + critical_liquidation_distance_percent=args.critical_liquidation_distance_percent, + warning_buffer_to_lltv_percent=args.warning_buffer_to_lltv_percent, + critical_buffer_to_lltv_percent=args.critical_buffer_to_lltv_percent, + ) + if severity_rank(alert["level"]) > severity_rank(highest_level): + highest_level = alert["level"] + alerts.append( + { + "market": position["market"], + "graphState": position["state"], + "riskMetrics": risk_metrics, + "alert": alert, + "monitoringNotes": [ + "Re-run alert-check on a timer for ongoing borrow positions.", + "If the alert level moves from ok to warning, prepare a repay or add-collateral plan.", + "If the alert level moves to critical, treat it as an active risk event rather than a passive reminder.", + ], + } + ) + + summary = { + "positionCount": len(alerts), + "ok": sum(1 for item in alerts if item["alert"]["level"] == "ok"), + "warning": sum(1 for item in alerts if item["alert"]["level"] == "warning"), + "critical": sum(1 for item in alerts if item["alert"]["level"] == "critical"), + "highestLevel": highest_level, + "alertTriggered": highest_level != "ok", + } + + payload = { + "command": "alert-check", + "userAddress": user_address, + "loanToken": loan_token.symbol, + "thresholds": { + "warningHealthFactor": args.warning_health_factor, + "criticalHealthFactor": args.critical_health_factor, + "warningLiquidationDistancePercent": args.warning_liquidation_distance_percent, + "criticalLiquidationDistancePercent": args.critical_liquidation_distance_percent, + "warningBufferToLltvPercent": args.warning_buffer_to_lltv_percent, + "criticalBufferToLltvPercent": args.critical_buffer_to_lltv_percent, + }, + "summary": summary, + "positions": alerts, + } + print_json(payload) + + if args.fail_on and severity_rank(highest_level) >= severity_rank(args.fail_on): + return 2 + return 0 + + +def repay_plan_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + selected_position = resolve_user_position( + positions=positions, + market_key=args.market_key, + collateral_token=args.collateral_token, + ) + + market = selected_position["market"] + graph_state = selected_position["state"] + morpho_address = market["morphoBlueAddress"] + onchain_position = morpho_position(args.rpc_url, morpho_address, market["uniqueKey"], user_address) + onchain_market = morpho_market_state(args.rpc_url, morpho_address, market["uniqueKey"]) + + current_borrow_shares = int(onchain_position["borrowShares"]) + if current_borrow_shares <= 0: + raise ValueError("No borrow shares were found for this wallet and market.") + + estimated_current_borrow_assets = estimate_assets_from_shares( + current_borrow_shares, + int(onchain_market["totalBorrowAssets"]), + int(onchain_market["totalBorrowShares"]), + round_up=True, + ) + if estimated_current_borrow_assets <= 0: + raise ValueError("Estimated current borrow assets were zero. Refresh state and try again.") + + current_balance = erc20_balance_of(args.rpc_url, market["loanAsset"]["address"], user_address) + current_allowance = erc20_allowance(args.rpc_url, market["loanAsset"]["address"], user_address, morpho_address) + + request: dict[str, Any] + repay_transaction: dict[str, Any] + approval_amount: int + estimated_assets_required: int + projected_repay_usd: float + + if args.amount is not None: + amount_raw = int(parse_units(args.amount, int(market["loanAsset"]["decimals"]))) + if amount_raw <= 0: + raise ValueError("Repay amount must be greater than zero.") + if amount_raw > estimated_current_borrow_assets: + raise ValueError("Requested repay amount exceeds current estimated debt. Use a smaller amount or --fraction 1.") + + request = { + "mode": "assets", + "amount": args.amount, + "amountRaw": str(amount_raw), + "loanToken": market["loanAsset"]["symbol"], + } + repay_transaction = build_repay_transaction(market, str(amount_raw), "0", user_address) + approval_amount = amount_raw + estimated_assets_required = amount_raw + projected_repay_usd = float(args.amount) + else: + fraction = parse_fraction(args.fraction) + numerator, denominator = fraction_to_parts(fraction) + computed_shares = (current_borrow_shares * numerator) // denominator + if computed_shares <= 0: + raise ValueError("Computed repay shares were zero. Increase the fraction.") + + estimated_assets_required = estimate_assets_from_shares( + computed_shares, + int(onchain_market["totalBorrowAssets"]), + int(onchain_market["totalBorrowShares"]), + round_up=True, + ) + approval_amount = estimated_assets_required + max(estimated_assets_required // 100, 1) + request = { + "mode": "shares", + "fraction": fraction, + "currentBorrowShares": str(current_borrow_shares), + "repayShares": str(computed_shares), + "estimatedAssetsRequired": str(estimated_assets_required), + "approvalBufferAmount": str(approval_amount), + "instruction": "Share-based repay is safer for full-position debt reduction because it avoids asset/share rounding drift.", + } + repay_transaction = build_repay_transaction(market, "0", str(computed_shares), user_address) + projected_repay_usd = estimated_assets_required / (10 ** int(market["loanAsset"]["decimals"])) + + has_sufficient_balance = current_balance >= estimated_assets_required + needs_approval = current_allowance < approval_amount + + approval: dict[str, str] | None = None + approval_transaction: dict[str, Any] | None = None + if needs_approval: + approval = { + "spender": morpho_address, + "token": market["loanAsset"]["address"], + "amount": str(approval_amount), + } + approval_transaction = build_approve_transaction(market["loanAsset"]["address"], morpho_address, str(approval_amount)) + + post_action_risk_metrics = compute_risk_metrics( + market=market, + graph_state=graph_state, + health_factor=selected_position["healthFactor"], + price_variation_to_liquidation_price=selected_position["priceVariationToLiquidationPrice"], + projected_additional_borrow_usd=-projected_repay_usd, + ) + + bankr_steps: list[tuple[str, dict[str, Any]]] = [] + if approval_transaction is not None: + bankr_steps.append( + ( + f"Approve {market['loanAsset']['symbol']} for Morpho Blue repayment", + approval_transaction, + ) + ) + bankr_steps.append( + ( + f"Repay {market['loanAsset']['symbol']} debt on Morpho {market['title']}", + repay_transaction, + ) + ) + + print_json( + { + "command": "repay-plan", + "userAddress": user_address, + "market": market, + "graphState": graph_state, + "liveState": { + "rpcUrl": args.rpc_url, + "currentTokenBalance": str(current_balance), + "currentAllowance": str(current_allowance), + "currentPosition": onchain_position, + "currentMarketState": onchain_market, + "estimatedCurrentBorrowAssets": str(estimated_current_borrow_assets), + }, + "repayRequest": request, + "executionReadiness": { + "hasBorrowPosition": current_borrow_shares > 0, + "hasSufficientBalance": has_sufficient_balance, + "needsApproval": needs_approval, + "readyToExecute": has_sufficient_balance, + }, + "approval": approval, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps(bankr_steps), + }, + "repayCall": { + "chainId": BASE_CHAIN_ID, + "to": morpho_address, + "function": "repay((address loanToken,address collateralToken,address oracle,address irm,uint256 lltv),uint256 assets,uint256 shares,address onBehalf,bytes data)", + "args": { + "marketParams": { + "loanToken": market["loanAsset"]["address"], + "collateralToken": market["collateralAsset"]["address"], + "oracle": market["oracleAddress"], + "irm": market["irmAddress"], + "lltv": market["lltv"], + }, + "assets": request.get("amountRaw", "0"), + "shares": request.get("repayShares", "0"), + "onBehalf": user_address, + "data": "0x", + }, + }, + "repayTransaction": repay_transaction, + "postActionRiskMetrics": post_action_risk_metrics, + "riskNotes": build_risk_notes(market, "repay") + + [ + "Repay is a safer first response than new borrowing when a position moves into warning or critical territory.", + "After the transaction confirms, re-run alert-check before assuming the risk level has improved enough.", + ], + } + ) + return 0 + + +def add_collateral_plan_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + markets = fetch_markets(loan_token) + user_positions = fetch_user_positions(user_address, loan_token) + + if args.market_key or args.collateral_token: + market = resolve_market(markets=markets, market_key=args.market_key, collateral_token=args.collateral_token) + else: + selected_position = resolve_user_position( + positions=user_positions, + market_key=args.market_key, + collateral_token=args.collateral_token, + ) + market = selected_position["market"] + + amount_raw = int(parse_units(args.amount, int(market["collateralAsset"]["decimals"]))) + + morpho_address = market["morphoBlueAddress"] + current_balance = erc20_balance_of(args.rpc_url, market["collateralAsset"]["address"], user_address) + current_allowance = erc20_allowance(args.rpc_url, market["collateralAsset"]["address"], user_address, morpho_address) + current_position = morpho_position(args.rpc_url, morpho_address, market["uniqueKey"], user_address) + + matching_position = find_position_for_market(user_positions, market["uniqueKey"]) + graph_state = matching_position["state"] if matching_position is not None else {} + health_factor = matching_position["healthFactor"] if matching_position is not None else None + price_variation = matching_position["priceVariationToLiquidationPrice"] if matching_position is not None else None + + has_sufficient_balance = current_balance >= amount_raw + needs_approval = current_allowance < amount_raw + + approval: dict[str, str] | None = None + approval_transaction: dict[str, Any] | None = None + if needs_approval: + approval = { + "spender": morpho_address, + "token": market["collateralAsset"]["address"], + "amount": str(amount_raw), + } + approval_transaction = build_approve_transaction(market["collateralAsset"]["address"], morpho_address, str(amount_raw)) + + add_collateral_transaction = build_supply_collateral_transaction(market, str(amount_raw), user_address) + estimated_added_collateral_usd = estimate_collateral_value_usd( + market=market, + graph_state=graph_state, + collateral_assets_raw=amount_raw, + ) + post_action_risk_metrics = None + if matching_position is not None and estimated_added_collateral_usd is not None: + post_action_risk_metrics = compute_risk_metrics( + market=market, + graph_state=graph_state, + health_factor=health_factor, + price_variation_to_liquidation_price=price_variation, + projected_additional_collateral_usd=estimated_added_collateral_usd, + ) + + bankr_steps: list[tuple[str, dict[str, Any]]] = [] + if approval_transaction is not None: + bankr_steps.append( + ( + f"Approve {market['collateralAsset']['symbol']} for Morpho Blue collateral", + approval_transaction, + ) + ) + bankr_steps.append( + ( + f"Add {args.amount} {market['collateralAsset']['symbol']} collateral to Morpho {market['title']}", + add_collateral_transaction, + ) + ) + + print_json( + { + "command": "add-collateral-plan", + "userAddress": user_address, + "market": market, + "graphState": graph_state, + "liveState": { + "rpcUrl": args.rpc_url, + "currentTokenBalance": str(current_balance), + "currentAllowance": str(current_allowance), + "currentPosition": current_position, + }, + "collateralRequest": { + "amount": args.amount, + "amountRaw": str(amount_raw), + "collateralToken": market["collateralAsset"]["symbol"], + "estimatedAddedCollateralUsd": estimated_added_collateral_usd, + }, + "executionReadiness": { + "hasSufficientBalance": has_sufficient_balance, + "needsApproval": needs_approval, + "readyToExecute": has_sufficient_balance, + }, + "approval": approval, + "bankrReady": { + "endpoint": "POST /agent/submit", + "steps": build_bankr_steps(bankr_steps), + }, + "addCollateralCall": { + "chainId": BASE_CHAIN_ID, + "to": morpho_address, + "function": "supplyCollateral((address loanToken,address collateralToken,address oracle,address irm,uint256 lltv),uint256 assets,address onBehalf,bytes data)", + "args": { + "marketParams": { + "loanToken": market["loanAsset"]["address"], + "collateralToken": market["collateralAsset"]["address"], + "oracle": market["oracleAddress"], + "irm": market["irmAddress"], + "lltv": market["lltv"], + }, + "assets": str(amount_raw), + "onBehalf": user_address, + "data": "0x", + }, + }, + "addCollateralTransaction": add_collateral_transaction, + "postActionRiskMetrics": post_action_risk_metrics, + "riskNotes": build_risk_notes(market, "add-collateral") + + [ + "Adding collateral is usually safer than increasing borrow when the position is already under pressure.", + "After the transaction confirms, re-run alert-check before assuming the liquidation buffer is wide enough.", + ], + } + ) + return 0 + + +def borrow_plan_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + selected_position = resolve_user_position( + positions=positions, + market_key=args.market_key, + collateral_token=args.collateral_token, + ) + + market = selected_position["market"] + graph_state = selected_position["state"] + collateral_usd = float(graph_state.get("collateralUsd") or 0) + if collateral_usd <= 0: + raise ValueError("No posted collateral was found for this wallet and market. Borrow planning requires an active collateral position.") + + amount_raw = parse_units(args.amount, int(market["loanAsset"]["decimals"])) + requested_borrow_usd = float(args.amount) + risk_metrics = compute_risk_metrics( + market=market, + graph_state=graph_state, + health_factor=selected_position["healthFactor"], + price_variation_to_liquidation_price=selected_position["priceVariationToLiquidationPrice"], + projected_additional_borrow_usd=requested_borrow_usd, + ) + + projected_ltv_percent = risk_metrics["projectedLtvPercent"] + max_ltv_percent = risk_metrics["maxLtvPercent"] + recommended_ltv_percent = risk_metrics["recommendedMaxLtvPercent"] + protocol_allows = projected_ltv_percent is not None and projected_ltv_percent < max_ltv_percent + recommended_allows = projected_ltv_percent is not None and projected_ltv_percent <= recommended_ltv_percent + + print_json( + { + "command": "borrow-plan", + "manualDecisionRequired": True, + "executionPolicy": "manual-confirmation-required", + "userAddress": user_address, + "market": market, + "graphState": graph_state, + "healthFactor": selected_position["healthFactor"], + "priceVariationToLiquidationPrice": selected_position["priceVariationToLiquidationPrice"], + "borrowRequest": { + "amount": args.amount, + "amountRaw": amount_raw, + "loanToken": market["loanAsset"]["symbol"], + "assumption": "Borrowed fxUSD is treated as approximately 1 USD per unit for quick LTV planning.", + }, + "riskMetrics": risk_metrics, + "guardrails": { + "protocolWouldAllowByLtv": protocol_allows, + "recommendedByBuffer": recommended_allows, + "recommendedAction": ( + "Proceed only if you explicitly accept borrow risk and the projected LTV stays below the recommended buffer." + if recommended_allows + else "Do not auto-execute. Reduce borrow size, add collateral, or choose a safer market." + ), + }, + "borrowCall": { + "chainId": BASE_CHAIN_ID, + "to": market["morphoBlueAddress"], + "function": "borrow((address loanToken,address collateralToken,address oracle,address irm,uint256 lltv),uint256 assets,uint256 shares,address onBehalf,address receiver)", + "args": { + "marketParams": { + "loanToken": market["loanAsset"]["address"], + "collateralToken": market["collateralAsset"]["address"], + "oracle": market["oracleAddress"], + "irm": market["irmAddress"], + "lltv": market["lltv"], + }, + "assets": amount_raw, + "shares": "0", + "onBehalf": user_address, + "receiver": user_address, + }, + }, + "borrowTransaction": build_borrow_transaction(market, amount_raw, user_address, user_address), + "riskNotes": build_risk_notes(market, "borrow") + + [ + "Borrowing should remain a user decision, not a default automation path.", + "Track currentLtvPercent, recommendedMaxLtvPercent, healthFactor, and priceVariationToLiquidationPrice before every borrow.", + "If projectedLtvPercent approaches the market LLTV, repay or add collateral before conditions worsen.", + ], + } + ) + return 0 + + +def suggest_borrow_size_command(args: argparse.Namespace) -> int: + loan_token = resolve_loan_token(args.loan_token) + user_address = validate_address(args.from_address, "from-address") + positions = fetch_user_positions(user_address, loan_token) + selected_position = resolve_user_position( + positions=positions, + market_key=args.market_key, + collateral_token=args.collateral_token, + ) + + market = selected_position["market"] + graph_state = selected_position["state"] + collateral_usd = float(graph_state.get("collateralUsd") or 0) + if collateral_usd <= 0: + raise ValueError("No posted collateral was found for this wallet and market. Borrow sizing requires an active collateral position.") + + risk_metrics = compute_risk_metrics( + market=market, + graph_state=graph_state, + health_factor=selected_position["healthFactor"], + price_variation_to_liquidation_price=selected_position["priceVariationToLiquidationPrice"], + ) + loan_decimals = int(market["loanAsset"]["decimals"]) + + protocol_max_borrow_usd = risk_metrics["protocolHeadroomUsd"] or 0 + recommended_max_borrow_usd = risk_metrics["recommendedHeadroomUsd"] or 0 + risk_adjusted_max_borrow_usd = risk_metrics["riskAdjustedHeadroomUsd"] or 0 + + risk_class = market["collateralRiskClass"] + if risk_class == "altcoin-collateral": + profile = "conservative-altcoin" + suggested_borrow_usd = risk_adjusted_max_borrow_usd + rationale = "Altcoin collateral can gap down quickly, so use the tighter risk-adjusted ceiling." + elif risk_class == "bluechip-collateral": + profile = "standard-bluechip" + suggested_borrow_usd = recommended_max_borrow_usd + rationale = "Blue-chip collateral still needs buffer, but can use the standard recommended ceiling." + else: + profile = "stable-collateral" + suggested_borrow_usd = min(recommended_max_borrow_usd, risk_adjusted_max_borrow_usd) + rationale = "Stable or correlated collateral can use a slightly higher ceiling, but still should not target protocol max." + + suggested_borrow_amount = format(Decimal(str(suggested_borrow_usd)).normalize(), "f") + suggested_borrow_raw = parse_units(suggested_borrow_amount, loan_decimals) if suggested_borrow_usd > 0 else "0" + + print_json( + { + "command": "suggest-borrow-size", + "userAddress": user_address, + "market": market, + "graphState": graph_state, + "healthFactor": selected_position["healthFactor"], + "priceVariationToLiquidationPrice": selected_position["priceVariationToLiquidationPrice"], + "riskProfile": profile, + "rationale": rationale, + "riskMetrics": risk_metrics, + "borrowSizing": { + "protocolMaxAdditionalBorrowUsd": round(protocol_max_borrow_usd, 6), + "recommendedMaxAdditionalBorrowUsd": round(recommended_max_borrow_usd, 6), + "riskAdjustedMaxAdditionalBorrowUsd": round(risk_adjusted_max_borrow_usd, 6), + "suggestedAdditionalBorrowUsd": round(suggested_borrow_usd, 6), + "suggestedAdditionalBorrowAmount": suggested_borrow_amount, + "suggestedAdditionalBorrowRaw": suggested_borrow_raw, + "loanToken": market["loanAsset"]["symbol"], + }, + "quickGuidance": [ + "Protocol max is not the target; use the suggestedAdditionalBorrowUsd output as the safer ceiling.", + "If currentLtvPercent is already above recommendedMaxLtvPercent, suggestedAdditionalBorrowUsd should be treated as zero.", + "Re-run this check before every borrow because collateral value and market parameters can move.", + ], + } + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Live Morpho market discovery and execution planning for the fxusd skill.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + discover_parser = subparsers.add_parser("discover", help="List live Morpho Blue markets for a loan token") + discover_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + discover_parser.set_defaults(handler=discover_command) + + recommend_parser = subparsers.add_parser("recommend", help="Rank Morpho supply markets for a loan token") + recommend_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + recommend_parser.add_argument("--limit", type=int, default=5, help="Number of results to return") + recommend_parser.set_defaults(handler=recommend_command) + + position_parser = subparsers.add_parser("position-reads", help="Read current Morpho positions for a wallet") + position_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL. Default: %(default)s") + position_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + position_parser.add_argument("--from-address", required=True, help="Wallet address to inspect") + position_parser.set_defaults(handler=position_reads_command) + + risk_parser = subparsers.add_parser("risk-check", help="Quick LTV and liquidation buffer check for Morpho positions") + risk_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + risk_parser.add_argument("--from-address", required=True, help="Wallet address to inspect") + risk_parser.add_argument("--market-key", help="Exact Morpho market unique key") + risk_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example wstETH") + risk_parser.set_defaults(handler=risk_check_command) + + alert_parser = subparsers.add_parser( + "alert-check", + help="Evaluate Morpho positions against warning and critical risk thresholds for monitoring", + ) + alert_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + alert_parser.add_argument("--from-address", required=True, help="Wallet address to inspect") + alert_parser.add_argument("--market-key", help="Exact Morpho market unique key") + alert_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example BNKR") + alert_parser.add_argument("--warning-health-factor", type=float, default=1.5, help="Warn when health factor is at or below this threshold") + alert_parser.add_argument("--critical-health-factor", type=float, default=1.2, help="Critical alert when health factor is at or below this threshold") + alert_parser.add_argument( + "--warning-liquidation-distance-percent", + type=float, + default=30.0, + help="Warn when estimated liquidation distance percent is at or below this threshold", + ) + alert_parser.add_argument( + "--critical-liquidation-distance-percent", + type=float, + default=12.0, + help="Critical alert when estimated liquidation distance percent is at or below this threshold", + ) + alert_parser.add_argument( + "--warning-buffer-to-lltv-percent", + type=float, + default=12.0, + help="Warn when the current buffer to LLTV percent is at or below this threshold", + ) + alert_parser.add_argument( + "--critical-buffer-to-lltv-percent", + type=float, + default=5.0, + help="Critical alert when the current buffer to LLTV percent is at or below this threshold", + ) + alert_parser.add_argument( + "--fail-on", + choices=["warning", "critical"], + help="Return exit code 2 when the highest alert level meets or exceeds this severity", + ) + alert_parser.set_defaults(handler=alert_check_command) + + repay_parser = subparsers.add_parser( + "repay-plan", + help="Build an execution-ready Morpho repay plan to reduce borrow risk", + ) + repay_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL. Default: %(default)s") + repay_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + repay_parser.add_argument("--from-address", required=True, help="Wallet address that owns the debt position") + repay_parser.add_argument("--market-key", help="Exact Morpho market unique key") + repay_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example BNKR") + repay_amount_group = repay_parser.add_mutually_exclusive_group(required=True) + repay_amount_group.add_argument("--amount", help="Human-readable asset amount to repay") + repay_amount_group.add_argument("--fraction", help="Fraction of current borrow shares to repay, for example 0.5 or 1") + repay_parser.set_defaults(handler=repay_plan_command) + + collateral_parser = subparsers.add_parser( + "add-collateral-plan", + help="Build an execution-ready Morpho collateral top-up plan", + ) + collateral_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL. Default: %(default)s") + collateral_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + collateral_parser.add_argument("--from-address", required=True, help="Wallet address that will add collateral") + collateral_parser.add_argument("--market-key", help="Exact Morpho market unique key") + collateral_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example BNKR") + collateral_parser.add_argument("--amount", required=True, help="Human-readable collateral amount to add") + collateral_parser.set_defaults(handler=add_collateral_plan_command) + + suggest_parser = subparsers.add_parser( + "suggest-borrow-size", + help="Suggest a safer maximum additional borrow size using collateral-aware buffers", + ) + suggest_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + suggest_parser.add_argument("--from-address", required=True, help="Wallet address that owns the collateral position") + suggest_parser.add_argument("--market-key", help="Exact Morpho market unique key") + suggest_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example BNKR") + suggest_parser.set_defaults(handler=suggest_borrow_size_command) + + supply_parser = subparsers.add_parser("supply-plan", help="Build an execution-ready Morpho supply plan") + supply_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL. Default: %(default)s") + supply_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + supply_parser.add_argument("--from-address", required=True, help="Wallet address that will supply assets") + supply_parser.add_argument("--amount", required=True, help="Human-readable amount to supply") + supply_parser.add_argument("--market-key", help="Exact Morpho market unique key") + supply_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example wstETH") + supply_parser.set_defaults(handler=supply_plan_command) + + withdraw_parser = subparsers.add_parser("withdraw-plan", help="Build an execution-ready Morpho withdraw plan") + withdraw_parser.add_argument("--rpc-url", default=BASE_RPC_URL, help="Base RPC URL. Default: %(default)s") + withdraw_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + withdraw_parser.add_argument("--from-address", required=True, help="Wallet address that owns the position") + withdraw_parser.add_argument("--market-key", help="Exact Morpho market unique key") + withdraw_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example wstETH") + withdraw_parser.add_argument("--fraction", default="1", help="Fraction of supply shares to withdraw. Default: %(default)s") + withdraw_parser.set_defaults(handler=withdraw_plan_command) + + borrow_parser = subparsers.add_parser("borrow-plan", help="Build a manual-decision Morpho borrow plan with LTV checks") + borrow_parser.add_argument("--loan-token", default="fxUSD", help="Loan token symbol or address. Default: %(default)s") + borrow_parser.add_argument("--from-address", required=True, help="Wallet address that owns the collateral position") + borrow_parser.add_argument("--market-key", help="Exact Morpho market unique key") + borrow_parser.add_argument("--collateral-token", help="Collateral token symbol or address, for example wstETH") + borrow_parser.add_argument("--amount", required=True, help="Human-readable amount to borrow") + borrow_parser.set_defaults(handler=borrow_plan_command) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return args.handler(args) + except ValueError as error: + print(json.dumps({"error": str(error)}, indent=2), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())