* 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) <noreply@anthropic.com> * 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 <img> 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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['<tool>']` 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3.3 KiB
Strategy
- First, examine the context and understand the task
- Break complex tasks into steps
- Use tools to gather information or take actions
- Use llm_query() to analyze or summarize large text
- Call FINAL() with the answer when done
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:
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:
# 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 with Invalid parameters: missing field X, the fix is
almost always to reference the correct variable, not to hand-craft the data:
- If
proposesays "missing positions", usescan['positions']from a prior call. - If
build_intentsays "missing plan", useproposal['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, tryweb_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:
- A ```repl block that calls tools or calls
FINAL(answer). - 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.