Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Repository Guidelines

## Project Structure & Key Modules
The CLI and MCP server are intentionally colocated under the repository root to match Bun's module resolution. `index.ts` is the `cashu` bin entry and wires the workflow defined in `service.ts`, while `cli.ts` holds the command surface and shared argument parsing. `mcp-server.ts` exposes the Model Context Protocol server backed by the same wallet service. Wallet persistence helpers and schema live in `db.ts`. Tests currently ship as `library.test.ts`; feel free to mirror that pattern for new suites. Runtime settings belong in `example.env`, and the default SQLite file (`wallet.sqlite`) is created beside the sources unless `CASHU_WALLET_DB` is set.

## Setup & Core Commands
Run `bun install` before your first build. CLI help is available through `bun run index.ts --help`, and typical wallet flows (e.g., `get-balance`, `mint`) should be exercised through that entrypoint during development. Use `bun run mcp-server.ts` to start the MCP transport for ContextVM integration. Code formatting is enforced via `bun run format`, which wraps Prettier 3 - run it before opening a PR.

## Coding Style & Naming Conventions
Target modern TypeScript with ECMAScript modules (`type: module`). Prefer named exports over default exports when adding shared utilities. Follow Prettier defaults (2-space indentation, 100-character lines) and keep async flows promise-based rather than callback-driven. Files and symbols should describe behavior (`createMintQuote`, `WalletService`), and new commands follow the existing kebab-case CLI naming.

## Testing Guidelines
Write tests in Bun's test runner; co-locate them next to the feature using the `*.test.ts` suffix. Run `bun test` for the full suite and `bun test path/to/file.test.ts` when iterating. New features that touch wallet persistence should include at least one integration-style check covering `db.ts` interactions.

## Commit & Pull Request Guidelines
Recent history uses Conventional Commit types with optional scopes (`feat(wallet):`, `chore:`). Match that format, describe the behavior change, and keep the body focused on rationale. Pull requests should link any relevant issues, enumerate testing performed, and include screenshots or CLI transcripts only when they clarify user-facing changes.

## Security & Configuration Notes
Never commit real mint URLs or wallet secrets; rely on `.env` files and document required entries in `example.env`. Confirm that new configuration knobs are read through strongly typed Zod schemas when applicable, and note any migration steps for existing wallets in the PR description.
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,110 @@ The wallet now supports the full Nostr Wallet Connect (NWC) API specification, m
- `lookup_invoice` - Check invoice status and details
- `pay_invoice` - Pay Lightning invoices

## ContextVM MCP over Nostr

The MCP server (`bun run mcp-server.ts`) exposes the wallet through ContextVM's Nostr transport. If you are not using the SDK, craft Nostr events manually as JSON-RPC 2.0 requests.

### Prerequisites
- Obtain the server hex pubkey derived from `SERVER_PRIVATE_KEY` and share relay URLs (`NOSTR_RELAYS`, defaults to `wss://relay.contextvm.org`).
- Use a standard Nostr secp256k1 keypair (32-byte hex) on the client, publish to the same relays, and tag requests with `["p", "<server_pubkey>"]`.
- For encryption, follow [CEP-4](https://contextvm.org/spec/ceps/cep-4/): serialize the MCP payload as a `kind` 25910 event, encrypt it with NIP-44 to the server pubkey, then embed the ciphertext inside a NIP-59 gift wrap (`kind` 1059). The inline examples below show plaintext 25910 events for clarity, but production deployments SHOULD ship the gift-wrapped form.
- `initialize` is optional—call it if you need the server’s capability manifest, otherwise you can send `tools/call` right away.

### Event Template
```json
{
"kind": 25910,
"created_at": 1732137600,
"pubkey": "<client_pubkey_hex>",
"tags": [["p", "<server_pubkey_hex>"]],
"content": "{... JSON-RPC payload ...}",
"id": "<calculated_id>",
"sig": "<event_signature>"
}
```
Responses repeat the format with `pubkey` set to the server key and include an `["e", "<request_id>"]` tag to correlate the call.

### initialize
Request payload:
```json
{"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"Example Client","version":"0.1.0"}}}
```
Sample response payload:
```json
{"jsonrpc":"2.0","id":"init-1","result":{"protocolVersion":"2025-06-18","serverInfo":{"name":"cashu-wallet","version":"1.0.0"},"capabilities":{"tools":{"cashu-wallet:get_balance":{},"cashu-wallet:pay_invoice":{},"cashu-wallet:make_invoice":{},"cashu-wallet:send_ecash":{},"cashu-wallet:lookup_invoice":{},"cashu-wallet:get_info":{}}}}}
```

### tools/call examples
All tool invocations use the same event envelope and set `method` to `"tools/call"`. You may issue them immediately after authentication or after an `initialize` handshake, depending on your client.

#### get_balance
Request payload:
```json
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"get_balance","arguments":{}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-1","result":{"content":[{"type":"text","text":"{\"result_type\":\"get_balance\",\"result\":{\"balance\":42000}}"}]}}
```
`balance` represents total spendable + pending balance in sats.

#### pay_invoice
Request payload:
```json
{"jsonrpc":"2.0","id":"call-2","method":"tools/call","params":{"name":"pay_invoice","arguments":{"invoice":"lnbc1exampleinvoice"}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-2","result":{"content":[{"type":"text","text":"{\"result_type\":\"pay_invoice\",\"result\":{\"preimage\":\"5f...ae\",\"fees_paid\":200}}"}]}}
```
`preimage` is included once the mint confirms payment; `fees_paid` is denominated in sats.

#### make_invoice
Request payload:
```json
{"jsonrpc":"2.0","id":"call-3","method":"tools/call","params":{"name":"make_invoice","arguments":{"amount":5000,"description":"Top up wallet"}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-3","result":{"content":[{"type":"text","text":"{\"result_type\":\"make_invoice\",\"result\":{\"type\":\"incoming\",\"state\":\"pending\",\"invoice\":\"lnbc1mintquote\",\"description\":\"Top up wallet\",\"description_hash\":\"\",\"payment_hash\":\"quote_123\",\"amount\":5000,\"created_at\":1732137600,\"expires_at\":1732141200}}"}]}}
```
`payment_hash` maps to the mint quote id you later pass to `lookup_invoice`.

#### send_ecash
Request payload:
```json
{"jsonrpc":"2.0","id":"call-4","method":"tools/call","params":{"name":"send_ecash","arguments":{"amount":1000}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-4","result":{"content":[{"type":"text","text":"{\"sentAmount\":1000,\"keepAmount\":9000,\"cashuToken\":\"cashuA1B2...\",\"proofCount\":2,\"timestamp\":\"2024-11-20T12:00:00.000Z\"}"}]}}
```
`cashuToken` is a base64 Cashu token string ready to hand to a recipient; `keepAmount` is the leftover balance retained locally.

#### lookup_invoice
Request payload:
```json
{"jsonrpc":"2.0","id":"call-5","method":"tools/call","params":{"name":"lookup_invoice","arguments":{"payment_hash":"quote_123"}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-5","result":{"content":[{"type":"text","text":"{\"result_type\":\"lookup_invoice\",\"result\":{\"isPaid\":true,\"isIssued\":false,\"payment_hash\":\"quote_123\",\"amount\":5000}}"}]}}
```
`isPaid` reflects whether the Lightning invoice was settled; `isIssued` becomes `true` after proofs are minted and credited.

#### get_info
Request payload:
```json
{"jsonrpc":"2.0","id":"call-6","method":"tools/call","params":{"name":"get_info","arguments":{}}}
```
Response payload:
```json
{"jsonrpc":"2.0","id":"call-6","result":{"content":[{"type":"text","text":"{\"result_type\":\"get_info\",\"result\":{\"callback\":\"https://example.org/lnurl\",\"maxSendable\":1000000000,\"minSendable\":1000,\"metadata\":\"[]\",\"tag\":\"payRequest\"}}"}]}}
```

Verify the server signature on every response event, parse the JSON-RPC payload, then JSON-decode the inner `text` string to recover the domain-specific result. Reuse unique `id` values per request to simplify matching with the correlated `e` tag.

## Example Workflow

1. **Create a new wallet** (automatically created on first use):
Expand Down
69 changes: 62 additions & 7 deletions mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,59 @@ function formatNWCError(error: string, message?: string): NWCError {
};
}

