From 77e746f683fd3263facd6c81b78fbe021925b07f Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Mon, 20 Apr 2026 14:47:51 +0900 Subject: [PATCH] feat(portfolio): complete tool, tests, widget, and share-gains flow (#2368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(portfolio): complete tool, tests, widget, and share-gains flow Portfolio WASM tool with full pipeline: - Indexer (fixture, dune, dune-replay backends) - Analyzer (6 protocol classifiers, health extraction, stablecoin detection) - Strategy filter (yield-floor, health-guard, LP impermanent-loss-watch) - Intent builder (fixture + solver backends, bounded checks, leg bundling) - Format (suggestion markdown, progress metric, widget state) 172 unit tests covering all modules including edge cases: - filter.rs: 33 tests (yield floor, health guard, LP watch, helpers) - bounded.rs: 16 tests (slippage, cost, chain allowlist, multi-leg) - parser.rs: 18 tests (delimiters, YAML, kind inference, real strategies) - fixture.rs: 14 tests (slippage calc, ID formats, payload structure) - analyzer: 18 tests (stablecoin detection, health extraction, debt/yield) - format.rs: 16 tests (totals, empty states, progress windowing) - widget.rs: 10 tests (rendering, intents, non-ready filtering) - types: 16 tests (parse_decimal, ChainSelector serde) - 14 YAML replay scenarios + 4 live Dune API tests (ignored by default) Share-gains feature: - Gateway-level IronClaw.api.share() modal with X, LinkedIn, Facebook, copy-to-clipboard, and download buttons - Portfolio widget generates SVG card showing gains (APY, annual savings, moves found) — no addresses or balances exposed - "Share gains" button appears only when portfolio has positive delta E2E Playwright tests (11 scenarios): - Skill discovery via API and settings UI - Chat integration (keyword + wallet address triggering) - Widget rendering with pre-seeded state (positions, totals, suggestions) - Share button visibility (present with gains, absent without) - Share modal lifecycle (opens with card image, social buttons, closes) Supporting changes: - E2E conftest: SKILLS_DIR points to workspace skills/ - Mock LLM: canned responses for portfolio/defi and wallet address patterns - Skill YAML, registry entry, capabilities JSON, 3 strategy docs, 4 scripts Co-Authored-By: Claude Opus 4.6 (1M context) * fix(portfolio): address PR review — XSS, OnceLock, bounded checks, docs Addresses review comments from #2368: - XSS: widget renders all interpolated fields through escapeHtml(); share modal creates via DOM API with data:image/ prefix check - OnceLock: protocol registry parsed once via std::sync::OnceLock - to_ascii_lowercase() for wallet address lookups (fixture + dune_replay) - bounded.rs: reject empty value_usd in single-leg slippage check - fixture.rs: compute min_out amount and value_usd separately - fixture.rs: clarify expires_at=0 comment (fixture = no expiry) - schema.json: add "dune-replay" to source enum - parser.rs: fix doc comment re kind inference (defaults, not inferred) - live_tests.rs: fix log placeholder (raw_count vs classified.len()) - intent.rs: expand kind comment to match SCHEMA.md Co-Authored-By: Claude Opus 4.6 (1M context) * fix(portfolio): escape remaining innerHTML fields, add tests, WASM build - Escape delta_vs_last_run_usd and next_mission_run in widget innerHTML - Add fixture test with amount != value_usd (stETH: 3.5 tokens / $12250) to verify the review fix separating amount from value_usd - Add empty-legs test for bundling.rs order_legs - Add comment explaining multi-leg empty value_usd tolerance in bounded.rs - WASM component builds successfully (754K release binary) via: cargo component build --release --target wasm32-wasip2 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(portfolio): address second-round PR review comments - Tighten share image validation to data:image/png only (was data:image/*) - Add ClipboardItem existence check to prevent runtime errors in some browsers - Fix SCHEMA.md to correctly attribute invariant enforcement (bounded.rs vs bundling.rs) Co-Authored-By: Claude Opus 4.6 (1M context) * feat(portfolio): NEAR support end-to-end with engine v2 quality fixes Add full NEAR Protocol support to the portfolio tool: scan via FastNEAR + Intear, classify positions through new protocols (Linear, Meta Pool, Rhea lending, Rhea LP), match against new NEAR-specific yield strategies, and build intent bundles. Plus assorted infrastructure fixes uncovered while exercising the v2 / CodeAct path. Indexer - New `near` source: FastNEAR `/v1/account/{id}/full` + Intear `/list-token-price` (235 KB, vs `/tokens` at 3.2 MB which exceeded fuel). - New `near-replay` source for offline fixture replay. - `auto` source dispatches per address: `0x...` → Dune, `*.near`/`*.tg` → NEAR backend. Mixed lists are split and merged. - `classify_near_token()` tags known NEAR DeFi contracts (Linear, Meta Pool, Rhea/Burrow, Rhea/Ref) with proper `protocol_id`. Default for unknown FT contracts is `wallet`. - Dust filter raised from \$0.01 → \$1 to keep wallets like `root.near` from passing 100+ micro-cap positions through the analyzer. - Dune `value_usd` now accepts both string and number (Dune started returning floats). Analyzer - New protocols: `wallet`, `near-staking`, `linear`, `meta-pool`, `rhea-lending`, `rhea-lp`. Wallet positions are no longer silently dropped (the prior bug that made root.near show "meteor-private" only). Strategies - New `near-staking-yield`, `near-lending-yield`, `near-lp-yield` — match wallet/staking/LP positions on `chain == "near"`. - `StrategyAppliesTo` gains `chains` and `tokens` filters. Tool API - `propose.strategies` is now optional → falls back to bundled defaults (3 EVM + 3 NEAR strategies). - `propose.config` is now optional → falls back to `ProjectConfig::default()`. - `build_intent.config` optional with default. - `propose` recovers from stringified positions (common LLM mistake of calling `json.dumps()` first) and returns a clearer error message. - Capability `dune_api_key` marked `optional: true` — NEAR-only and fixture flows no longer block on a missing Dune key. - Default source is now `auto`. WASM runtime - Default fuel limit raised 10M → 500M across config, settings, channel runtime, and ResourceLimits. Production was using 10M (config path) while tests used `ResourceLimits::DEFAULT_FUEL_LIMIT` (was 100M) — the divergence masked the real fuel exhaustion. The 235 KB Intear parse uses ~27M fuel, so 500M provides ample headroom. - Wrapper now logs fuel consumption at debug level for diagnostics. Engine v2 / CodeAct UX - Preamble: 3 new rules - Never reconstruct tool results manually — reference variables. - Never paste Python code outside `\`\`\`repl` or `FINAL(answer)`. - Chain tool calls in a single block. - Pass native Python objects to tools, never `json.dumps()` first. - Postamble: explicit good/bad chaining example + `FINAL()` answer quality guidance (no terse counts). - Orchestrator: when an action result exceeds 500 chars, the truncated preview now tells the LLM the full result is in `state['']` to discourage manual reconstruction. Skill (`skills/portfolio/SKILL.md`) - Step 4 (Propose): explicit anti-patterns for fabricated positions, strategy-name-only strings, and `floor_apy` percentage integers. - Step 5 (Rank): allows informational LLM-only suggestions when `propose` returns no `ready` proposals. - Step 6 (Build intents): explicit skip when no `ready` proposals; documents required `plan` shape (`legs`, `expected_out`, `expected_cost_usd`, `proposal_id`). - Step 8 (Summarize): require detailed Markdown output, not counts. Tests - `tests/e2e_wasm_portfolio.rs` (5 tests): scan, propose, full pipeline via `TestRigBuilder` with canned HTTP — exercises real wasmtime sandbox with fuel metering. - `tests/e2e_live_portfolio.rs` (2 tests, live-only via `IRONCLAW_LIVE_TEST=1`): end-to-end via `LiveTestHarness` against real LLM + real FastNEAR/Intear, with `engine_v2(true)`. Requires `--test-threads=1` due to a v2 thread-registry race. - Portfolio unit tests: 183 pass (added NEAR indexer parsers, dispatch auto-detection, new strategy filter cases). - Live portfolio tests: 10 pass against real APIs. - Updated `hostile/fake-token-dust` scenario for the new "wallet" protocol behaviour. Bug fixes uncovered along the way - `intents/bounded.rs`: epsilon raised to 0.005 to tolerate the 2-decimal truncation in `intents/fixture.rs` (intent bundles previously failed the slippage check on synthetic targets). Co-Authored-By: Claude Opus 4.6 (1M context) * fix(portfolio): address review findings from #2368 Correctness: - bounded.rs: multi-leg slippage now checks the terminal leg (matching plan.expected_out.chain), not just single-leg bundles. Regression tests added for the bypass and for a multi-leg bundle with min_out=0 on the terminal leg. - bounded.rs: reject zero/negative/NaN/infinite expected_out (would make min_required = 0 and every leg pass vacuously). - indexer/mod.rs: is_near_address now validates NEAR account rules (2..64 chars, lowercase, separators). Previously any non-0x string (empty, whitespace, emoji, SQL injection) passed. - indexer/mod.rs: scan_auto rejects addresses that are neither valid EVM nor valid NEAR, instead of silently routing them to Dune. Code quality: - bundling.rs: replace .expect("indegree") and .expect("leg by id") with explicit error returns. - fixture.rs: replace .unwrap() on plan.legs.last() with an Err path. - types/mod.rs: pub use → pub(crate) use (crate-internal only). - dune.rs / near.rs: warn (via host::log at Warn level) when a non-zero amount has a missing/zero value_usd, so silent undercounts surface in diagnostics rather than being invisible. Security: - gateway config.js: hoist the data:image/png prefix check to the top of IronClaw.api.share() so both img.src and a.href are gated. - gateway config.js: add noopener,noreferrer to window.open features on share popups to close reverse-tabnabbing surface. - widget/index.js: extend escapeXml to also escape apostrophes. Infrastructure: - limits.rs: TODO comment noting that 500M fuel default is driven by one tool (portfolio/near) and follow-up should add a per-tool override so the global default can stay tighter. - test_portfolio.py: silent-return on missing widget tab converted to pytest.skip via shared _open_portfolio_tab_or_skip helper, so a regression that removes widget registration fails loudly instead of passing silently. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(portfolio): address follow-up review comments - lib.rs: BuildIntent.solver now defaults to "fixture" (a valid value), not "auto" (unrecognized by intents::build — was shipping the default straight into an "Unknown intent solver: 'auto'" error whenever the caller omitted the field). - capabilities.json: update discovery_summary to reflect that strategies/config on propose and config/solver on build_intent are optional. Stale text had propose requiring both positions and strategies. - limits.rs + config/wasm.rs: fix the fuel-limit doc comments. The prior value in limits.rs was 100M (not 10M — that was the config path). Clarify both paths converged at 500M in #2368. - config.js (share modal): add aria-label, aria-modal, role=dialog, aria-labelledby for the modal and explicit aria-label on every icon-only share button. Mark decorative SVGs aria-hidden. Toast becomes role=status with aria-live=polite. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../ironclaw_engine/orchestrator/default.py | 11 +- .../prompts/codeact_postamble.md | 62 +- .../prompts/codeact_preamble.md | 12 + crates/ironclaw_gateway/src/assets.rs | 2 + .../static/js/surfaces/config.js | 127 ++ .../static/styles/components/share-modal.css | 102 ++ .../plans/2026-04-11-defi-portfolio-keeper.md | 1092 +++++++++++++++++ registry/tools/portfolio.json | 36 + skills/portfolio/SKILL.md | 323 +++++ .../scripts/alert_if_health_below.py | 53 + skills/portfolio/scripts/backtest_strategy.py | 79 ++ .../scripts/concentration_warning.py | 51 + skills/portfolio/scripts/weekly_report.py | 76 ++ skills/portfolio/widget/index.js | 267 ++++ skills/portfolio/widget/manifest.json | 7 + skills/portfolio/widget/style.css | 121 ++ src/channels/wasm/runtime.rs | 2 +- src/config/wasm.rs | 5 +- src/settings.rs | 2 +- src/tools/wasm/limits.rs | 24 +- src/tools/wasm/wrapper.rs | 16 + tests/e2e/conftest.py | 1 + tests/e2e/mock_llm.py | 7 + tests/e2e/scenarios/test_portfolio.py | 331 +++++ tests/e2e_live_portfolio.rs | 216 ++++ tests/e2e_wasm_portfolio.rs | 687 +++++++++++ tools-src/portfolio/Cargo.toml | 27 + ...1000000000000000000000000000000000ba1.json | 28 + ...1000000000000000000000000000000000003.json | 73 ++ ...700000000000000000000000000000000dead.json | 31 + ...1000000000000000000000000000000000003.json | 34 + .../fixtures/near/intear_prices.json | 1 + .../portfolio/fixtures/near/root.near.json | 1 + .../bridge-opportunity-ethereum-base.json | 66 + .../solver/hostile-bad-quote-base.json | 21 + .../portfolio-tool.capabilities.json | 104 ++ tools-src/portfolio/protocols/aave-v3.json | 25 + .../portfolio/protocols/compound-v3.json | 24 + tools-src/portfolio/protocols/lido.json | 21 + tools-src/portfolio/protocols/linear.json | 10 + tools-src/portfolio/protocols/meta-pool.json | 10 + .../portfolio/protocols/morpho-blue.json | 23 + .../portfolio/protocols/near-staking.json | 10 + .../portfolio/protocols/rhea-lending.json | 10 + tools-src/portfolio/protocols/rhea-lp.json | 10 + .../portfolio/protocols/test-lending.json | 10 + tools-src/portfolio/protocols/uniswap-v3.json | 22 + tools-src/portfolio/protocols/wallet.json | 10 + .../scenarios/backtest-strategy.yaml | 72 ++ .../scenarios/bridge-opportunity.yaml | 39 + .../portfolio/scenarios/bull-2024-03.yaml | 85 ++ .../scenarios/hostile/fake-token-dust.yaml | 49 + .../scenarios/hostile/malicious-protocol.yaml | 47 + .../scenarios/hostile/solver-bad-quote.yaml | 35 + .../portfolio/scenarios/idempotent-rerun.yaml | 23 + .../scenarios/lending-health-warning.yaml | 45 + .../scenarios/smoke-empty-wallet.yaml | 46 + .../scenarios/smoke-single-usdc.yaml | 75 ++ .../scenarios/stale-aave-rebalance.yaml | 47 + .../portfolio/scenarios/widget-shape.yaml | 64 + tools-src/portfolio/src/analyzer/mod.rs | 478 ++++++++ tools-src/portfolio/src/analyzer/registry.rs | 87 ++ tools-src/portfolio/src/format.rs | 536 ++++++++ tools-src/portfolio/src/indexer/dune.rs | 590 +++++++++ .../portfolio/src/indexer/dune_replay.rs | 96 ++ tools-src/portfolio/src/indexer/fixture.rs | 62 + tools-src/portfolio/src/indexer/fixtures.json | 64 + tools-src/portfolio/src/indexer/mod.rs | 268 ++++ tools-src/portfolio/src/indexer/near.rs | 623 ++++++++++ .../portfolio/src/indexer/near_replay.rs | 93 ++ tools-src/portfolio/src/intents/SCHEMA.md | 116 ++ tools-src/portfolio/src/intents/bounded.rs | 375 ++++++ tools-src/portfolio/src/intents/bundling.rs | 185 +++ tools-src/portfolio/src/intents/fixture.rs | 270 ++++ tools-src/portfolio/src/intents/mod.rs | 72 ++ tools-src/portfolio/src/intents/solver.rs | 367 ++++++ tools-src/portfolio/src/lib.rs | 283 +++++ tools-src/portfolio/src/live_tests.rs | 430 +++++++ tools-src/portfolio/src/replay_tests.rs | 554 +++++++++ tools-src/portfolio/src/schema.json | 157 +++ tools-src/portfolio/src/strategy/filter.rs | 959 +++++++++++++++ tools-src/portfolio/src/strategy/mod.rs | 31 + tools-src/portfolio/src/strategy/parser.rs | 292 +++++ tools-src/portfolio/src/types/intent.rs | 34 + tools-src/portfolio/src/types/mod.rs | 123 ++ tools-src/portfolio/src/types/position.rs | 130 ++ tools-src/portfolio/src/types/proposal.rs | 114 ++ tools-src/portfolio/src/widget.rs | 569 +++++++++ .../strategies/lending-health-guard.md | 31 + .../strategies/lp-impermanent-loss-watch.md | 26 + .../strategies/near-lending-yield.md | 35 + .../portfolio/strategies/near-lp-yield.md | 37 + .../strategies/near-staking-yield.md | 32 + .../strategies/stablecoin-yield-floor.md | 34 + 94 files changed, 13051 insertions(+), 12 deletions(-) create mode 100644 crates/ironclaw_gateway/static/styles/components/share-modal.css create mode 100644 docs/plans/2026-04-11-defi-portfolio-keeper.md create mode 100644 registry/tools/portfolio.json create mode 100644 skills/portfolio/SKILL.md create mode 100644 skills/portfolio/scripts/alert_if_health_below.py create mode 100644 skills/portfolio/scripts/backtest_strategy.py create mode 100644 skills/portfolio/scripts/concentration_warning.py create mode 100644 skills/portfolio/scripts/weekly_report.py create mode 100644 skills/portfolio/widget/index.js create mode 100644 skills/portfolio/widget/manifest.json create mode 100644 skills/portfolio/widget/style.css create mode 100644 tests/e2e/scenarios/test_portfolio.py create mode 100644 tests/e2e_live_portfolio.rs create mode 100644 tests/e2e_wasm_portfolio.rs create mode 100644 tools-src/portfolio/Cargo.toml create mode 100644 tools-src/portfolio/fixtures/dune/balances/0xba1000000000000000000000000000000000ba1.json create mode 100644 tools-src/portfolio/fixtures/dune/balances/0xbu11000000000000000000000000000000000003.json create mode 100644 tools-src/portfolio/fixtures/dune/balances/0xdu5700000000000000000000000000000000dead.json create mode 100644 tools-src/portfolio/fixtures/dune/positions/0xbu11000000000000000000000000000000000003.json create mode 100644 tools-src/portfolio/fixtures/near/intear_prices.json create mode 100644 tools-src/portfolio/fixtures/near/root.near.json create mode 100644 tools-src/portfolio/fixtures/solver/bridge-opportunity-ethereum-base.json create mode 100644 tools-src/portfolio/fixtures/solver/hostile-bad-quote-base.json create mode 100644 tools-src/portfolio/portfolio-tool.capabilities.json create mode 100644 tools-src/portfolio/protocols/aave-v3.json create mode 100644 tools-src/portfolio/protocols/compound-v3.json create mode 100644 tools-src/portfolio/protocols/lido.json create mode 100644 tools-src/portfolio/protocols/linear.json create mode 100644 tools-src/portfolio/protocols/meta-pool.json create mode 100644 tools-src/portfolio/protocols/morpho-blue.json create mode 100644 tools-src/portfolio/protocols/near-staking.json create mode 100644 tools-src/portfolio/protocols/rhea-lending.json create mode 100644 tools-src/portfolio/protocols/rhea-lp.json create mode 100644 tools-src/portfolio/protocols/test-lending.json create mode 100644 tools-src/portfolio/protocols/uniswap-v3.json create mode 100644 tools-src/portfolio/protocols/wallet.json create mode 100644 tools-src/portfolio/scenarios/backtest-strategy.yaml create mode 100644 tools-src/portfolio/scenarios/bridge-opportunity.yaml create mode 100644 tools-src/portfolio/scenarios/bull-2024-03.yaml create mode 100644 tools-src/portfolio/scenarios/hostile/fake-token-dust.yaml create mode 100644 tools-src/portfolio/scenarios/hostile/malicious-protocol.yaml create mode 100644 tools-src/portfolio/scenarios/hostile/solver-bad-quote.yaml create mode 100644 tools-src/portfolio/scenarios/idempotent-rerun.yaml create mode 100644 tools-src/portfolio/scenarios/lending-health-warning.yaml create mode 100644 tools-src/portfolio/scenarios/smoke-empty-wallet.yaml create mode 100644 tools-src/portfolio/scenarios/smoke-single-usdc.yaml create mode 100644 tools-src/portfolio/scenarios/stale-aave-rebalance.yaml create mode 100644 tools-src/portfolio/scenarios/widget-shape.yaml create mode 100644 tools-src/portfolio/src/analyzer/mod.rs create mode 100644 tools-src/portfolio/src/analyzer/registry.rs create mode 100644 tools-src/portfolio/src/format.rs create mode 100644 tools-src/portfolio/src/indexer/dune.rs create mode 100644 tools-src/portfolio/src/indexer/dune_replay.rs create mode 100644 tools-src/portfolio/src/indexer/fixture.rs create mode 100644 tools-src/portfolio/src/indexer/fixtures.json create mode 100644 tools-src/portfolio/src/indexer/mod.rs create mode 100644 tools-src/portfolio/src/indexer/near.rs create mode 100644 tools-src/portfolio/src/indexer/near_replay.rs create mode 100644 tools-src/portfolio/src/intents/SCHEMA.md create mode 100644 tools-src/portfolio/src/intents/bounded.rs create mode 100644 tools-src/portfolio/src/intents/bundling.rs create mode 100644 tools-src/portfolio/src/intents/fixture.rs create mode 100644 tools-src/portfolio/src/intents/mod.rs create mode 100644 tools-src/portfolio/src/intents/solver.rs create mode 100644 tools-src/portfolio/src/lib.rs create mode 100644 tools-src/portfolio/src/live_tests.rs create mode 100644 tools-src/portfolio/src/replay_tests.rs create mode 100644 tools-src/portfolio/src/schema.json create mode 100644 tools-src/portfolio/src/strategy/filter.rs create mode 100644 tools-src/portfolio/src/strategy/mod.rs create mode 100644 tools-src/portfolio/src/strategy/parser.rs create mode 100644 tools-src/portfolio/src/types/intent.rs create mode 100644 tools-src/portfolio/src/types/mod.rs create mode 100644 tools-src/portfolio/src/types/position.rs create mode 100644 tools-src/portfolio/src/types/proposal.rs create mode 100644 tools-src/portfolio/src/widget.rs create mode 100644 tools-src/portfolio/strategies/lending-health-guard.md create mode 100644 tools-src/portfolio/strategies/lp-impermanent-loss-watch.md create mode 100644 tools-src/portfolio/strategies/near-lending-yield.md create mode 100644 tools-src/portfolio/strategies/near-lp-yield.md create mode 100644 tools-src/portfolio/strategies/near-staking-yield.md create mode 100644 tools-src/portfolio/strategies/stablecoin-yield-floor.md diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py index ab7229e021..51be894d9e 100644 --- a/crates/ironclaw_engine/orchestrator/default.py +++ b/crates/ironclaw_engine/orchestrator/default.py @@ -181,8 +181,15 @@ def format_output(result, max_chars=8000): if r.get("is_error"): parts.append("[" + name + " ERROR] " + output) else: - preview = output[:500] + "..." if len(output) > 500 else output - parts.append("[" + name + "] " + preview) + if len(output) > 500: + preview = output[:500] + "..." + parts.append( + "[" + name + "] " + preview + + "\n(full result stored in state['" + name + "']; " + "do NOT retype the data — reference the variable in your next call.)" + ) + else: + parts.append("[" + name + "] " + output) ret = result.get("return_value") if ret is not None: diff --git a/crates/ironclaw_engine/prompts/codeact_postamble.md b/crates/ironclaw_engine/prompts/codeact_postamble.md index ea969e993a..5cdca91b3c 100644 --- a/crates/ironclaw_engine/prompts/codeact_postamble.md +++ b/crates/ironclaw_engine/prompts/codeact_postamble.md @@ -9,10 +9,70 @@ Think step by step. Execute code immediately — don't just describe what you would do. +## Chaining tool calls — pass results forward by variable + +When multiple tools need to run in sequence, chain them in one block and pass +previous results by **variable reference**, not by re-typing the data: + +```repl +scan = await portfolio(action="scan", addresses=["root.near"], source="auto") +proposals = await portfolio(action="propose", positions=scan["positions"]) +ready = [p for p in proposals["proposals"] if p["status"] == "ready"] +FINAL(f"Scanned {len(scan['positions'])} positions, {len(ready)} ready proposals") +``` + +**DO NOT** write this anti-pattern: + +```repl +# WRONG: hand-typing positions from a previous tool call +positions = [ + {"address": "root.near", "category": "wallet", "principal_usd": "5526.36", ...}, + {"address": "root.near", "category": "liquid-staking", ...}, +] +proposals = await portfolio(action="propose", positions=positions) +``` + +The scan already stored the positions in a variable. Just reference it. + ## Error recovery -When a tool call fails, do NOT give up immediately. Try alternative approaches before calling FINAL(): +When a tool call fails with `Invalid parameters: missing field X`, the fix is +almost always to reference the correct variable, not to hand-craft the data: + +- If `propose` says "missing positions", use `scan['positions']` from a prior call. +- If `build_intent` says "missing plan", use `proposal['movement_plan']` from a prior propose. +- Do not "reconstruct" tool arguments from your understanding of the data — + the previous call already produced them as a Python object. + +When a network tool fails with a real error (auth, 5xx, no results), try alternatives +before calling FINAL(): - If `http()` fails with an auth error, try `web_search()` or a different public endpoint - If one API endpoint fails, try a different one that provides similar data - If a search returns no results, try different keywords or broader queries - Only call FINAL() to report failure after exhausting at least 2-3 alternative approaches + +## Output discipline + +Your response has exactly two useful forms: + +1. A ```repl block that calls tools or calls `FINAL(answer)`. +2. Nothing else reaches the user except what you pass to `FINAL()`. + +Do NOT write prose *about* the code ("Let me try a different approach", "I need +to pass the positions as a Python list") — prose outside a `FINAL()` answer is +noise that confuses the user. If you need to reason about what to do next, do +it silently and write code. + +## FINAL() answer quality + +The string you pass to `FINAL(answer)` is what the user sees. It must contain +the actual content they asked for — not a summary about it. + +- BAD: `FINAL("Scan complete. 50 positions, 10 ready proposals.")` +- GOOD: `FINAL(f"## Portfolio\\n\\n{positions_table}\\n\\n## Top 3 Proposals\\n\\n{proposal_details}")` + +If the user asked for yield opportunities, the answer must name specific +proposals with their APY, gain, and cost — not a count. Build up the answer +string with real data from tool results (`proposal["rationale"]`, +`proposal["projected_annual_gain_usd"]`, etc.), then call `FINAL()` once +with the complete Markdown. diff --git a/crates/ironclaw_engine/prompts/codeact_preamble.md b/crates/ironclaw_engine/prompts/codeact_preamble.md index 04842bc923..b1510440b7 100644 --- a/crates/ironclaw_engine/prompts/codeact_preamble.md +++ b/crates/ironclaw_engine/prompts/codeact_preamble.md @@ -60,6 +60,18 @@ This is much faster than calling tools sequentially. Use `asyncio.gather()` when 7. For large data, process it in chunks using llm_query() on subsets rather than loading everything into context. 8. Outputs are truncated to 8000 chars — use variables to store large intermediate results. 9. Include the actual content in your FINAL() answer, not just a count or summary. Users want to see the details. +10. **Never reconstruct tool results manually.** Prior tool outputs are already Python objects — reference them via `state['']` or `state['last_return']` or by the variable name you stored them in. Writing `positions = [{"address": "...", ...}, ...]` with hardcoded data from a previous step is wrong — use the variable. +11. **Do not paste Python code into prose.** When you need to run code, put it in a ```repl block. When you need to explain something to the user, that explanation goes inside `FINAL(answer)` — NOT as free-form text followed by code. Mixing prose and code without a fence is the #1 source of bad responses. +12. **Chain tool calls in a single block.** If the task is scan → propose → build_intent, write one `repl` block that awaits all three in sequence, using the result of each as input to the next. Don't split across turns. +13. **Pass Python objects, NOT JSON strings.** Tool parameters accept native Python lists and dicts. NEVER call `json.dumps()` before passing a value. The tool harness serializes for you. + + ```python + # CORRECT — pass the list directly + await portfolio(action="propose", positions=scan["positions"]) + + # WRONG — passes a string literal; tool rejects with "expected a sequence" + await portfolio(action="propose", positions=json.dumps(scan["positions"])) + ``` ## Runtime environment diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs index 16d8509a98..5850e5d923 100644 --- a/crates/ironclaw_gateway/src/assets.rs +++ b/crates/ironclaw_gateway/src/assets.rs @@ -88,6 +88,8 @@ pub const STYLE_CSS: &str = concat!( "\n", include_str!("../static/styles/components/markdown.css"), "\n", + include_str!("../static/styles/components/share-modal.css"), + "\n", include_str!("../static/styles/primitives/toast.css"), "\n", include_str!("../static/styles/surfaces/auth.css"), diff --git a/crates/ironclaw_gateway/static/js/surfaces/config.js b/crates/ironclaw_gateway/static/js/surfaces/config.js index 881e867772..4b20b47d0e 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/config.js +++ b/crates/ironclaw_gateway/static/js/surfaces/config.js @@ -732,6 +732,133 @@ IronClaw.api = { /** Navigate to a tab by ID. */ navigate: function(tabId) { if (typeof switchTab === 'function') switchTab(tabId); + }, + + /** + * Open a share modal with social buttons. + * + * @param {Object} opts + * @param {string} opts.imageDataUrl - PNG data URL of the card image + * @param {string} opts.text - Pre-filled share text + * @param {string} [opts.hashtags] - Comma-separated hashtags (no #) + */ + share: function(opts) { + if (!opts || typeof opts.imageDataUrl !== 'string' || + !opts.imageDataUrl.startsWith('data:image/png')) { + return; + } + var overlay = document.getElementById('share-modal-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'share-modal-overlay'; + overlay.className = 'share-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + overlay.querySelector('.share-close').addEventListener('click', function() { + overlay.style.display = 'none'; + }); + overlay.addEventListener('click', function(e) { + if (e.target === overlay) overlay.style.display = 'none'; + }); + } + + var text = opts.text || ''; + var hashtags = opts.hashtags || 'DeFi,IronClaw'; + var encodedText = encodeURIComponent(text); + var popupFeatures = 'noopener,noreferrer,width=550,height='; + + var preview = overlay.querySelector('.share-preview'); + preview.innerHTML = ''; + var cardImg = document.createElement('img'); + cardImg.className = 'share-card-img'; + cardImg.alt = 'Share card'; + cardImg.src = opts.imageDataUrl; + preview.appendChild(cardImg); + + var toast = overlay.querySelector('.share-toast'); + function showToast(msg) { + toast.textContent = msg; + toast.classList.add('visible'); + setTimeout(function() { toast.classList.remove('visible'); }, 2000); + } + + var xBtn = overlay.querySelector('.share-x'); + xBtn.onclick = function() { + window.open( + 'https://twitter.com/intent/tweet?text=' + encodedText + + '&hashtags=' + encodeURIComponent(hashtags), + '_blank', popupFeatures + '420' + ); + }; + var liBtn = overlay.querySelector('.share-linkedin'); + liBtn.onclick = function() { + window.open( + 'https://www.linkedin.com/sharing/share-offsite/?mini=true&title=' + encodedText, + '_blank', popupFeatures + '520' + ); + }; + var fbBtn = overlay.querySelector('.share-facebook'); + fbBtn.onclick = function() { + window.open( + 'https://www.facebook.com/sharer/sharer.php?quote=' + encodedText, + '_blank', popupFeatures + '420' + ); + }; + var copyBtn = overlay.querySelector('.share-copy'); + copyBtn.onclick = function() { + var img = preview.querySelector('img'); + if (!img) return; + var canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + canvas.toBlob(function(blob) { + if (navigator.clipboard && navigator.clipboard.write && typeof ClipboardItem !== 'undefined') { + try { + navigator.clipboard.write([new ClipboardItem({'image/png': blob})]).then(function() { + showToast('Image copied!'); + }).catch(function() { showToast('Copy failed'); }); + } catch (_) { showToast('Clipboard not supported'); } + } else { + showToast('Clipboard not supported'); + } + }, 'image/png'); + }; + var dlBtn = overlay.querySelector('.share-download'); + dlBtn.onclick = function() { + var a = document.createElement('a'); + a.href = opts.imageDataUrl; + a.download = 'ironclaw-portfolio-gains.png'; + a.click(); + showToast('Downloaded!'); + }; + + overlay.style.display = 'flex'; } }; diff --git a/crates/ironclaw_gateway/static/styles/components/share-modal.css b/crates/ironclaw_gateway/static/styles/components/share-modal.css new file mode 100644 index 0000000000..f07be6a7d3 --- /dev/null +++ b/crates/ironclaw_gateway/static/styles/components/share-modal.css @@ -0,0 +1,102 @@ +/* Share modal */ +.share-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} +.share-modal { + background: var(--bg-primary, #1a1a2e); + border: 1px solid var(--border, #333); + border-radius: 12px; + padding: 24px; + width: 420px; + max-width: 92vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +.share-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.share-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #eee); +} +.share-close { + background: none; + border: none; + color: var(--text-muted, #888); + font-size: 22px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} +.share-close:hover { + color: var(--text-primary, #eee); +} +.share-preview { + margin-bottom: 20px; + border-radius: 8px; + overflow: hidden; + background: #111; +} +.share-card-img { + display: block; + width: 100%; + height: auto; +} +.share-actions { + display: flex; + gap: 10px; + justify-content: center; +} +.share-btn { + width: 44px; + height: 44px; + border-radius: 10px; + border: 1px solid var(--border, #333); + background: var(--bg-secondary, #222); + color: var(--text-primary, #ddd); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +.share-btn:hover { + background: var(--bg-tertiary, #333); + transform: translateY(-1px); +} +.share-btn:active { + transform: translateY(0); +} +.share-x:hover { color: #1da1f2; } +.share-linkedin:hover { color: #0077b5; } +.share-facebook:hover { color: #1877f2; } +.share-toast { + text-align: center; + font-size: 13px; + color: var(--text-muted, #888); + margin-top: 12px; + height: 20px; + opacity: 0; + transition: opacity 0.2s; +} +.share-toast.visible { + opacity: 1; +} +@media (max-width: 768px) { + .share-overlay { align-items: flex-end; } + .share-modal { + width: 100%; + max-width: 100%; + border-radius: 12px 12px 0 0; + } +} diff --git a/docs/plans/2026-04-11-defi-portfolio-keeper.md b/docs/plans/2026-04-11-defi-portfolio-keeper.md new file mode 100644 index 0000000000..0249c6920b --- /dev/null +++ b/docs/plans/2026-04-11-defi-portfolio-keeper.md @@ -0,0 +1,1092 @@ +# Portfolio — Full Plan + +**Status**: draft +**Owner**: tbd +**Date**: 2026-04-11 +**Target**: user pastes a wallet address into chat → IronClaw discovers cross-chain DeFi positions, scores them, suggests improvements with projected Δ earnings, constructs unsigned NEAR Intents to execute, runs as a recurring mission, and projects a live widget. Extensible by user via custom scripts. Viral-ready. + +--- + +## 0. Guiding principles + +1. **Nothing DeFi-specific is hardcoded in the IronClaw core.** All protocol and strategy knowledge lives as data (JSON/Markdown) embedded in the `portfolio` WASM tool or authored in the project workspace. Adding a new protocol or strategy is a PR to a data file, not to Rust. +2. **Agent never holds private keys.** The only execution path is the construction of *unsigned NEAR Intents*. Signing happens in the user's wallet (Phase 6). +3. **NEAR Intents is the only movement primitive.** No raw EVM tx building. If a route isn't reachable via a solver, the suggestion is surfaced as an `unmet-route`, not built from scratch. +4. **Project-scoped.** The whole capability lives under a v2-engine `Project`. Files, mission, memory docs, scripts, and widget state are all scoped to that project. A user can have many projects (`portfolio`, `portfolio-treasury`, `portfolio-dao`) that share the same skill/tool/registries. +5. **Deterministic and replayable.** The tool's scan operation accepts `at_block` / `at_timestamp`. Every mission run is reproducible against a pinned historical state. Test fixtures are recorded, not synthesized. +6. **LLM data is never deleted.** State snapshots, suggestions, and intents are retained forever in the project workspace. They are the backtest corpus. +7. **Everything through dispatch.** All mutations go through `ToolDispatcher::dispatch()` for audit, redaction, and safety. +8. **One tool, one trust boundary.** The entire capability is a single `portfolio` WASM tool exposing multiple operations. Simpler install, versioning, capabilities, and auditing than a fleet of coupled micro-tools. +9. **YAGNI for pluggability.** Dune REST is hardcoded as the v1 data source. No sources registry, no transport abstraction, no escape hatches until a second source actually shows up. + +## 1. Architecture overview + +``` + ┌────────────────────────────────────────────────────────────────┐ + │ skills/portfolio/ │ + │ SKILL.md — activation on address/keywords, requires the │ + │ `portfolio` WASM tool, instructs LLM on project bootstrap, │ + │ mission creation, script authoring, widget writing, and │ + │ suggestion format. │ + └────────────────────────────┬───────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────┐ + │ WASM tool: portfolio │ + │ │ + │ operations: │ + │ scan(addr, at?) │ + │ propose(positions, cfg) │ + │ build_intent(plan, cfg) │ + │ │ + │ internal: │ + │ ├── indexer (Dune REST) │ + │ ├── analyzer (protocols/*) │ + │ ├── strategy (strategies/*) │ + │ └── intents (solver client) │ + └──────┬────────────┬───────────────┘ + │ │ + Dune REST API NEAR Intents solver + (HTTP allowlisted; key via CredentialInjector) + + ▼ + v2 engine Project: "portfolio" + └── Mission: portfolio-keeper (cron 0 */6 * * *) + └── workspace://projects//... + ├── addresses.md + ├── strategies/*.md + ├── config.json + ├── state/{latest.json, history/*.json} + ├── suggestions/*.md + ├── intents/*.json + ├── scripts/*.py (user custom flows) + └── widgets/state.json (web widget data) +``` + +**Count**: one WASM tool. Two embedded registries (`protocols/`, `strategies/`). One skill. One mission. One widget. One Python-scripting extensibility seam. One final wallet UI phase. + +## 2. Components + +### 2.1 Skill: `skills/portfolio/SKILL.md` + +**Frontmatter:** +```yaml +name: portfolio +version: 0.1.0 +description: Cross-chain DeFi portfolio discovery, rebalancing suggestions, and + NEAR Intent execution. Runs as a recurring mission per project. +activation: + keywords: [portfolio, defi, yield, apy, rebalance, positions, wallet, farming, + stake, lending, liquidity] + patterns: + - '0x[a-fA-F0-9]{40}' # EVM address + - '[a-zA-Z0-9_-]+\.near' # NEAR account + - '[a-zA-Z0-9-]+\.eth' # ENS + exclude_keywords: [nft, mint] + tags: [crypto, defi, finance] + max_context_tokens: 4000 +requires: + tools: + - portfolio + env: + - DUNE_API_KEY # optional; fixture backend works without + config: + project_bootstrap: true # skill is allowed to create a project +``` + +**Body (markdown instructions to the LLM):** + +A short playbook: + +1. **Project bootstrap.** If no `portfolio` project exists for this user, create one via `project_create(name="portfolio", description=...)`. **Only create additional portfolio projects when the user explicitly asks** ("create a separate treasury portfolio", "track this one under a different project"). Otherwise append to the existing default `portfolio` project — multiple wallets live inside one project by default. +2. **Address capture.** Append to `projects//addresses.md` using `memory_write`. Multiple addresses are the norm, not the exception. Never store addresses outside the project. +3. **Config defaults.** On first run, write `config.json` with sensible defaults: `{floor_apy: 0.04, max_risk_score: 3, notify_threshold_usd: 100, auto_intent_ceiling_usd: 1000, max_slippage_bps: 50}`. +4. **Scan.** Call `portfolio.scan(addresses, at: null)`. Returns `ClassifiedPosition[]`. +5. **Propose.** Call `portfolio.propose(positions, strategies, config)`. Returns deterministic candidate `Proposal[]` filtered by strategy constraints. +6. **Rank.** LLM ranks/prunes the candidate set using each proposal's `rationale` and the strategy doc prose. +7. **Build.** For each top-ranked proposal, call `portfolio.build_intent(plan, config)` to produce an unsigned NEAR Intent bundle. Bounded checks must pass before the intent is written. +8. **Persist.** Write `state/latest.json`, `state/history/.json`, `suggestions/.md`, `intents/-