Skip to content

Commit 27e5aba

Browse files
authored
Merge pull request #449 from szhygulin/feat/demo-saga-followups
Demo saga followups: items #3#7 from plan parking lot
2 parents 046f44a + d3ca78c commit 27e5aba

8 files changed

Lines changed: 691 additions & 9 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Verify demo personas
2+
3+
# Out-of-tree watcher for src/demo/personas.ts cell drift. Personas
4+
# point at real public wallets that may go quiet or rotate over time;
5+
# this workflow runs cheap liveness checks via public chain APIs and
6+
# exits non-zero (failing the run) if any cell looks dead.
7+
#
8+
# Trigger: workflow_dispatch (manual) + weekly cron. Not blocking on
9+
# PRs — drift is caught after the fact, not at PR time. The intent is
10+
# that a maintainer reviews the failing run, refreshes the offending
11+
# cell in src/demo/personas.ts, and bumps verifiedAt.
12+
13+
on:
14+
workflow_dispatch:
15+
schedule:
16+
# Weekly, Mondays 06:00 UTC. Picked off-peak so a flaky public-RPC
17+
# response doesn't drown out CI alerts on weekday work hours.
18+
- cron: "0 6 * * 1"
19+
20+
jobs:
21+
verify:
22+
name: Liveness check
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- uses: actions/setup-node@v4
28+
with:
29+
node-version: "22"
30+
cache: npm
31+
32+
- name: Install
33+
run: npm ci --legacy-peer-deps
34+
35+
- name: Build (provides dist/demo/personas.js)
36+
run: npm run build
37+
38+
- name: Verify personas
39+
id: verify
40+
run: node scripts/verify-personas.mjs | tee verify-personas.report.md
41+
42+
- name: Upload report
43+
if: always()
44+
uses: actions/upload-artifact@v4
45+
with:
46+
name: verify-personas-report
47+
path: verify-personas.report.md
48+
retention-days: 30

CONTRIBUTING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ If you genuinely want to contribute on a tracking issue, demonstrate it by:
2424
1. Opening a small, focused PR against an actual bug or already-scoped task first, so we have signal that you understand the codebase.
2525
2. Asking a specific clarifying question that shows you read the issue and the linked code — pick one of the open decisions in the issue and propose a defensible answer with reasoning.
2626

27+
## Testing demo mode locally
28+
29+
Demo mode runs the server without RPC keys, Ledger pairing, or a config file:
30+
31+
```bash
32+
VAULTPILOT_DEMO=true node dist/index.js
33+
```
34+
35+
Or wire it into a local Claude Code session:
36+
37+
```bash
38+
claude mcp add vaultpilot-mcp-dev --env VAULTPILOT_DEMO=true -- node /absolute/path/to/vaultpilot-mcp/dist/index.js
39+
```
40+
41+
Useful when changing `src/demo/`, the `prepare_*` refusal/simulation paths, or `buildSimulationEnvelope` — unit tests cover contract correctness, but the agent-UX class of regressions (persona drift, simulation envelope readability, nudge timing) only surface in a live walkthrough.
42+
2743
## Reporting security issues
2844

2945
Do **not** open a public issue for vulnerabilities. See [SECURITY.md](./SECURITY.md) for the disclosure process.

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,43 @@ npm run setup
204204

205205
Environment variables always override the config file.
206206

