178 lines
5.8 KiB
TypeScript
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) }] };
|
|
},
|
|
);
|
|
}
|