Repository navigation
Prime repo, green test suite, and fix live-trading correctness bugs in the Uniswap path - #2
Merged
Merged
Conversation
…line Generate agent configuration from a full repository scan: - AGENTS.md: project overview, navigation, architecture, working setup/validation/run commands, risk model, escalation, env vars, and known issues - manifest.yml: fill in stale adws manifest (entry point, per-path risk, validate/run/setup commands) - .agent/maturity.md: Level 2 classification with path to Level 3 - .agent/baseline.md: pytest 21 failed / 33 passed / 17 skipped, flake8 756, black 25 files, mypy 111 errors, with root causes - .claude/settings.json, requirements/agent.txt: framework scaffolding - .gitignore: agent hook logs and machine-local framework output Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
- pytest.ini: rename `[tool:pytest]` (setup.cfg syntax, ignored by pytest) to `[pytest]` so addopts, markers and asyncio_mode=auto apply. Drop --cov-fail-under=80: coverage is 41%, so the gate would fail every run; coverage is still reported. - requirements.txt: pin setuptools<81 and eth-typing<5 (web3 6.12 imports pkg_resources and eth_typing.ContractName, both removed upstream); add pytest-cov; pin the unpinned trailing entries (PyJWT, flower, psutil) and remove the duplicate pydantic-settings / celery[redis] / httpx lines. - integrations/coingecko.py: bound HTTP 429 retries (max_retries=3) and raise CoinGeckoError afterwards. Previously the retry recursed forever, which hung the suite once the async tests actually ran. - .gitignore: ignore .coverage. - Baseline is now 26 failed / 42 passed / 3 skipped (was 21/33/17); the 17 "skips" were async tests pytest-asyncio strict mode refused to run. No previously-passing test regressed. .agent/baseline.md and AGENTS.md updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
The suite patched `api.deps.get_current_user` / `get_optional_user`, but those functions are captured inside `Depends(...)` at import time, so the patches were never seen and the real NFT gate ran (400 on every authenticated request). Replace them with FastAPI `app.dependency_overrides` fixtures: - `authenticated_user` / `anonymous_user` override the auth dependencies - `trade_deps` stubs `trade_rate_limiter` and `get_redis_client` so trade endpoints don't need a live Redis Also patch `verify_nft_ownership` in `api.routers.auth` (where it is called) and retarget the prompt-parser mock to the local `api.routers.trade.parse_trading_prompt`, which is what the endpoint actually calls and which returns a dict, not a `TradingInstruction`. tests/unit/test_api.py: 28/28 passing. Full suite: 12 failed / 56 passed / 3 skipped (was 26/42/3); run time 68s -> 8s since the API tests no longer time out against a real RPC. Baseline and AGENTS.md updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
`RateLimiter.__call__` declared `request_id: str` with no default, which FastAPI exposed as a required query parameter: every `/trade/prompt` and `/trade/execute` call without `?request_id=` returned 422. Worse, the value was client-controlled, so a caller could reset its own bucket by changing it. The limiter now takes the `Request` plus `get_optional_user`, and keys the bucket on the verified wallet address (`rate_limit:wallet:0x…`), falling back to client IP for unauthenticated routes. Add two tests that run the real `trade_rate_limiter` through the app with only Redis stubbed: no request_id needed, key is the wallet, and 429 at the limit. Suite: 12 failed / 58 passed / 3 skipped. Also correct the AGENTS.md timing note — the intermittent ~60s is `TestCoinGeckoIntegration` sleeping on a live 429, not the API tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…ests - _check_volume_confirmation returned numpy.bool_ (np.mean comparison), so `is True` checks failed; cast to bool. Treat exactly 20% above average as confirming (>=), matching the documented threshold. - _calculate_ma_crossover only returned ±1 on the single bar where the averages cross. _determine_signal_type consumes it as a trend filter (ma_signal >= 0 for BUY), which made it nearly inert. It now reports the alignment state: 1 when short MA is above long MA, -1 below. - _analyze_token_momentum compared the current bar's volume against a history that already included that bar (analyze_market appends before analysing). Compare against the history preceding it instead. tests/unit/test_strategies.py: 19/19. Suite: 9 failed / 61 passed / 3 skipped; every unit test is green. Baseline and AGENTS.md updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
All nine failures were test-side:
- Uniswap: `@patch('integrations.uniswap.Web3')` replaced the class with a
bare MagicMock, so `Web3.is_address("ETH")` was truthy and
`to_checksum_address` returned the same mock for every token — every
swap became "Cannot swap token with itself". Use `wraps=Web3` so the
static helpers stay real while the constructor still returns the fake
connection. Give `test_create_uniswap_adapter` the same patch (it was
dialling a real RPC), and compare the address-passthrough result
case-insensitively since it is returned checksummed.
- Twitter: `integrations/twitter.py` captures `settings = get_settings()`
at import, so patching `config.get_settings` afterwards had no effect.
Patch `integrations.twitter.settings` instead.
- CoinGecko: `response.text()` is awaited; mock it with AsyncMock.
Suite: 70 passed / 3 skipped / 0 failed (exit 0), coverage 43%. Also
record in AGENTS.md that the USDC address in integrations/uniswap.py is
a placeholder, not mainnet USDC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…t contracts integrations/uniswap.py hard-coded WETH 0xC02aaA39b223FE8C0625C6E8C11028C0C5B9B2dB USDC 0xA0b86a33E6441E6C7C7C8C7C8C7C8C7C8C7C8C7C and api/routers/trade.py carried two variants of the bogus USDC. None of these are real contracts — they all fail EIP-55 checksum validation — so with REAL_DATA_MODE every swap (all paths route through WETH) would have targeted a non-existent token. Set them to the canonical Ethereum mainnet addresses, matching core/tasks.py: WETH 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 USDC 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 Add test_token_addresses_are_real_mainnet_contracts, which asserts every entry in UniswapV2Adapter.token_addresses is checksum-valid and pins WETH/USDC; it fails against the old values. Suite: 71 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Token contract addresses were hard-coded in three places that had drifted apart (integrations/uniswap.py, api/routers/trade.py, core/tasks.py) — which is how placeholder WETH/USDC addresses reached production code. Replace them with a single per-network registry: - core/tokens.py: TokenInfo(symbol, address, decimals) keyed by network and symbol; every address is EIP-55-validated at import so a typo or placeholder fails on startup. Helpers: get_token, get_token_address, token_addresses, is_supported_token, to_base_units, from_base_units. Only "ethereum" is registered; skale/beam raise UnknownTokenError instead of silently using mainnet addresses. - integrations/uniswap.py: adapter table comes from the registry. - core/tasks.py: drop TOKEN_ADDRESSES; resolve addresses and convert amounts via the registry. This also fixes USDT being scaled as 18 decimals (it has 6) and uses Decimal so int(1.1 * 10**18) float noise no longer leaks into wei amounts. - api/routers/trade.py: portfolio token list built from the registry. tests/unit/test_tokens.py covers checksums, canonical mainnet contracts, case-insensitive lookup, unknown token/network errors, and exact base-unit conversion. Suite: 79 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…o core/contracts.py
The Uniswap V3 router address was duplicated between
integrations/uniswap.py (class attributes on both adapters) and
core/tasks.py (UNISWAP_V3_ROUTER). Move all router/factory addresses to
a per-network, per-version registry alongside the token registry:
- core/contracts.py: UniswapDeployment(version, router, factory) under
UNISWAP[network][version]; get_uniswap(version, network) raises
UnknownContractError for unregistered combinations. Also hosts the
shared checksummed() validator, which core/tokens.py now reuses.
- integrations/uniswap.py: adapters declare UNISWAP_VERSION ("v2"/"v3")
and resolve router_address / factory_address from the registry in
__init__, so the router is looked up per network like tokens are.
- core/tasks.py: UNISWAP_V3_ROUTER is derived from the registry; the
dead "not configured" check is removed.
tests/unit/test_contracts.py covers checksums, canonical deployments,
unknown version/network errors and placeholder rejection; the adapter
factory test asserts both adapters take their router from the registry.
Suite: 81 passed / 3 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…uter UniswapV3Adapter inherited UniswapV2Adapter.get_quote, which called getAmountsOut with the V2 router ABI on the V3 SwapRouter address — a function that contract does not have — so every V3 quote would have reverted on a real chain. - core/contracts.py: add `quoter` to UniswapDeployment; register the mainnet V3 QuoterV1 (0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6). - UniswapV3Adapter.get_quote: call quoteExactInputSingle for the single-hop path on each standard fee tier (500/3000/10000), skip tiers whose pool reverts, keep the best output, and report that tier's fee. Amounts use core.tokens decimals (1600 USDC is 1600.0, not 1.6e-9). - UniswapV3Adapter.execute_trade: raise UniswapError instead of inheriting the V2 swap calls, which would send a guaranteed-revert transaction to the V3 router and burn gas. Live V3 swaps continue to go through core/tasks.py (exactInputSingle). Tests: V3 quote uses the Quoter and not getAmountsOut, probes all tiers with the right arguments, picks the best-paying tier, raises when no pool exists, and execute_trade refuses without sending. Suite: 85 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
UniswapV2Adapter scaled every amount by 10**18 "for simplicity". For a 6-decimal token that is wrong by 10**12 in both directions: a quote for 1 ETH -> 1600 USDC reported amount_out as 1.6e-9, and execute_trade encoded 1000 USDC as 10**21 base units and a USDC amountOutMin at 10**18 scale — so any swap touching USDC/USDT would have reverted (or, for amount_in, tried to spend a billion times the intended amount). Promote the decimal-aware helpers added for V3 to the base class and use them in V2's get_quote (amount_in, amount_out) and execute_trade (amount_in, amountOutMin). Conversions go through Decimal, so float noise does not leak into wei. Tests: the V2 quote test now asserts 1600.0 out and 10**18 in; a new execute test checks a USDC->DAI swap encodes 1000 * 10**6 in and an exact 18-decimal amountOutMin. Suite: 86 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…rtup TradeExecutionEngine had a register_adapter() hook but nothing ever called it, so the global trade_engine had no adapters and get_best_quote always returned None. - core/execution/adapters.py: register_default_adapters(engine, network) builds an adapter for each exchange in config.SUPPORTED_EXCHANGES that has an implementation (uniswap_v2, uniswap_v3; sushiswap is skipped) and registers it. Adapter construction dials the RPC node, so a failure drops that adapter with a warning instead of failing startup. Lives outside engine.py because integrations.uniswap imports from the engine. - api/main.py: the lifespan calls it for trade_engine, off the event loop via asyncio.to_thread. Tests cover registration of both Uniswap adapters and the SushiSwap skip, an unreachable node dropping only its adapter, a network with no exchanges, an end-to-end get_best_quote through both real adapters on mocked contracts (V3 wins on net output), and that app startup performs the registration. Suite: 91 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Read-only endpoint exposing trade_engine.get_best_quote(). Compares every adapter registered for the network net of fees, or a single one when `exchange` is given. Authenticated, under api_rate_limiter (not the trade limiter, so quotes do not consume the trade budget). Query: token_in, token_out, amount_in (> 0), optional exchange, network. Returns QuoteResponse: exchange, amounts, price, fees, slippage, gas_estimate, route (token addresses), valid_until, exchanges_checked. Errors: 400 for identical tokens or an exchange not registered on the network (the detail lists what is), 422 for a non-positive amount, 503 when no adapters are registered (RPC unreachable) or none could quote. Tests (8) drive the real engine's selection with fake adapters patched onto trade_engine: best net output wins, pinned exchange only queries that adapter, each error path, and auth required. The class stubs the lifespan's adapter registration so it stays hermetic on machines with a live RPC. Documented in docs/API_DOCUMENTATION.md. Suite: 99 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
…stered Startup registration fails silently if the RPC node is unreachable, and until now that left /trade/quote returning 503 until a restart. core/execution/adapters.py gains ensure_adapters(engine, network): it returns the registered exchanges, and when there are none it re-runs register_default_adapters off the event loop. Attempts are serialised under an asyncio.Lock so concurrent 503s do not each dial the node, and rate-limited to one per RETRY_COOLDOWN_SECONDS (30s) per network; the cooldown is stamped before dialling (and by register_default_adapters itself, so the startup attempt counts). /trade/quote uses it in place of reading trade_engine.adapters directly. Tests: no retry when adapters exist, retry when empty, cooldown honoured and expiring, startup attempt starting the cooldown, five concurrent callers sharing one attempt, and the endpoint going 503 -> 200 once the node is back. Suite: 105 passed / 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Delete api/deps.py.backup, api/main.py.backup, core/tasks.py.backup, core/tasks.py.backup6 (stale hand-made copies; git history serves this purpose) and celerybeat-schedule (Celery beat's runtime state database). Nothing references them. Ignore *.backup, *.backup[0-9]* and celerybeat-schedule* so they cannot be re-added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Runs on pushes to main and on pull requests. test job: matrix over Python 3.11 (Dockerfile) and 3.12 (runtime.txt); pip install -r requirements.txt; pytest -m "not external" so live CoinGecko/Twitter tests are excluded and the run is hermetic. The seven env vars config.Settings requires are set to dummies. Coverage XML is uploaded from the 3.12 leg. lint job: flake8 --select=E9,F63,F7,F82 is a hard gate (syntax errors, undefined names). Full flake8, black --check and mypy run with continue-on-error so the existing debt (see .agent/baseline.md) is visible without blocking; flip them to blocking as counts reach zero. Also drop the `external` marker from test_uniswap_v3_quote_difference, which is fully mocked, so it runs in CI. Verified both matrix legs pass locally (104 passed / 6 deselected on 3.11 and 3.12). Badge added to README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Hooks run on staged files only, so the existing style debt in untouched files does not block commits; black and the whitespace/EOF fixers clean up whatever you touch, paying the debt down incrementally. - pre-commit-hooks v5: trailing-whitespace, end-of-file-fixer, check-yaml, check-toml, check-merge-conflict, check-added-large-files (500 KB), detect-private-key (this repo handles trading wallet keys), debug-statements - black 23.11.0 (matches requirements.txt / CI) - flake8 6.1.0 with the same --select=E9,F63,F7,F82 hard gate as CI Verified: hooks pass on clean files, the flake8 gate passes tree-wide, and detect-private-key flags a PEM key. Setup documented in AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Mirrors AGENTS.md's Review Expectations and Escalation Triggers: a summary, risk-area checkboxes for the paths that need maintainer review (auth, on-chain execution, address registries, strategies, migrations, safety-default env vars), a behaviour-changes section, a test plan that names the CI command, and a checklist with trading- and migration-specific items. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
Three GitHub issue forms plus a config: - Bug report — what/repro/area/version/environment/logs, with a reminder never to paste .env contents or keys. - Trading incident — for a live or dry-run trade that did the wrong thing: mode (real funds / reverted / dry run), network, intended vs. actual, tx hashes and wallet, trigger path, trade id, logs, and containment checkboxes (emergency stop, router approvals). Labels bug + trading. - Feature request — problem, proposal, area, and whether it changes how or when real funds move. - config.yml — contact link routing security issues and leaked keys to a private security advisory instead of a public issue. Adds the `trading` label to the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
- pyproject.toml: [tool.pytest.ini_options] (migrated from pytest.ini, which is removed), [tool.coverage], [tool.black] (line-length 120, py311/py312), [tool.mypy] (files, ignore_missing_imports, error codes). No [project] table — the app is not packaged; deps stay in requirements.txt. - .flake8: max-line-length 120, black-compatible ignores (E203, W503), excludes. flake8 cannot read pyproject.toml, hence the sibling file. - CI and pre-commit now invoke the tools without inline options (except the deliberate flake8 --select error gate). Full flake8 also scans tests/ now, which moves its informational count from 756 to 1088. - Reformat the modules created on this branch at line length 120. - AGENTS.md / manifest.yml validation commands updated; maturity raised to Level 3 with a Level 4 path (pay down debt, CODEOWNERS + branch protection, private vulnerability reporting, single trade path). Verified: pytest reports configfile=pyproject.toml, 104 passed with the CI selection; flake8 error gate passes; black/flake8/mypy pick up their settings with no flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Started as a
/primebootstrap (AGENTS.md, manifest.yml, baseline) and turned into a correctness pass on everything the baseline surfaced. The test suite goes from 21 failed / 33 passed / 17 silently skipped to 105 passed / 0 failed (3 skips are live-CoinGecko tests), and several bugs on the real-funds trading path are fixed along the way.Bugs fixed on the trading path — please review these first
REAL_DATA_MODE=trueintegrations/uniswap.pyapi/routers/trade.pyhad two more bogus USDC variants.core/tasks.pyintegrations/uniswap.py(V2)10**18.amountInandamountOutMinencoded wrong by 10¹² → reverts, or wrong spend.integrations/uniswap.py(V3)getAmountsOuton the V3 SwapRouter (function doesn't exist).execute_tradewould send a guaranteed-revert tx.api/deps.pyRateLimiterdeclaredrequest_id: str→ required query param; also client-controlled./trade/prompt&/trade/execute; bucket resettable by the caller.integrations/coingecko.pycore/strategies/momentum.pynumpy.bool_leak; MA "crossover" only fired on the exact bar; current bar's volume averaged against itself.Structural changes
core/tokens.py— single per-network token registry (address + decimals), EIP-55-validated at import. Replaces three divergent hard-coded tables (which is how the placeholders got in).to_base_unitsgoes throughDecimalsoint(1.1 * 10**18)float noise can't reach wei.core/contracts.py— same for Uniswap router/factory/quoter addresses.core/execution/adapters.py— registers Uniswap V2/V3 adapters ontrade_engineat app startup (previously nothing ever calledregister_adapter). Unreachable RPC drops that adapter with a warning, not startup.ensure_adapters()retries lazily (locked, 30s cooldown) so the app recovers when the node comes back.GET /trade/quote— new read-only endpoint overtrade_engine.get_best_quote(); compares exchanges net of fees. Documented indocs/API_DOCUMENTATION.md.quoteExactInputSingleacross the 0.05/0.3/1% fee tiers;execute_tradeon it now refuses explicitly (live V3 swaps continue throughcore/tasks.py).Behaviour changes to be aware of
get_optional_userruns inside the limiter, so on trade endpoints the NFT check runs twice per request (second is a Redis cache hit).skale/beamhave no token/contract registry; resolving there now raisesUnknownTokenErrorinstead of silently using mainnet addresses. Nothing in production creates adapters for those networks today.pytest.iniheader fixed ([tool:pytest]→[pytest]), which activatedasyncio_mode=auto— the 17 "skipped" tests were async tests never actually running.--cov-fail-under=80removed (coverage is ~43%; it would have failed every run).Tooling
requirements.txt: pinssetuptools<81andeth-typing<5(needed byweb3==6.12.0), addspytest-cov, pins the previously unpinned tail, removes duplicates. Fresh install was broken without these.AGENTS.md,manifest.yml,.agent/baseline.md,.agent/maturity.mdgenerated from a real scan; testing conventions (patch<module>.settings,wraps=Web3,AsyncMockfor aiohttp,dependency_overridesforDepends) recorded so the wrong-mock-target class of failure doesn't recur.Test plan
pytest— 105 passed, 3 skipped (live CoinGecko), 0 failedgetAmountsOutnot called from V3, etc.)ETHEREUM_RPC_URL,GET /trade/quote?token_in=ETH&token_out=USDC&amount_in=1returns a V3 quote withamount_outin the right order of magnitudeNot addressed (pre-existing, noted in AGENTS.md): flake8/black/mypy debt across the codebase, tracked
*.backup*files andcelerybeat-schedule, no CI,/trade/promptuses a regex parser rather than the LLM client.🤖 Generated with Claude Code
https://claude.ai/code/session_01MrMkjaJypc9mcgLZp9cfyM