207+
## Demo mode
208+
209+
For try-before-install exploration without RPC keys, Ledger pairing, or running the wizard, set `VAULTPILOT_DEMO=true` (or pass `--demo`):
210+
211+
```bash
212+
claude mcp add vaultpilot-mcp --env VAULTPILOT_DEMO=true -- npx -y vaultpilot-mcp
213+
# or, equivalently:
214+
claude mcp add vaultpilot-mcp -- npx -y vaultpilot-mcp --demo
215+
```
216+
217+
`--demo` is a thin alias that sets `VAULTPILOT_DEMO=true` before initialization. An explicit env value (including `VAULTPILOT_DEMO=false`) wins over the flag — useful when scripted invocations need a deterministic opt-out.
218+
219+
What demo mode does:
220+
221+
- Reads run against real on-chain RPC, but every wallet is one of a curated set of public personas (`whale`, `defi-degen`, `stable-saver`, `staking-maxi`) — no key access, no signing.
222+
- `send_transaction` is intercepted and returns a [simulation envelope](src/demo/index.ts) — the unsigned tx is `simulate_transaction`'d for revert detection, but nothing is signed and nothing is broadcast.
223+
- Pairing tools (`pair_ledger_*`), `request_capability`, and on-device signing (`sign_message_*`) are refused outright in demo, since none have an on-chain simulation equivalent.
224+
225+
Picking a persona:
226+
227+
- `get_demo_wallet` lists all personas with their per-chain curated addresses + `rehearsableFlows` (multi-step flows the wallet's existing on-chain state already supports end-to-end).
228+
- `set_demo_wallet({ persona: "defi-degen" })` activates one. Default mode (no persona) refuses signing-class tools with a structured error pointing at this call.
229+
- Multi-step flows whose preconditions are themselves state changes (e.g. `prepare_solana_nonce_init``marinade_stake`) cannot be rehearsed end-to-end — simulated sends don't mutate chain state. The MCP surfaces a one-shot hint when the agent-loop trap is detected.
230+
231+
API key overrides without restart:
232+
233+
- Solana public RPC throttles within seconds under multi-tool fan-out. Inject a [Helius](https://helius.dev) key at runtime: `set_helius_api_key({ key: "..." })`. The override applies to the next Solana RPC call. Demo mode also nudges proactively every 10 public-RPC throttle errors.
234+
235+
Leaving demo:
236+
237+
- `exit_demo_mode` returns a tailored handoff guide — preflight checks, per-chain RPC recommendations, and the exact `claude mcp add ...` recipe with `VAULTPILOT_DEMO` removed plus a setup-wizard launch step.
238+
239+
Caveats:
240+
241+
- Demo state is process-local and ephemeral; restart loses persona selection.
242+
- Demo is a scaffold for first-contact, not a sandbox — there is no virtual chain overlay. Permanent setup is what `vaultpilot-mcp-setup` is for.
243+
207244
## Use with Claude Desktop / Claude Code / Cursor
208245

209246
`vaultpilot-mcp-setup` detects which agent clients you have installed and offers to add a `vaultpilot-mcp` entry to each one's MCP-server config automatically. Each existing config is backed up to `<file>.vaultpilot.bak` before any change. Detected client paths:
@@ -245,6 +282,7 @@ All optional if the matching field is in `~/.vaultpilot-mcp/config.json`; env va
245282
- `VAULTPILOT_FEEDBACK_ENDPOINT` — optional https proxy for `request_capability` direct POSTs. **The client does not sign or authenticate requests — the proxy MUST enforce its own auth.**
246283
- `VAULTPILOT_SKILL_MARKER_PATH` — suppresses the preflight-skill notice for read-only users who accept the tradeoff
247284
- `VAULTPILOT_DISABLE_SKILL_AUTOINSTALL=1` — skips the lazy first-run `git clone` of the companion preflight + setup skills into `~/.claude/skills/`. The original manual-install notice fires instead. Use for air-gapped / no-egress operation where the MCP must not contact github.com.
285+
- `VAULTPILOT_DEMO=true` — enables [demo mode](#demo-mode) (curated personas + simulated `send_transaction`, no signing, no broadcast). Set the literal string `true`; any other value is rejected with a diagnostic message.
248286

249287
## Development
250288

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"setup": "node dist/setup.js",
2323
"test": "vitest run",
2424
"test:watch": "vitest",
25+
"verify-personas": "node scripts/verify-personas.mjs",
2526
"bundle": "pkg .",
2627
"prepare": "patch-package"
2728
},

scripts/verify-personas.mjs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Persona address rotation watcher.
4+
*
5+
* Personas in `src/demo/personas.ts` point at real public wallets that
6+
* may drift over time — a "stable-saver" cell could exit all USDC
7+
* positions tomorrow, leaving the persona description false. This
8+
* script does cheap liveness checks against public RPC / public chain
9+
* APIs and prints a markdown report flagging cells that look dead or
10+
* drifted. Designed for `workflow_dispatch` (manual + weekly cron) so
11+
* a human reviews the report and refreshes the matrix when needed.
12+
*
13+
* Scope is intentionally minimal: we verify the address still exists
14+
* and has a non-zero native balance for the chain. We do NOT try to
15+
* verify per-flow rehearsability (e.g. "does this wallet actually
16+
* have an Aave V3 supply position?") — that would require per-protocol
17+
* SDK setup with API keys, which is out of scope for an out-of-tree
18+
* weekly watcher. Liveness is the cheap proxy: a wallet that's gone
19+
* from "exchange hot wallet" to zero balance has clearly rotated.
20+
*
21+
* Usage:
22+
* npm run build # produce dist/demo/personas.js
23+
* node scripts/verify-personas.mjs # prints report, exits 0 / 1
24+
*/
25+
import { DEMO_WALLETS } from "../dist/demo/personas.js";
26+
27+
const TIMEOUT_MS = 15_000;
28+
29+
async function fetchWithTimeout(url, init) {
30+
const controller = new AbortController();
31+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
32+
try {
33+
return await fetch(url, { ...init, signal: controller.signal });
34+
} finally {
35+
clearTimeout(timer);
36+
}
37+
}
38+
39+
async function evmNativeBalance(address) {
40+
const res = await fetchWithTimeout("https://ethereum-rpc.publicnode.com", {
41+
method: "POST",
42+
headers: { "content-type": "application/json" },
43+
body: JSON.stringify({
44+
jsonrpc: "2.0",
45+
id: 1,
46+
method: "eth_getBalance",
47+
params: [address, "latest"],
48+
}),
49+
});
50+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
51+
const json = await res.json();
52+
if (json.error) throw new Error(json.error.message);
53+
return BigInt(json.result);
54+
}
55+
56+
async function solanaNativeBalance(address) {
57+
const res = await fetchWithTimeout("https://api.mainnet-beta.solana.com", {
58+
method: "POST",
59+
headers: { "content-type": "application/json" },
60+
body: JSON.stringify({
61+
jsonrpc: "2.0",
62+
id: 1,
63+
method: "getBalance",
64+
params: [address],
65+
}),
66+
});
67+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
68+
const json = await res.json();
69+
if (json.error) throw new Error(json.error.message);
70+
return BigInt(json.result.value);
71+
}
72+
73+
async function tronNativeBalance(address) {
74+
const res = await fetchWithTimeout(
75+
`https://api.trongrid.io/v1/accounts/${address}`,
76+
);
77+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
78+
const json = await res.json();
79+
const account = json.data?.[0];
80+
if (!account) return 0n;
81+
return BigInt(account.balance ?? 0);
82+
}
83+
84+
async function btcAddressTxCount(address) {
85+
const res = await fetchWithTimeout(
86+
`https://mempool.space/api/address/${address}`,
87+
);
88+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
89+
const json = await res.json();
90+
return (json.chain_stats?.tx_count ?? 0) + (json.mempool_stats?.tx_count ?? 0);
91+
}
92+
93+
function isTransientErr(err) {
94+
const msg = err instanceof Error ? err.message : String(err);
95+
// Rate-limit / abort / network reset: not drift, just public-API noise.
96+
return /HTTP 429|HTTP 5\d\d|aborted|fetch failed|ECONN/i.test(msg);
97+
}
98+
99+
async function checkCell(chain, type, cell) {
100+
try {
101+
if (chain === "evm") {
102+
const wei = await evmNativeBalance(cell.address);
103+
return {
104+
status: wei > 0n ? "alive" : "drifted",
105+
detail: `${(Number(wei) / 1e18).toFixed(4)} ETH`,
106+
};
107+
}
108+
if (chain === "solana") {
109+
const lamports = await solanaNativeBalance(cell.address);
110+
return {
111+
status: lamports > 0n ? "alive" : "drifted",
112+
detail: `${(Number(lamports) / 1e9).toFixed(4)} SOL`,
113+
};
114+
}
115+
if (chain === "tron") {
116+
const sun = await tronNativeBalance(cell.address);
117+
return {
118+
status: sun > 0n ? "alive" : "drifted",
119+
detail: `${(Number(sun) / 1e6).toFixed(4)} TRX`,
120+
};
121+
}
122+
if (chain === "bitcoin") {
123+
const txCount = await btcAddressTxCount(cell.address);
124+
return {
125+
status: txCount > 0 ? "alive" : "drifted",
126+
detail: `${txCount} txs (lifetime)`,
127+
};
128+
}
129+
return { status: "drifted", detail: `unknown chain: ${chain}` };
130+
} catch (err) {
131+
const msg = err instanceof Error ? err.message : String(err);
132+
return {
133+
status: isTransientErr(err) ? "inconclusive" : "drifted",
134+
detail: `error: ${msg}`,
135+
};
136+
}
137+
}
138+
139+
async function main() {
140+
const lines = [];
141+
lines.push("# Persona address rotation report");
142+
lines.push("");
143+
lines.push(`Generated: ${new Date().toISOString()}`);
144+
lines.push("");
145+
lines.push("| Chain | Type | Address | Status | Detail |");
146+
lines.push("|-------|------|---------|--------|--------|");
147+
148+
let drifted = 0;
149+
let inconclusive = 0;
150+
let total = 0;
151+
for (const [chain, byType] of Object.entries(DEMO_WALLETS)) {
152+
for (const [type, cell] of Object.entries(byType)) {
153+
if (!cell) continue;
154+
total++;
155+
const { status, detail } = await checkCell(chain, type, cell);
156+
const label =
157+
status === "alive" ? "✓ alive" : status === "inconclusive" ? "⚠ inconclusive" : "✗ drifted";
158+
if (status === "drifted") drifted++;
159+
else if (status === "inconclusive") inconclusive++;
160+
const shortAddr = cell.address.slice(0, 8) + "…" + cell.address.slice(-4);
161+
lines.push(`| ${chain} | ${type} | \`${shortAddr}\` | ${label} | ${detail} |`);
162+
}
163+
}
164+
165+
lines.push("");
166+
const alive = total - drifted - inconclusive;
167+
lines.push(
168+
`**Summary:** ${alive} / ${total} cells alive, ${drifted} drifted, ${inconclusive} inconclusive (transient API errors — re-run later).`,
169+
);
170+
if (drifted > 0) {
171+
lines.push("");
172+
lines.push("Drifted cells need attention — refresh the wallet in `src/demo/personas.ts` and bump `verifiedAt`.");
173+
}
174+
175+
const report = lines.join("\n");
176+
console.log(report);
177+
// Inconclusive results don't fail the workflow — they're rate-limit
178+
// noise, not real drift. A genuine drift requires a confirmed
179+
// empty-balance / no-activity response.
180+
process.exit(drifted > 0 ? 1 : 0);
181+
}
182+
183+
main().catch((err) => {
184+
console.error("verify-personas failed:", err);
185+
process.exit(2);
186+
});

0 commit comments

Comments
 (0)