type ToolDefinition = Parameters<typeof server.registerTool>[1];

function registerToolWithLogging(
name: string,
definition: ToolDefinition,
handler: (input: unknown, context: unknown) => Promise<any>,
) {
server.registerTool(
name,
definition,
async (input, context) => {
const start = Date.now();
const contextDetails =
context && typeof context === "object"
? {
source: (context as { source?: unknown }).source,
connectionId: (context as { connectionId?: unknown }).connectionId,
invocationId: (context as { invocationId?: unknown }).invocationId,
}
: undefined;

console.log("[MCP] Incoming tool call", {
tool: name,
input,
context: contextDetails,
});

try {
const result = await handler(input, context);

console.log("[MCP] Completed tool call", {
tool: name,
durationMs: Date.now() - start,
});

return result;
} catch (error) {
console.error("[MCP] Tool call failed", {
tool: name,
durationMs: Date.now() - start,
error: error instanceof Error ? error.message : error,
});

throw error;
}
},
);
}

// NWC API Methods

// get_balance - NWC format
server.registerTool(
registerToolWithLogging(
"get_balance",
{
title: "Get Balance (NWC)",
Expand Down Expand Up @@ -87,7 +136,7 @@ server.registerTool(
);

// pay_invoice - NWC format
server.registerTool(
registerToolWithLogging(
"pay_invoice",
{
title: "Pay Invoice (NWC)",
Expand Down Expand Up @@ -133,7 +182,7 @@ server.registerTool(
);

// make_invoice - NWC format (equivalent to create-mint-quote)
server.registerTool(
registerToolWithLogging(
"make_invoice",
{
title: "Create Invoice (NWC)",
Expand Down Expand Up @@ -188,7 +237,7 @@ server.registerTool(
);

// Send eCash Tool
server.registerTool(
registerToolWithLogging(
"send_ecash",
{
title: "Send eCash",
Expand Down Expand Up @@ -226,7 +275,7 @@ server.registerTool(
);

// lookup_invoice - NWC format (equivalent to check-mint-quote)
server.registerTool(
registerToolWithLogging(
"lookup_invoice",
{
title: "Lookup Invoice (NWC)",
Expand Down Expand Up @@ -281,7 +330,7 @@ server.registerTool(
);

// get_info - NWC format
server.registerTool(
registerToolWithLogging(
"get_info",
{
title: "Get Info (NWC)",
Expand Down Expand Up @@ -340,7 +389,11 @@ const allowedPublicKeys = process.env.ALLOWED_PUBLIC_KEYS
.filter((key) => key.length > 0)
: undefined;

console.log("Allowed public keys:", allowedPublicKeys);
console.log("[MCP] Server configuration", {
relayUrls,
hasServerPrivateKey: Boolean(serverPrivateKey),
allowedPublicKeys,
});

const transport = new NostrServerTransport({
relayHandler: new ApplesauceRelayPool(relayUrls),
Expand Down Expand Up @@ -378,4 +431,6 @@ process.on("SIGTERM", () => {
process.exit(0);
});

console.log("[MCP] Connecting to transport...");
await server.connect(transport);
console.log("[MCP] MCP server ready");