mirror of
https://github.com/aljazceru/mcp-code.git
synced 2025-12-17 04:35:19 +01:00
feat: Add zap command for sending Bitcoin Lightning tips
- Implemented the `zap` command in the CLI to allow users to send sats to a user, event, or snippet using a NIP-60 wallet. - Created a new `zap.ts` file to handle the command logic and integrated it into the MCP server. - Added wallet balance command to check the balance of a user's wallet. - Enhanced the MCP server to register the new zap command and wallet balance command. - Introduced caching for wallets to optimize performance and reduce redundant network requests. - Updated database schema to include snippets table for storing code snippets. - Improved logging functionality for better debugging and tracking of operations. - Added functionality to save snippets to the database upon retrieval. - Updated project overview documentation to reflect new features and structure. - Refactored existing commands and logic for better modularity and maintainability.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import NDK, { NDKPrivateKeySigner, NDKUser, NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { NDKCashuWallet } from "@nostr-dev-kit/ndk-wallet";
|
||||
import { NDKCashuMintList } from "@nostr-dev-kit/ndk";
|
||||
import { getWallet } from "../lib/cache/wallets.js";
|
||||
import { z } from "zod";
|
||||
import { getUser, saveUser } from "../config.js";
|
||||
import { log } from "../lib/utils/log.js";
|
||||
import { ndk } from "../ndk.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
export async function createPubkey({
|
||||
username,
|
||||
@@ -44,6 +47,27 @@ export async function createPubkey({
|
||||
// Publish the event
|
||||
await event.publish();
|
||||
|
||||
|
||||
// --- Setup Cashu Wallet (NIP-60/NIP-61) ---
|
||||
log(`Setting up Cashu wallet for ${username}...`);
|
||||
|
||||
try {
|
||||
// Use getWallet to retrieve or create a wallet for the new pubkey
|
||||
const wallet = await getWallet(signer.pubkey);
|
||||
|
||||
if (wallet) {
|
||||
log(`Cashu wallet setup complete for ${username}.`);
|
||||
log(` -> P2PK: ${wallet.p2pk}`);
|
||||
log(` -> Mints: ${wallet.mints.join(', ')}`);
|
||||
} else {
|
||||
throw new Error("Failed to create wallet");
|
||||
}
|
||||
} catch (walletError) {
|
||||
console.error(`Failed to set up Cashu wallet for ${username}:`, walletError);
|
||||
// Decide if this should be a fatal error for pubkey creation
|
||||
// For now, log the error and continue
|
||||
}
|
||||
// --- End Cashu Wallet Setup ---
|
||||
// Save the user to the config
|
||||
saveUser(username, {
|
||||
nsec: signer.privateKey,
|
||||
@@ -59,7 +83,7 @@ export async function createPubkey({
|
||||
|
||||
return {
|
||||
user,
|
||||
message: `Created pubkey for ${username} with npub ${user.npub}`,
|
||||
message: `Created pubkey for ${username} with npub ${user.npub} and a NIP-60 Cashu wallet for nutzaps`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -55,12 +55,7 @@ export function addListUsernamesCommand(server: McpServer) {
|
||||
server.tool(
|
||||
"list_usernames",
|
||||
"List all available usernames in the system",
|
||||
{
|
||||
random_string: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Dummy parameter for no-parameter tools"),
|
||||
},
|
||||
{ },
|
||||
async () => {
|
||||
return listUsernames();
|
||||
}
|
||||
|
||||
87
logic/wallet-balance.ts
Normal file
87
logic/wallet-balance.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
import { getUser } from "../config.js";
|
||||
import { getWallet } from "../lib/cache/wallets.js";
|
||||
import { getSigner } from "../lib/nostr/utils.js";
|
||||
import { log } from "../lib/utils/log.js";
|
||||
import { ndk } from "../ndk.js";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
/**
|
||||
* Get the balance for a user's wallet
|
||||
* @param username Username to check balance for
|
||||
* @returns Balance information
|
||||
*/
|
||||
export async function getWalletBalance(username: string): Promise<{
|
||||
balance: number;
|
||||
mint_balances: Record<string, number>;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
// Get the appropriate signer based on username
|
||||
const signer = await getSigner(username);
|
||||
|
||||
// Set the signer for this operation
|
||||
ndk.signer = signer;
|
||||
|
||||
const user = await signer.user();
|
||||
const pubkey = user.pubkey;
|
||||
|
||||
// Get wallet for the pubkey
|
||||
const wallet = await getWallet(pubkey, signer);
|
||||
if (!wallet) {
|
||||
throw new Error(`No wallet found for ${username}`);
|
||||
}
|
||||
|
||||
// Get wallet balance
|
||||
const totalBalance = wallet.balance?.amount || 0;
|
||||
log(`Wallet balance for ${username}: ${totalBalance} sats`);
|
||||
|
||||
// Get individual mint balances
|
||||
const mintBalances: Record<string, number> = {};
|
||||
for (const [mintUrl, balance] of Object.entries(wallet.mintBalances)) {
|
||||
mintBalances[mintUrl] = balance;
|
||||
log(` - ${mintUrl}: ${balance} sats`);
|
||||
}
|
||||
|
||||
return {
|
||||
balance: totalBalance,
|
||||
mint_balances: mintBalances,
|
||||
message: `Wallet balance for ${username}: ${totalBalance} sats`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to get wallet balance: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the wallet-balance command to the MCP server
|
||||
* @param server MCP server to add the command to
|
||||
*/
|
||||
export function addWalletBalanceCommand(server: McpServer) {
|
||||
server.tool(
|
||||
"wallet_balance",
|
||||
"Get the balance of a user's wallet",
|
||||
{
|
||||
username: z.string().describe("Username to check balance for"),
|
||||
},
|
||||
async ({ username }) => {
|
||||
const result = await getWalletBalance(username);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.message,
|
||||
},
|
||||
],
|
||||
// Include detailed balance information in the response
|
||||
wallet_balance: {
|
||||
total: result.balance,
|
||||
mints: result.mint_balances,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
235
logic/zap.ts
Normal file
235
logic/zap.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { NDKZapper } from "@nostr-dev-kit/ndk";
|
||||
import type { CashuPaymentInfo, LnPaymentInfo, NDKEvent, NDKUser, NDKZapDetails } from "@nostr-dev-kit/ndk";
|
||||
import type { NDKCashuWallet } from "@nostr-dev-kit/ndk-wallet";
|
||||
import { ndk } from "../ndk.js";
|
||||
import { getSigner } from "../lib/nostr/utils.js";
|
||||
import { snippetsCache } from "../lib/cache/snippets.js";
|
||||
import { getWallet } from "../lib/cache/wallets.js";
|
||||
import { SNIPPET_KIND } from "../lib/nostr/utils.js";
|
||||
import { toPubkeys } from "../lib/converters/users.js";
|
||||
import { log } from "../lib/utils/log.js";
|
||||
|
||||
/**
|
||||
* Find a snippet by title in the cache
|
||||
* @param title The title to search for
|
||||
* @returns Matching event ID or null if not found
|
||||
*/
|
||||
function findSnippetIdByTitle(title: string): string | null {
|
||||
// First search in the cache (case insensitive)
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
for (const id in snippetsCache) {
|
||||
const snippet = snippetsCache[id];
|
||||
if (snippet && snippet.title.toLowerCase() === normalizedTitle) {
|
||||
return snippet.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a nutzap to an event, snippet, or user
|
||||
* @param amount Amount in sats to send
|
||||
* @param recipient Optional recipient (username, npub, or pubkey) to zap directly
|
||||
* @param eventId Optional event ID to zap
|
||||
* @param title Optional snippet title to look up and zap
|
||||
* @param thanks_message Optional thank you message to include with the zap
|
||||
* @param username Username to zap from (uses wallet associated with this user)
|
||||
* @returns Zap results
|
||||
*/
|
||||
export async function sendZap(
|
||||
amount: number,
|
||||
recipient?: string,
|
||||
eventId?: string,
|
||||
title?: string,
|
||||
thanks_message?: string,
|
||||
username?: string
|
||||
): Promise<{ content: Array<{ type: "text", text: string }> }> {
|
||||
const userToZapFrom = username ?? "main";
|
||||
|
||||
try {
|
||||
// Get the appropriate signer based on username
|
||||
const signer = await getSigner(userToZapFrom);
|
||||
|
||||
// Set the signer for this operation
|
||||
ndk.signer = signer;
|
||||
|
||||
// Get the user's pubkey
|
||||
const user = await signer.user();
|
||||
const senderPubkey = user.pubkey;
|
||||
|
||||
// Determine what to zap based on provided parameters
|
||||
let targetEvent: NDKEvent | undefined;
|
||||
let targetUser: NDKUser | undefined;
|
||||
let zapTarget: NDKEvent | NDKUser;
|
||||
let targetDescription: string;
|
||||
|
||||
// 1. If an event ID is provided, use that
|
||||
if (eventId) {
|
||||
if (eventId.length === 64 && /^[0-9a-f]+$/i.test(eventId)) {
|
||||
const fetchedEvent = await ndk.fetchEvent(eventId);
|
||||
if (!fetchedEvent) {
|
||||
throw new Error(`Event not found with ID: ${eventId}`);
|
||||
}
|
||||
targetEvent = fetchedEvent;
|
||||
targetDescription = `event: ${eventId}`;
|
||||
} else {
|
||||
throw new Error("Invalid event ID format");
|
||||
}
|
||||
}
|
||||
// 2. If a title is provided, look up the matching event
|
||||
else if (title) {
|
||||
// Try to find in cache first
|
||||
const cachedId = findSnippetIdByTitle(title);
|
||||
|
||||
if (cachedId) {
|
||||
const fetchedEvent = await ndk.fetchEvent(cachedId);
|
||||
if (!fetchedEvent) {
|
||||
throw new Error(`Event with cached ID ${cachedId} not found`);
|
||||
}
|
||||
targetEvent = fetchedEvent;
|
||||
targetDescription = `snippet: ${title}`;
|
||||
} else {
|
||||
// Not in cache, search on the network
|
||||
console.log(`Searching for snippet with title: ${title}`);
|
||||
|
||||
// Search for events with this title
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [SNIPPET_KIND as number],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Find the first event where the title matches
|
||||
for (const event of events) {
|
||||
const eventTitle = event.tagValue("title") ?? event.tagValue("name");
|
||||
if (eventTitle && eventTitle.toLowerCase() === title.toLowerCase()) {
|
||||
targetEvent = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetEvent) {
|
||||
throw new Error(`No snippet found with title: ${title}`);
|
||||
}
|
||||
targetDescription = `snippet: ${title}`;
|
||||
}
|
||||
}
|
||||
// 3. If a recipient is provided, get the user
|
||||
else if (recipient) {
|
||||
const pubkeys = toPubkeys(recipient);
|
||||
if (pubkeys.length === 0) {
|
||||
throw new Error(`No user found for identifier: ${recipient}`);
|
||||
}
|
||||
|
||||
targetUser = ndk.getUser({ pubkey: pubkeys[0] });
|
||||
targetDescription = `user: ${recipient} (${pubkeys[0]})`;
|
||||
}
|
||||
// 4. If none are provided, throw an error
|
||||
else {
|
||||
throw new Error("Must provide either an event ID, snippet title, or recipient to zap");
|
||||
}
|
||||
|
||||
// Set the zap target based on what was provided
|
||||
if (targetEvent) {
|
||||
zapTarget = targetEvent;
|
||||
} else if (targetUser) {
|
||||
zapTarget = targetUser;
|
||||
} else {
|
||||
throw new Error("Failed to determine zap target");
|
||||
}
|
||||
|
||||
// Get wallet for this user
|
||||
console.log(`Getting wallet for user: ${senderPubkey}`);
|
||||
const wallet = await getWallet(senderPubkey, signer);
|
||||
|
||||
if (!wallet) {
|
||||
throw new Error("No NIP-60 wallet found for this user. Create a wallet first.");
|
||||
}
|
||||
|
||||
if (wallet.balance === undefined || wallet.balance.amount < amount) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I don't have enough money, damn, man. I have ${wallet.balance?.amount ?? 0} sats.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Send the zap using NDKZapper as explicitly requested
|
||||
log(`Sending ${amount} sats zap to ${targetDescription}`);
|
||||
|
||||
ndk.wallet = wallet;
|
||||
|
||||
// Create an NDKZapper instance
|
||||
// @ts-ignore - NDKZapper constructor signature may differ from types
|
||||
const zapper = new NDKZapper(zapTarget, amount * 1000, 'msat', {
|
||||
nutzapAsFallback: true,
|
||||
comment: thanks_message,
|
||||
cashuPay: (payment: NDKZapDetails<CashuPaymentInfo>) => {
|
||||
log(`Cashu payment: ${JSON.stringify(payment)}`);
|
||||
return wallet.cashuPay(payment);
|
||||
},
|
||||
lnPay: (payment: NDKZapDetails<LnPaymentInfo>) => {
|
||||
log(`LN payment: ${JSON.stringify(payment)}`);
|
||||
return wallet.lnPay(payment);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the zap using NDKZapper as explicitly requested
|
||||
// @ts-ignore - zap method parameters might differ from types
|
||||
const ret = await zapper.zap(['nip61']);
|
||||
log('return'+ ret?.id);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully sent ${amount} sats zap to ${targetDescription}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to send zap: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function addZapCommand(server: McpServer) {
|
||||
// Add zap tool
|
||||
server.tool(
|
||||
"zap",
|
||||
"Send a nutzap to an event, snippet, or user using a NIP-60 wallet. Specify one of event_id, title, or recipient, no need for all of them.",
|
||||
{
|
||||
amount: z
|
||||
.number()
|
||||
.describe("Amount in sats to send"),
|
||||
recipient: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Recipient (username, npub, or pubkey) to zap directly"),
|
||||
event_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Event ID to zap"),
|
||||
title: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Snippet title to look up and zap"),
|
||||
thanks_message: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional thank you message to include with the zap"),
|
||||
username: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Username to zap from (uses wallet associated with this user)"),
|
||||
},
|
||||
async ({ amount, recipient, event_id, title, thanks_message, username }) => {
|
||||
return await sendZap(amount, recipient, event_id, title, thanks_message, username);
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user