Files
ironclaw/tests/e2e_wasm_portfolio.rs
Illia Polosukhin 77e746f683 feat(portfolio): complete tool, tests, widget, and share-gains flow (#2368)
* 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>
2026-04-20 14:47:51 +09:00

688 lines
28 KiB
Rust

//! E2E test: portfolio WASM tool with real wasmtime sandbox + fuel metering.
//!
//! Loads the compiled portfolio WASM binary into the test rig, replays an LLM
//! trace that calls portfolio.scan for a NEAR address, and verifies the WASM
//! tool executes successfully within fuel limits using canned HTTP responses.
//!
//! These tests are `#[ignore]` by default because they require a pre-compiled
//! WASM binary. Build it with:
//! cargo component build -p portfolio-tool --target wasm32-wasip2 --release
//! Then run with:
//! cargo test --features libsql --test e2e_wasm_portfolio -- --ignored
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::time::Duration;
use serde_json::json;
use ironclaw::llm::recording::{HttpExchange, HttpExchangeRequest, HttpExchangeResponse};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::{
LlmTrace, TraceExpects, TraceResponse, TraceStep, TraceToolCall,
};
const PORTFOLIO_WASM: &str =
"tools-src/portfolio/target/wasm32-wasip2/release/portfolio_tool.wasm";
const PORTFOLIO_CAPS: &str = "tools-src/portfolio/portfolio-tool.capabilities.json";
/// Load a fixture file from the portfolio fixtures directory.
fn load_fixture(name: &str) -> String {
let path = format!("tools-src/portfolio/fixtures/near/{name}");
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Missing fixture {path}: {e}. Run: cargo test -p portfolio-tool live_near_replay_fixture_record -- --ignored"))
}
fn json_ok(body: &str) -> HttpExchangeResponse {
HttpExchangeResponse {
status: 200,
headers: vec![("content-type".to_string(), "application/json".to_string())],
body: body.to_string(),
}
}
/// Portfolio scan of a NEAR address via the `fixture` source.
///
/// Uses the embedded fixture data (no HTTP calls), so this test
/// validates that the WASM binary loads, executes within fuel
/// limits, and returns valid output — without needing network
/// access or canned HTTP responses.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary
async fn wasm_portfolio_scan_fixture_source() {
let trace = LlmTrace {
model_name: "test-portfolio-scan-fixture".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Scan wallet 0x1111111111111111111111111111111111111111".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_portfolio_1".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "scan",
"addresses": ["0x1111111111111111111111111111111111111111"],
"source": "fixture"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found positions in the fixture wallet.".to_string(),
input_tokens: 500,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: Vec::new(),
expects: TraceExpects {
tools_used: vec!["portfolio".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("portfolio", PORTFOLIO_WASM, Some(PORTFOLIO_CAPS.into()))
.build()
.await;
rig.send_message("Scan wallet 0x1111111111111111111111111111111111111111")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(30)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify tool succeeded (not fuel-exhausted or errored)
let completed = rig.tool_calls_completed();
assert!(
completed
.iter()
.any(|(name, success)| name == "portfolio" && *success),
"portfolio tool should succeed with fixture source, got: {completed:?}"
);
rig.shutdown();
}
/// Portfolio scan of root.near via the NEAR indexer with canned
/// FastNEAR + Intear HTTP responses.
///
/// This is the critical test for the fuel exhaustion bug: the WASM
/// tool must parse the Intear price response (~235 KB) within the
/// default 100M fuel budget. The previous `/tokens` endpoint
/// (~3.2 MB) caused fuel exhaustion; this test ensures the lighter
/// `/list-token-price` endpoint stays within budget.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary + recorded fixtures
async fn wasm_portfolio_scan_near_within_fuel_budget() {
let fastnear_body = load_fixture("root.near.json");
let intear_body = load_fixture("intear_prices.json");
let trace = LlmTrace {
model_name: "test-portfolio-scan-near".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Scan root.near portfolio".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_portfolio_near_1".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "scan",
"addresses": ["root.near"],
"source": "near"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found NEAR positions for root.near.".to_string(),
input_tokens: 2000,
output_tokens: 50,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![
// Intear: token prices (~235 KB) — fetched first
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://prices.intear.tech/list-token-price".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&intear_body),
},
// FastNEAR: account balances — fetched second
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://api.fastnear.com/v1/account/root.near/full".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&fastnear_body),
},
],
expects: TraceExpects {
tools_used: vec!["portfolio".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("portfolio", PORTFOLIO_WASM, Some(PORTFOLIO_CAPS.into()))
.build()
.await;
rig.send_message("Scan root.near portfolio").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(60)).await;
rig.verify_trace_expects(&trace, &responses);
// Verify tool succeeded — this is the fuel exhaustion regression check
let completed = rig.tool_calls_completed();
let portfolio_result = completed.iter().find(|(name, _)| name == "portfolio");
assert!(
portfolio_result.is_some(),
"portfolio tool was never completed — may have timed out or crashed"
);
let (_, success) = portfolio_result.unwrap();
assert!(
*success,
"portfolio tool failed (likely fuel exhaustion). \
Completed tools: {completed:?}"
);
// Verify the output contains NEAR positions
let results = rig.tool_results();
let portfolio_output = results
.iter()
.find(|(name, _)| name == "portfolio")
.map(|(_, preview)| preview.clone())
.unwrap_or_default();
assert!(
portfolio_output.contains("NEAR") || portfolio_output.contains("near"),
"portfolio output should mention NEAR, got: {portfolio_output}"
);
rig.shutdown();
}
/// Portfolio scan with `source=auto` — verifies auto-detection routes
/// a NEAR address to the NEAR backend (not the Dune EVM backend).
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary + recorded fixtures
async fn wasm_portfolio_auto_detect_near_address() {
let fastnear_body = load_fixture("root.near.json");
let intear_body = load_fixture("intear_prices.json");
let trace = LlmTrace {
model_name: "test-portfolio-auto-detect".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Scan root.near".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_portfolio_auto_1".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "scan",
"addresses": ["root.near"],
"source": "auto"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Scanned root.near via auto-detect.".to_string(),
input_tokens: 2000,
output_tokens: 50,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![
// Intear first, then FastNEAR (matches WASM fetch order)
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://prices.intear.tech/list-token-price".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&intear_body),
},
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://api.fastnear.com/v1/account/root.near/full".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&fastnear_body),
},
],
expects: TraceExpects {
tools_used: vec!["portfolio".to_string()],
all_tools_succeeded: Some(true),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("portfolio", PORTFOLIO_WASM, Some(PORTFOLIO_CAPS.into()))
.build()
.await;
rig.send_message("Scan root.near").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(60)).await;
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Full scan → propose pipeline using fixture data.
///
/// The fixture has a test_lending position (USDC, 3.2% APY, $5000)
/// below the 4% stablecoin yield floor. The propose step should
/// generate at least one proposal for it.
///
/// This tests the complete WASM pipeline: scan parses fixtures,
/// analyzer classifies positions, strategy engine matches against
/// the yield-floor strategy, and proposals are generated — all
/// within the fuel budget.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary
async fn wasm_portfolio_full_pipeline_scan_then_propose() {
// Step 1: scan the fixture address. The scan response is
// deterministic — 0x1111... has a test_lending USDC position.
// Step 2: propose with the classified positions from step 1.
//
// The trace encodes both steps. The propose call uses the known
// classified output from the fixture: test_lending position
// classified as stablecoin-idle (USDC in lending = stablecoin-idle).
let strategy_doc = r#"---
id: stablecoin-yield-floor
version: 1
applies_to:
category: stablecoin-idle
min_principal_usd: 100
constraints:
min_projected_delta_apy_bps: 50
max_risk_score: 3
max_bridge_legs: 1
gas_payback_days: 30
prefer_same_chain: true
prefer_near_intents: true
inputs:
floor_apy: 0.04
---
# Stablecoin Yield Floor
Keep idle stablecoins at or above floor_apy net APY."#;
// The classified position from scanning 0x1111... with fixture source.
// This is the deterministic output of scan + classify for the fixture.
let classified_position = json!({
"protocol": {"id": "test_lending", "name": "Test Lending"},
"category": "stablecoin-idle",
"chain": "base",
"address": "0x1111111111111111111111111111111111111111",
"principal_usd": "5000.00",
"debt_usd": "0.00",
"net_yield_apy": 0.032,
"unrealized_pnl_usd": "0.00",
"risk_score": 0,
"exit_cost_estimate_usd": "0.00",
"withdrawal_delay_seconds": 0,
"liquidity_tier": "instant",
"health": null,
"tags": [],
"raw_position": {
"chain": "base",
"protocol_id": "test_lending",
"position_type": "supply",
"address": "0x1111111111111111111111111111111111111111",
"token_balances": [{
"symbol": "USDC",
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"chain": "base",
"amount": "5000.000000",
"value_usd": "5000.00"
}],
"debt_balances": [],
"reward_balances": [],
"raw_metadata": {
"supply_apy": 0.032,
"borrow_apy": 0.0,
"pool_contract": "0x0000000000000000000000000000000000000abc"
},
"block_number": 12000000,
"fetched_at": 1712822400
}
});
let trace = LlmTrace {
model_name: "test-portfolio-full-pipeline".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Scan and propose yield for 0x1111111111111111111111111111111111111111"
.to_string(),
steps: vec![
// Step 1: LLM calls scan
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_scan".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "scan",
"addresses": ["0x1111111111111111111111111111111111111111"],
"source": "fixture"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
// Step 2: LLM calls propose with the scan results
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_propose".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "propose",
"positions": [classified_position],
"strategies": [strategy_doc],
"config": {
"floor_apy": 0.04,
"max_risk_score": 3,
"notify_threshold_usd": 100,
"auto_intent_ceiling_usd": 1000,
"max_slippage_bps": 50
}
}),
}],
input_tokens: 500,
output_tokens: 50,
},
expected_tool_results: Vec::new(),
},
// Step 3: LLM summarizes
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found a position below the yield floor.".to_string(),
input_tokens: 1000,
output_tokens: 50,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: Vec::new(),
expects: TraceExpects {
tools_used: vec!["portfolio".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(2),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("portfolio", PORTFOLIO_WASM, Some(PORTFOLIO_CAPS.into()))
.build()
.await;
rig.send_message("Scan and propose yield for 0x1111111111111111111111111111111111111111")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(30)).await;
rig.verify_trace_expects(&trace, &responses);
// Check both tool calls succeeded
let completed = rig.tool_calls_completed();
let portfolio_calls: Vec<_> = completed
.iter()
.filter(|(name, _)| name == "portfolio")
.collect();
assert_eq!(
portfolio_calls.len(),
2,
"expected 2 portfolio calls (scan + propose), got: {completed:?}"
);
assert!(
portfolio_calls.iter().all(|(_, success)| *success),
"both portfolio calls should succeed: {completed:?}"
);
// Check the propose output contains a proposal (test_lending at 3.2%
// is below the 4% floor, so it should trigger a yield-floor proposal)
let results = rig.tool_results();
let propose_output = results
.iter()
.filter(|(name, _)| name == "portfolio")
.nth(1)
.map(|(_, preview)| preview.clone())
.unwrap_or_default();
assert!(
propose_output.contains("proposals")
|| propose_output.contains("ready")
|| propose_output.contains("below-threshold"),
"propose output should contain proposals, got: {propose_output}"
);
rig.shutdown();
}
/// Full pipeline with NEAR address: scan root.near → propose with
/// canned HTTP responses. Verifies the entire WASM pipeline works
/// within fuel limits for a real NEAR wallet.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary + recorded fixtures
async fn wasm_portfolio_near_scan_then_propose() {
let fastnear_body = load_fixture("root.near.json");
let intear_body = load_fixture("intear_prices.json");
let strategy_doc = r#"---
id: stablecoin-yield-floor
version: 1
applies_to:
category: stablecoin-idle
min_principal_usd: 100
constraints:
min_projected_delta_apy_bps: 50
max_risk_score: 3
inputs:
floor_apy: 0.04
---
# Stablecoin Yield Floor
Keep idle stablecoins at or above floor_apy net APY."#;
// For NEAR wallet positions (category=wallet, not stablecoin-idle),
// no yield proposals are expected — wallet holdings don't match
// the yield-floor strategy. The test verifies the pipeline runs
// end-to-end without errors (especially fuel exhaustion).
// We need the classified positions from scan to pass to propose.
// Since we can't dynamically extract them from step 1's output in
// a trace, we pass an empty array — propose should return 0 proposals
// without erroring.
let trace = LlmTrace {
model_name: "test-portfolio-near-pipeline".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Scan root.near and check yield".to_string(),
steps: vec![
// Step 1: scan root.near
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_near_scan".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "scan",
"addresses": ["root.near"],
"source": "near"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
// Step 2: propose with empty positions (wallet
// positions don't match yield strategies — this
// verifies propose handles the "no matches" case)
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_near_propose".to_string(),
name: "portfolio".to_string(),
arguments: json!({
"action": "propose",
"positions": [],
"strategies": [strategy_doc],
"config": {
"floor_apy": 0.04,
"max_risk_score": 3,
"notify_threshold_usd": 100,
"auto_intent_ceiling_usd": 1000,
"max_slippage_bps": 50
}
}),
}],
input_tokens: 2000,
output_tokens: 50,
},
expected_tool_results: Vec::new(),
},
// Step 3: summary
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Scanned root.near. Wallet holdings found but no DeFi yield positions to optimize.".to_string(),
input_tokens: 2500,
output_tokens: 80,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![
// Intear first, then FastNEAR
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://prices.intear.tech/list-token-price".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&intear_body),
},
HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: "https://api.fastnear.com/v1/account/root.near/full".to_string(),
headers: vec![],
body: None,
},
response: json_ok(&fastnear_body),
},
],
expects: TraceExpects {
tools_used: vec!["portfolio".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(2),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("portfolio", PORTFOLIO_WASM, Some(PORTFOLIO_CAPS.into()))
.build()
.await;
rig.send_message("Scan root.near and check yield").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(60)).await;
rig.verify_trace_expects(&trace, &responses);
// Both calls should succeed
let completed = rig.tool_calls_completed();
let portfolio_calls: Vec<_> = completed
.iter()
.filter(|(name, _)| name == "portfolio")
.collect();
assert_eq!(
portfolio_calls.len(),
2,
"expected 2 portfolio calls (scan + propose): {completed:?}"
);
assert!(
portfolio_calls.iter().all(|(_, success)| *success),
"both calls should succeed: {completed:?}"
);
rig.shutdown();
}
}