diff --git a/apps/android/build.gradle b/apps/android/build.gradle
index 95ce4417b..2125a7de7 100644
--- a/apps/android/build.gradle
+++ b/apps/android/build.gradle
@@ -25,8 +25,8 @@ android {
applicationId "ai.ciris.mobile"
minSdk 24
targetSdk 36
- versionCode 201
- versionName "2.14.0"
+ versionCode 202
+ versionName "2.15.0"
ndk {
// Include x86_64 for emulator testing (Chaquopy reads abiFilters at config time)
diff --git a/apps/android/src/main/python/version.py b/apps/android/src/main/python/version.py
index 79e05f63f..bca17fefe 100644
--- a/apps/android/src/main/python/version.py
+++ b/apps/android/src/main/python/version.py
@@ -7,7 +7,7 @@
# Static version - updated at build time by the Android build process
# This avoids file-system hashing logic that doesn't work in the Android package
-__version__ = "android-2.14.0"
+__version__ = "android-2.15.0"
def get_version() -> str:
diff --git a/apps/ios/iosApp/Info.plist b/apps/ios/iosApp/Info.plist
index bce9bc50a..91770e37e 100644
--- a/apps/ios/iosApp/Info.plist
+++ b/apps/ios/iosApp/Info.plist
@@ -21,9 +21,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 2.14.0
+ 2.15.0
CFBundleVersion
- 363
+ 364
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/ciris_engine/constants.py b/ciris_engine/constants.py
index 71562ced3..38419ea43 100644
--- a/ciris_engine/constants.py
+++ b/ciris_engine/constants.py
@@ -6,10 +6,10 @@
from ciris_engine.schemas.runtime.canonical_peer import CanonicalBootstrapPeer
# Version information
-CIRIS_VERSION = "2.14.0-stable"
+CIRIS_VERSION = "2.15.0-stable"
ACCORD_VERSION = "1.2-Beta"
CIRIS_VERSION_MAJOR = 2
-CIRIS_VERSION_MINOR = 14
+CIRIS_VERSION_MINOR = 15
CIRIS_VERSION_PATCH = 0
CIRIS_VERSION_BUILD = 0
CIRIS_VERSION_STAGE = "stable"
diff --git a/tests/tools/qa_runner/test_wallet_stub_rpc.py b/tests/tools/qa_runner/test_wallet_stub_rpc.py
new file mode 100644
index 000000000..217872529
--- /dev/null
+++ b/tests/tools/qa_runner/test_wallet_stub_rpc.py
@@ -0,0 +1,221 @@
+"""QA must never talk to the public Base RPC.
+
+v2.14.0 release build (run 37649288177), Staged QA (all_2) sqlite: 463/463
+tests passed and the job still failed, because the incidents gate found
+
+ [ChainClient] Failed to get USDC balance: Server error '503 Service
+ Unavailable' for url 'https://mainnet.base.org'
+
+(and the ETH twin). The wallet adapter auto-loads for base-mainnet and its
+context-enrichment tool queries balances during context gathering, so QA was
+gated on a third party's uptime. The fix is a local stub RPC the runner
+points the wallet at via WALLET_X402_RPC_URL -- NOT an incidents-pattern
+allow-list entry, which would hide real wallet failures.
+"""
+
+from __future__ import annotations
+
+import logging
+import pathlib
+from decimal import Decimal
+from typing import Iterator
+
+import httpx
+import pytest
+
+from ciris_adapters.wallet.providers.chain_client import CHAIN_CONFIG, ChainClient
+from tools.qa_runner.mock_chain_rpc import (
+ BASE_MAINNET_CHAIN_ID,
+ WALLET_RPC_URL_ENV,
+ ZERO_WORD,
+ MockChainRPCServer,
+ apply_wallet_rpc_stub_env,
+)
+
+REPO = pathlib.Path(__file__).resolve().parents[3]
+TEST_ADDRESS = "0x" + "ab" * 20
+
+
+@pytest.fixture
+def stub() -> Iterator[MockChainRPCServer]:
+ server = MockChainRPCServer()
+ assert server.start()
+ yield server
+ server.stop()
+
+
+def _rpc(url: str, method: str, params: list[object]) -> dict[str, object]:
+ resp = httpx.post(url, json={"jsonrpc": "2.0", "id": 7, "method": method, "params": params})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert isinstance(body, dict)
+ return body
+
+
+# --------------------------------------------------------------------------
+# The stub itself
+# --------------------------------------------------------------------------
+
+
+def test_stub_binds_a_free_local_port(stub: MockChainRPCServer) -> None:
+ assert stub.port != 0
+ assert stub.endpoint_url.startswith("http://127.0.0.1:")
+
+
+def test_stub_answers_read_calls_deterministically(stub: MockChainRPCServer) -> None:
+ url = stub.endpoint_url
+ assert _rpc(url, "eth_chainId", [])["result"] == hex(BASE_MAINNET_CHAIN_ID)
+ assert _rpc(url, "eth_getBalance", [TEST_ADDRESS, "latest"])["result"] == "0x0"
+ call = _rpc(url, "eth_call", [{"to": CHAIN_CONFIG["base-mainnet"]["usdc_address"], "data": "0x70a08231"}, "latest"])
+ assert call["result"] == ZERO_WORD
+ assert call["id"] == 7
+ assert "error" not in call
+ assert int(str(_rpc(url, "eth_blockNumber", [])["result"]), 16) >= 0
+ assert _rpc(url, "eth_getTransactionReceipt", ["0x" + "00" * 32])["result"] is None
+ block = _rpc(url, "eth_getBlockByNumber", ["latest", False])["result"]
+ assert isinstance(block, dict) and "baseFeePerGas" in block
+
+
+def test_stub_refuses_transaction_submission(stub: MockChainRPCServer) -> None:
+ body = _rpc(stub.endpoint_url, "eth_sendRawTransaction", ["0xdead"])
+ assert "result" not in body
+ assert isinstance(body["error"], dict)
+
+
+def test_stub_rejects_unknown_methods(stub: MockChainRPCServer) -> None:
+ body = _rpc(stub.endpoint_url, "debug_traceTransaction", [])
+ assert isinstance(body["error"], dict) and body["error"]["code"] == -32601
+
+
+def test_stub_records_calls(stub: MockChainRPCServer) -> None:
+ _rpc(stub.endpoint_url, "eth_chainId", [])
+ _rpc(stub.endpoint_url, "eth_getBalance", [TEST_ADDRESS, "latest"])
+ assert [c.method for c in stub.get_calls()] == ["eth_chainId", "eth_getBalance"]
+
+
+async def test_chain_client_against_stub_logs_no_errors(
+ stub: MockChainRPCServer, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Positive: the exact calls that failed in v2.14.0 succeed against the stub."""
+ client = ChainClient(network="base-mainnet", rpc_url=stub.endpoint_url)
+ with caplog.at_level(logging.DEBUG, logger="ciris_adapters.wallet.providers.chain_client"):
+ assert await client.get_usdc_balance(TEST_ADDRESS) == Decimal("0")
+ assert await client.get_eth_balance(TEST_ADDRESS) == Decimal("0")
+ assert await client.get_block_number() == 1
+ errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
+ assert not errors, [r.getMessage() for r in errors]
+ assert {c.method for c in stub.get_calls()} == {"eth_call", "eth_getBalance", "eth_blockNumber"}
+
+
+# --------------------------------------------------------------------------
+# Negative checks -- the defect is reproducible without the fix
+# --------------------------------------------------------------------------
+
+
+def test_without_override_wallet_targets_public_mainnet(monkeypatch: pytest.MonkeyPatch) -> None:
+ """NEGATIVE: no WALLET_X402_RPC_URL -> the ChainClient aims at mainnet.base.org."""
+ from ciris_adapters.wallet.adapter import WalletAdapter
+
+ monkeypatch.delenv(WALLET_RPC_URL_ENV, raising=False)
+ monkeypatch.delenv("WALLET_X402_ENABLED", raising=False)
+ cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self
+ x402 = cfg.provider_configs["x402"]
+ assert x402.rpc_url is None
+ assert ChainClient(network=x402.network, rpc_url=x402.rpc_url).rpc_url == "https://mainnet.base.org"
+
+
+def test_override_env_reaches_the_chain_client(monkeypatch: pytest.MonkeyPatch, stub: MockChainRPCServer) -> None:
+ """The existing env var flows adapter config -> X402 config -> ChainClient."""
+ from ciris_adapters.wallet.adapter import WalletAdapter
+
+ env: dict[str, str] = {}
+ assert apply_wallet_rpc_stub_env(env, stub, operator_env={})
+ monkeypatch.setenv(WALLET_RPC_URL_ENV, env[WALLET_RPC_URL_ENV])
+ cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self
+ x402 = cfg.provider_configs["x402"]
+ assert ChainClient(network=x402.network, rpc_url=x402.rpc_url).rpc_url == stub.endpoint_url
+
+
+async def test_upstream_503_is_logged_as_chain_client_error(caplog: pytest.LogCaptureFixture) -> None:
+ """NEGATIVE: an RPC answering 503 reproduces the exact v2.14.0 incident lines.
+
+ This is why the incidents gate went red, and why the fix is to stop
+ talking to a live RPC rather than to allow-list the message.
+ """
+ outage = MockChainRPCServer(forced_status=503)
+ assert outage.start()
+ try:
+ client = ChainClient(network="base-mainnet", rpc_url=outage.endpoint_url)
+ with caplog.at_level(logging.ERROR, logger="ciris_adapters.wallet.providers.chain_client"):
+ assert await client.get_usdc_balance(TEST_ADDRESS) == Decimal("0")
+ assert await client.get_eth_balance(TEST_ADDRESS) == Decimal("0")
+ finally:
+ outage.stop()
+ messages = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR]
+ assert any("[ChainClient] Failed to get USDC balance" in m and "503" in m for m in messages), messages
+ assert any("[ChainClient] Failed to get ETH balance" in m and "503" in m for m in messages), messages
+
+
+def test_chain_client_error_is_not_allow_listed() -> None:
+ """The fix must not be an incidents-gate allow-list entry."""
+ from tools.qa_runner.runner import EXPECTED_QA_INCIDENT_PATTERNS
+
+ joined = " ".join(str(p) for p in EXPECTED_QA_INCIDENT_PATTERNS)
+ assert "ChainClient" not in joined
+ assert "mainnet.base.org" not in joined
+
+
+# --------------------------------------------------------------------------
+# Runner wiring
+# --------------------------------------------------------------------------
+
+
+def test_apply_env_sets_stub_url(stub: MockChainRPCServer) -> None:
+ env: dict[str, str] = {}
+ assert apply_wallet_rpc_stub_env(env, stub, operator_env={})
+ assert env[WALLET_RPC_URL_ENV] == stub.endpoint_url
+
+
+def test_operator_export_wins(stub: MockChainRPCServer) -> None:
+ env = {WALLET_RPC_URL_ENV: "https://sepolia.base.org"}
+ assert not apply_wallet_rpc_stub_env(env, stub, operator_env=dict(env))
+ assert env[WALLET_RPC_URL_ENV] == "https://sepolia.base.org"
+
+
+def test_no_stub_no_change() -> None:
+ env: dict[str, str] = {}
+ assert not apply_wallet_rpc_stub_env(env, None, operator_env={})
+ assert WALLET_RPC_URL_ENV not in env
+
+
+def test_server_manager_starts_stub_and_wires_wallet() -> None:
+ """APIServerManager.start() must start the stub and apply it to the agent env; stop() must stop it."""
+ src = (REPO / "tools" / "qa_runner" / "server.py").read_text(encoding="utf-8")
+ start_body = src[src.index(' def start(self) -> bool:\n """Start the API server') :]
+ assert "self.mock_chain_rpc = MockChainRPCServer()" in start_body
+ assert "apply_wallet_rpc_stub_env(env, self.mock_chain_rpc, os.environ)" in start_body
+ assert "self.mock_chain_rpc.stop()" in src
+
+
+async def test_x402_provider_from_env_fetches_balance_via_stub(
+ monkeypatch: pytest.MonkeyPatch, stub: MockChainRPCServer, caplog: pytest.LogCaptureFixture
+) -> None:
+ """End to end inside the wallet: env -> adapter config -> X402Provider -> stub.
+
+ _fetch_balance_from_chain is the path wallet:get_statement takes during
+ context enrichment -- the one that hit mainnet in v2.14.0.
+ """
+ from ciris_adapters.wallet.adapter import WalletAdapter
+ from ciris_adapters.wallet.providers.x402_provider import X402Provider
+
+ env: dict[str, str] = {}
+ apply_wallet_rpc_stub_env(env, stub, operator_env={})
+ monkeypatch.setenv(WALLET_RPC_URL_ENV, env[WALLET_RPC_URL_ENV])
+ cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self
+ provider = X402Provider(cfg.provider_configs["x402"], evm_address=TEST_ADDRESS)
+
+ with caplog.at_level(logging.ERROR):
+ balance = await provider._fetch_balance_from_chain()
+ assert balance.available == Decimal("0")
+ assert not [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR]
+ assert {c.method for c in stub.get_calls()} == {"eth_call", "eth_getBalance"}
diff --git a/tools/qa_runner/mock_chain_rpc.py b/tools/qa_runner/mock_chain_rpc.py
new file mode 100644
index 000000000..144e5e968
--- /dev/null
+++ b/tools/qa_runner/mock_chain_rpc.py
@@ -0,0 +1,233 @@
+"""
+Local stub JSON-RPC server for the wallet adapter during QA.
+
+The wallet adapter auto-loads at startup (bootstrap_helpers) with x402 on
+base-mainnet, and its context-enrichment tool ``wallet:get_statement`` calls
+the chain during context gathering. Without an override every QA run queries
+the public ``https://mainnet.base.org`` endpoint, so an outage of that third
+party turns the QA incidents gate red (v2.14.0, run 37649288177: 503s logged
+as ``[ChainClient] Failed to get USDC/ETH balance`` ERRORs).
+
+This stub answers the read-only calls ``ChainClient`` issues with
+deterministic values. The QA runner points the wallet at it through the
+adapter's existing ``WALLET_X402_RPC_URL`` env var, so QA never talks to
+mainnet. It refuses ``eth_sendRawTransaction``: QA must never submit a
+transaction, and a submission attempt should be loud.
+
+Same lifecycle shape as ``MockLogshipperServer`` in ``server.py``.
+"""
+
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from typing import Dict, List, Mapping, MutableMapping, Optional, Union
+
+from pydantic import BaseModel, Field, JsonValue
+
+# Env var the wallet adapter already reads for the x402 ChainClient RPC URL
+# (ciris_adapters/wallet/adapter.py::_load_config_from_env).
+WALLET_RPC_URL_ENV = "WALLET_X402_RPC_URL"
+
+# Base mainnet chain id - the network the wallet initialises for in QA.
+BASE_MAINNET_CHAIN_ID = 8453
+
+# Deterministic answers. Balances are zero (a fresh QA wallet holds nothing),
+# so the stub cannot make a test pass by inventing funds.
+ZERO_WORD = "0x" + "0" * 64
+STUB_BLOCK_NUMBER = "0x1"
+STUB_GAS_PRICE = "0x3b9aca00" # 1 gwei
+STUB_GAS_ESTIMATE = "0x5208" # 21000
+STUB_BASE_FEE = "0x0"
+
+JSONRPC_METHOD_NOT_FOUND = -32601
+JSONRPC_INVALID_REQUEST = -32600
+JSONRPC_SERVER_ERROR = -32000
+
+
+class JsonRpcError(BaseModel):
+ """JSON-RPC 2.0 error object."""
+
+ code: int
+ message: str
+
+
+class JsonRpcResponse(BaseModel):
+ """JSON-RPC 2.0 response envelope."""
+
+ jsonrpc: str = "2.0"
+ id: Optional[Union[int, str]] = None
+ result: JsonValue = None
+ error: Optional[JsonRpcError] = None
+
+ def to_wire(self) -> bytes:
+ body = self.model_dump(exclude_none=False)
+ # Per spec a response carries exactly one of result / error.
+ if self.error is not None:
+ body.pop("result", None)
+ else:
+ body.pop("error", None)
+ return json.dumps(body).encode("utf-8")
+
+
+class RecordedCall(BaseModel):
+ """One RPC call the stub received (for assertions and the stop report)."""
+
+ method: str
+ params: List[JsonValue] = Field(default_factory=list)
+
+
+def answer_rpc(method: str, chain_id: int) -> JsonRpcResponse:
+ """Deterministic answer for one JSON-RPC method (id filled in by caller)."""
+ static: Dict[str, JsonValue] = {
+ "eth_chainId": hex(chain_id),
+ "net_version": str(chain_id),
+ "eth_getBalance": "0x0",
+ # ERC-20 balanceOf / allowance and EntryPoint getNonce all decode a
+ # single uint256 word - zero for each.
+ "eth_call": ZERO_WORD,
+ "eth_blockNumber": STUB_BLOCK_NUMBER,
+ "eth_getTransactionCount": "0x0",
+ "eth_gasPrice": STUB_GAS_PRICE,
+ "eth_estimateGas": STUB_GAS_ESTIMATE,
+ "eth_getTransactionReceipt": None,
+ "eth_getBlockByNumber": {
+ "number": STUB_BLOCK_NUMBER,
+ "baseFeePerGas": STUB_BASE_FEE,
+ "timestamp": "0x0",
+ "transactions": [],
+ },
+ }
+ if method in static:
+ return JsonRpcResponse(result=static[method])
+ if method == "eth_sendRawTransaction":
+ return JsonRpcResponse(
+ error=JsonRpcError(
+ code=JSONRPC_SERVER_ERROR,
+ message="QA stub RPC refuses transaction submission",
+ )
+ )
+ return JsonRpcResponse(
+ error=JsonRpcError(code=JSONRPC_METHOD_NOT_FOUND, message=f"QA stub RPC: method not found: {method}")
+ )
+
+
+class _MockChainRPCHTTPServer(HTTPServer):
+ """HTTPServer holding per-instance state (see _MockLogshipperHTTPServer)."""
+
+ chain_id: int
+ forced_status: Optional[int]
+ calls: List[RecordedCall]
+ calls_lock: threading.Lock
+
+
+class MockChainRPCHandler(BaseHTTPRequestHandler):
+ """Answers JSON-RPC POSTs on any path."""
+
+ server: _MockChainRPCHTTPServer
+
+ def log_message(self, format: str, *args: object) -> None:
+ """Suppress default logging."""
+
+ def _send(self, status: int, body: bytes) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_POST(self) -> None:
+ length = int(self.headers.get("Content-Length", 0))
+ raw = self.rfile.read(length)
+
+ if self.server.forced_status is not None:
+ # Test hook: emulate an upstream outage (e.g. 503).
+ self._send(self.server.forced_status, b'{"error": "forced by QA stub"}')
+ return
+
+ try:
+ payload = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ payload = None
+ if not isinstance(payload, dict) or not isinstance(payload.get("method"), str):
+ invalid = JsonRpcResponse(error=JsonRpcError(code=JSONRPC_INVALID_REQUEST, message="Invalid Request"))
+ self._send(200, invalid.to_wire())
+ return
+
+ method: str = payload["method"]
+ params = payload.get("params")
+ with self.server.calls_lock:
+ self.server.calls.append(RecordedCall(method=method, params=params if isinstance(params, list) else []))
+
+ response = answer_rpc(method, self.server.chain_id)
+ request_id = payload.get("id")
+ response.id = request_id if isinstance(request_id, (int, str)) else None
+ self._send(200, response.to_wire())
+
+
+class MockChainRPCServer:
+ """Local stub Base RPC for QA, run in a background thread.
+
+ port=0 (the default) binds an OS-assigned free port, so concurrent
+ backends under --parallel-backends never collide.
+ """
+
+ def __init__(
+ self,
+ port: int = 0,
+ chain_id: int = BASE_MAINNET_CHAIN_ID,
+ forced_status: Optional[int] = None,
+ ) -> None:
+ self.port = port
+ self.chain_id = chain_id
+ self.forced_status = forced_status
+ self.server: Optional[_MockChainRPCHTTPServer] = None
+ self.thread: Optional[threading.Thread] = None
+
+ def start(self) -> bool:
+ """Start the stub in a background thread."""
+ try:
+ server = _MockChainRPCHTTPServer(("127.0.0.1", self.port), MockChainRPCHandler)
+ except OSError:
+ return False
+ server.chain_id = self.chain_id
+ server.forced_status = self.forced_status
+ server.calls = []
+ server.calls_lock = threading.Lock()
+ self.port = server.server_address[1]
+ self.server = server
+ self.thread = threading.Thread(target=server.serve_forever, daemon=True)
+ self.thread.start()
+ return True
+
+ def stop(self) -> None:
+ """Stop the stub."""
+ if self.server:
+ self.server.shutdown()
+ self.server.server_close()
+ self.server = None
+
+ def get_calls(self) -> List[RecordedCall]:
+ """RPC calls received so far."""
+ if self.server is None:
+ return []
+ with self.server.calls_lock:
+ return list(self.server.calls)
+
+ @property
+ def endpoint_url(self) -> str:
+ return f"http://127.0.0.1:{self.port}"
+
+
+def apply_wallet_rpc_stub_env(
+ env: MutableMapping[str, str], stub: Optional[MockChainRPCServer], operator_env: Mapping[str, str]
+) -> bool:
+ """Point the agent's wallet at the stub RPC.
+
+ An operator-exported WALLET_X402_RPC_URL wins (same precedence as
+ apply_module_server_env), so a deliberate testnet/mainnet run is still
+ possible. Returns True when the stub URL was applied.
+ """
+ if stub is None or WALLET_RPC_URL_ENV in operator_env:
+ return False
+ env[WALLET_RPC_URL_ENV] = stub.endpoint_url
+ return True
diff --git a/tools/qa_runner/server.py b/tools/qa_runner/server.py
index bc18f5eff..e8535e77e 100644
--- a/tools/qa_runner/server.py
+++ b/tools/qa_runner/server.py
@@ -45,6 +45,7 @@
from rich.console import Console
from .config import QAConfig
+from .mock_chain_rpc import MockChainRPCServer, apply_wallet_rpc_stub_env
logger = logging.getLogger(__name__)
@@ -603,6 +604,9 @@ def __init__(self, config: QAConfig, database_backend: str = "sqlite", modules:
self.process: Optional[subprocess.Popen] = None
self.pid: Optional[int] = None
self.mock_logshipper: Optional[MockLogshipperServer] = None
+ # Local stub Base RPC for the wallet adapter, so QA never depends on
+ # the uptime of the public mainnet.base.org endpoint.
+ self.mock_chain_rpc: Optional[MockChainRPCServer] = None
self._extracted_password: Optional[str] = None # Dynamically extracted from server output
# Per-backend output namespace. In parallel-backend mode both SQLITE
@@ -954,6 +958,20 @@ def start(self) -> bool:
self.console.print("[yellow][WARN] Could not start mock logshipper[/yellow]")
self.mock_logshipper = None
+ # Start the stub chain RPC. The wallet adapter auto-loads for
+ # base-mainnet and its context-enrichment tool (wallet:get_statement)
+ # queries balances during context gathering; without this the agent
+ # calls https://mainnet.base.org and a third-party 503 surfaces as a
+ # ChainClient ERROR that fails the incidents gate (v2.14.0 all_2).
+ # Reused across a restart (start() without stop()) so the URL is stable.
+ if self.mock_chain_rpc is None:
+ self.mock_chain_rpc = MockChainRPCServer()
+ if self.mock_chain_rpc.start():
+ self.console.print(f"[cyan][STUB] Stub chain RPC started at {self.mock_chain_rpc.endpoint_url}[/cyan]")
+ else:
+ self.console.print("[yellow][WARN] Could not start stub chain RPC[/yellow]")
+ self.mock_chain_rpc = None
+
self.console.print("[cyan] Starting API server...[/cyan]")
# Build command. When --from-staged is set, the server under test is
@@ -1090,6 +1108,11 @@ def _is_host(domain: str) -> bool:
if self.mock_logshipper:
env["CIRIS_ACCORD_METRICS_ENDPOINT"] = self.mock_logshipper.endpoint_url
+ # Point the wallet's ChainClient at the stub RPC (operator export of
+ # WALLET_X402_RPC_URL still wins).
+ if apply_wallet_rpc_stub_env(env, self.mock_chain_rpc, os.environ):
+ self.console.print(f"[dim]WALLET_X402_RPC_URL={env['WALLET_X402_RPC_URL']} (stub chain RPC)[/dim]")
+
# When running in --live mode, opt the agent into location sharing in
# accord traces so lens dashboards can correlate by region. Defaults
# to the dev's home base (Schaumburg, IL) and can be overridden by
@@ -1574,6 +1597,15 @@ def stop(self):
self.console.print(f" • {trace['task_name']}: {trace['filepath']}")
self.mock_logshipper = None
+ # Stop the stub chain RPC and report what the wallet asked it
+ if self.mock_chain_rpc:
+ calls = self.mock_chain_rpc.get_calls()
+ self.mock_chain_rpc.stop()
+ if calls:
+ methods = sorted({c.method for c in calls})
+ self.console.print(f"[green] Stub chain RPC served {len(calls)} calls: {', '.join(methods)}[/green]")
+ self.mock_chain_rpc = None
+
# Skip port cleanup - it's causing hangs
# JOIN THE LOG READER. It is a daemon thread teeing the server's stdout;
# once the process is gone its readline returns EOF and it exits -- but