rspace-online/server/mcp-tools/rwallet.ts

178 lines
5.8 KiB
TypeScript

/**
* MCP tools for rWallet (read-only).
*
* Tools: rwallet_get_safe_balances, rwallet_get_transfers,
* rwallet_get_defi_positions, rwallet_get_crdt_balances
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { enrichWithPrices } from "../../modules/rwallet/lib/price-feed";
import { getDefiPositions } from "../../modules/rwallet/lib/defi-positions";
import { getTokenDoc, listTokenDocs, getAllBalances } from "../token-service";
import { verifyToken } from "../auth";
// Safe Global chain prefix map (subset for MCP)
const CHAIN_MAP: Record<string, string> = {
"1": "mainnet",
"10": "optimism",
"100": "gnosis",
"137": "polygon",
"8453": "base",
"42161": "arbitrum",
"42220": "celo",
"43114": "avalanche",
"56": "bsc",
"324": "zksync",
"11155111": "sepolia",
};
const NATIVE_TOKENS: Record<string, { name: string; symbol: string; decimals: number }> = {
"1": { name: "Ether", symbol: "ETH", decimals: 18 },
"10": { name: "Ether", symbol: "ETH", decimals: 18 },
"100": { name: "xDAI", symbol: "xDAI", decimals: 18 },
"137": { name: "MATIC", symbol: "MATIC", decimals: 18 },
"8453": { name: "Ether", symbol: "ETH", decimals: 18 },
"42161": { name: "Ether", symbol: "ETH", decimals: 18 },
};
function safeApiBase(prefix: string): string {
return `https://api.safe.global/tx-service/${prefix}/api/v1`;
}
export function registerWalletTools(server: McpServer) {
server.tool(
"rwallet_get_safe_balances",
"Get token balances for a Safe wallet address on a specific chain, enriched with USD prices",
{
chain_id: z.string().describe("Chain ID (e.g. '1' for Ethereum, '100' for Gnosis, '8453' for Base)"),
address: z.string().describe("Wallet address (0x...)"),
},
async ({ chain_id, address }) => {
const prefix = CHAIN_MAP[chain_id];
if (!prefix) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain", supported: Object.keys(CHAIN_MAP) }) }] };
}
try {
const res = await fetch(
`${safeApiBase(prefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`,
{ signal: AbortSignal.timeout(15000) },
);
if (!res.ok) {
return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] };
}
const raw = await res.json() as any[];
const nativeToken = NATIVE_TOKENS[chain_id] || { name: "ETH", symbol: "ETH", decimals: 18 };
const data = raw.map((item: any) => ({
tokenAddress: item.tokenAddress,
token: item.token || nativeToken,
balance: item.balance || "0",
fiatBalance: item.fiatBalance || "0",
fiatConversion: item.fiatConversion || "0",
}));
const enriched = (await enrichWithPrices(data, chain_id, { filterSpam: true }))
.filter(b => BigInt(b.balance || "0") > 0n);
return { content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }] };
} catch (e: any) {
return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
}
},
);
server.tool(
"rwallet_get_transfers",
"Get recent token transfers for a Safe wallet address",
{
chain_id: z.string().describe("Chain ID"),
address: z.string().describe("Wallet address (0x...)"),
limit: z.number().optional().describe("Max results (default 20)"),
},
async ({ chain_id, address, limit }) => {
const prefix = CHAIN_MAP[chain_id];
if (!prefix) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain" }) }] };
}
try {
const maxResults = Math.min(limit || 20, 100);
const res = await fetch(
`${safeApiBase(prefix)}/safes/${address}/all-transactions/?limit=${maxResults}&executed=true`,
{ signal: AbortSignal.timeout(15000) },
);
if (!res.ok) {
return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] };
}
const data = await res.json() as any;
return { content: [{ type: "text", text: JSON.stringify(data.results || [], null, 2) }] };
} catch (e: any) {
return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
}
},
);
server.tool(
"rwallet_get_defi_positions",
"Get DeFi protocol positions (Aave, Uniswap, etc.) for an address via Zerion",
{
address: z.string().describe("Wallet address (0x...)"),
},
async ({ address }) => {
const positions = await getDefiPositions(address);
return { content: [{ type: "text", text: JSON.stringify(positions, null, 2) }] };
},
);
server.tool(
"rwallet_get_crdt_balances",
"Get CRDT token balances (cUSDC, $MYCO) for all holders or a specific DID (requires auth token)",
{
token: z.string().describe("JWT auth token (required — exposes DID→balance mapping)"),
did: z.string().optional().describe("Filter by DID (optional — returns all holders if omitted)"),
},
async ({ token, did }) => {
try {
await verifyToken(token);
} catch {
return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid or expired token" }) }], isError: true };
}
const docIds = listTokenDocs();
const result: Array<{
tokenId: string;
name: string;
symbol: string;
decimals: number;
holders: Record<string, { did: string; label: string; balance: number }>;
}> = [];
for (const docId of docIds) {
const tokenId = docId.replace("global:tokens:ledgers:", "");
const doc = getTokenDoc(tokenId);
if (!doc) continue;
let holders = getAllBalances(doc);
if (did) {
const filtered: typeof holders = {};
if (holders[did]) filtered[did] = holders[did];
holders = filtered;
}
result.push({
tokenId,
name: doc.token.name,
symbol: doc.token.symbol,
decimals: doc.token.decimals,
holders,
});
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
}