mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
feat: add NIP-60/61 and applesauce-wallet skills
Add comprehensive skill documentation for: - NIP-60: Cashu wallets on Nostr (kind:17375, 7375, 7376, 7374) - NIP-61: Nutzaps - P2PK-locked Cashu payments (kind:9321, 10019) - applesauce-wallet: hzrd149's package for NIP-60 implementation including actions, casts, helpers, and integration patterns
This commit is contained in:
586
.claude/skills/applesauce-wallet/SKILL.md
Normal file
586
.claude/skills/applesauce-wallet/SKILL.md
Normal file
@@ -0,0 +1,586 @@
|
||||
---
|
||||
name: applesauce-wallet
|
||||
description: This skill should be used when building NIP-60 Cashu wallets using the applesauce-wallet package. Covers wallet actions (CreateWallet, UnlockWallet, ReceiveNutzaps, TokensOperation), casts (Wallet, WalletToken, Nutzap), helpers (IndexedDBCouch), and integration with the applesauce ecosystem.
|
||||
---
|
||||
|
||||
# applesauce-wallet Package
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides expert-level assistance with `applesauce-wallet`, a TypeScript package for building NIP-60 Cashu wallets and NIP-61 nutzaps on Nostr. Part of the applesauce ecosystem by hzrd149.
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate this skill when:
|
||||
- Building a NIP-60 wallet with applesauce
|
||||
- Implementing nutzap sending/receiving
|
||||
- Managing Cashu tokens on Nostr
|
||||
- Working with wallet actions and casts
|
||||
- Integrating wallet functionality into a Nostr client
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install applesauce-wallet
|
||||
# Also need related packages:
|
||||
npm install applesauce-core applesauce-actions applesauce-react @cashu/cashu-ts
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
|
||||
### Imports
|
||||
|
||||
```typescript
|
||||
// Actions - wallet operations
|
||||
import {
|
||||
CreateWallet,
|
||||
UnlockWallet,
|
||||
ReceiveNutzaps,
|
||||
ReceiveToken,
|
||||
ConsolidateTokens,
|
||||
RecoverFromCouch,
|
||||
SetWalletMints,
|
||||
SetWalletRelays,
|
||||
AddNutzapInfoMint,
|
||||
RemoveNutzapInfoMint,
|
||||
TokensOperation,
|
||||
} from "applesauce-wallet/actions";
|
||||
|
||||
// Casts - reactive data wrappers
|
||||
import {
|
||||
Wallet,
|
||||
WalletToken,
|
||||
WalletHistory,
|
||||
Nutzap,
|
||||
} from "applesauce-wallet/casts";
|
||||
|
||||
// Helpers - utilities and constants
|
||||
import {
|
||||
getWalletRelays,
|
||||
IndexedDBCouch,
|
||||
WALLET_KIND,
|
||||
WALLET_TOKEN_KIND,
|
||||
WALLET_HISTORY_KIND,
|
||||
NUTZAP_KIND,
|
||||
} from "applesauce-wallet/helpers";
|
||||
|
||||
// IMPORTANT: Import casts to enable user.wallet$ property
|
||||
import "applesauce-wallet/casts";
|
||||
```
|
||||
|
||||
## Event Kind Constants
|
||||
|
||||
```typescript
|
||||
const WALLET_KIND = 17375; // Wallet configuration
|
||||
const WALLET_TOKEN_KIND = 7375; // Token storage
|
||||
const WALLET_HISTORY_KIND = 7376; // Spending history
|
||||
const NUTZAP_KIND = 9321; // Nutzap events
|
||||
const NUTZAP_INFO_KIND = 10019; // Nutzap recipient info
|
||||
```
|
||||
|
||||
## Core Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```typescript
|
||||
import { EventStore, EventFactory } from "applesauce-core";
|
||||
import { ActionRunner } from "applesauce-actions";
|
||||
import { RelayPool } from "applesauce-relay";
|
||||
import { ProxySigner } from "applesauce-accounts";
|
||||
import { persistEncryptedContent } from "applesauce-common/helpers";
|
||||
import { IndexedDBCouch } from "applesauce-wallet/helpers";
|
||||
|
||||
// Create singletons
|
||||
const eventStore = new EventStore();
|
||||
const pool = new RelayPool();
|
||||
const couch = new IndexedDBCouch();
|
||||
|
||||
// Setup encrypted content persistence
|
||||
const storage$ = new BehaviorSubject<SecureStorage | null>(null);
|
||||
persistEncryptedContent(eventStore, storage$.pipe(defined()));
|
||||
|
||||
// Create action runner
|
||||
const factory = new EventFactory({
|
||||
signer: new ProxySigner(signer$.pipe(defined()))
|
||||
});
|
||||
|
||||
const actions = new ActionRunner(eventStore, factory, async (event) => {
|
||||
// Publish handler - determine relays and publish
|
||||
const relays = getPublishRelays(event);
|
||||
await pool.publish(relays, event);
|
||||
});
|
||||
```
|
||||
|
||||
## Wallet Casts
|
||||
|
||||
### Wallet
|
||||
|
||||
The main wallet cast provides reactive observables for wallet state.
|
||||
|
||||
```typescript
|
||||
import { castUser } from "applesauce-common/casts";
|
||||
import "applesauce-wallet/casts"; // Enable wallet$ property
|
||||
|
||||
const user = castUser(pubkey, eventStore);
|
||||
|
||||
// Access wallet
|
||||
const wallet = use$(user.wallet$);
|
||||
|
||||
// Wallet properties (all are observables)
|
||||
wallet.unlocked // boolean - whether wallet is decrypted
|
||||
wallet.mints$ // string[] - configured mints
|
||||
wallet.relays$ // string[] - wallet relays
|
||||
wallet.balance$ // { [mint: string]: number } - balance by mint
|
||||
wallet.tokens$ // WalletToken[] - all token events
|
||||
wallet.received$ // string[] - received nutzap IDs
|
||||
```
|
||||
|
||||
### WalletToken
|
||||
|
||||
Represents a kind:7375 token event.
|
||||
|
||||
```typescript
|
||||
interface WalletToken {
|
||||
id: string; // Event ID
|
||||
event: NostrEvent; // Raw event
|
||||
unlocked: boolean; // Is content decrypted
|
||||
mint?: string; // Mint URL (when unlocked)
|
||||
proofs?: Proof[]; // Cashu proofs (when unlocked)
|
||||
seen?: string[]; // Relays where seen
|
||||
|
||||
// Observables
|
||||
meta$: Observable<TokenMeta>; // Parsed metadata
|
||||
amount$: Observable<number>; // Total amount
|
||||
}
|
||||
```
|
||||
|
||||
### WalletHistory
|
||||
|
||||
Represents a kind:7376 history event.
|
||||
|
||||
```typescript
|
||||
interface WalletHistory {
|
||||
id: string;
|
||||
event: NostrEvent;
|
||||
unlocked: boolean;
|
||||
|
||||
// Observable
|
||||
meta$: Observable<{
|
||||
direction: "in" | "out";
|
||||
amount: number;
|
||||
mint?: string;
|
||||
unit?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Nutzap
|
||||
|
||||
Represents a kind:9321 nutzap event.
|
||||
|
||||
```typescript
|
||||
interface Nutzap {
|
||||
id: string;
|
||||
event: NostrEvent;
|
||||
amount: number; // Total nutzap amount
|
||||
mint?: string; // Mint URL
|
||||
comment?: string; // Message content
|
||||
createdAt: Date; // Event timestamp
|
||||
|
||||
// Cast references
|
||||
sender: User; // Sender user cast
|
||||
|
||||
// Observables
|
||||
zapped$: Observable<NostrEvent | undefined>; // Referenced event
|
||||
}
|
||||
```
|
||||
|
||||
## User Extensions
|
||||
|
||||
When you import `"applesauce-wallet/casts"`, the User cast is extended with:
|
||||
|
||||
```typescript
|
||||
// Available on User cast
|
||||
user.wallet$ // Observable<Wallet | undefined>
|
||||
user.nutzap$ // Observable<NutzapInfo | undefined>
|
||||
|
||||
// NutzapInfo structure
|
||||
interface NutzapInfo {
|
||||
mints: Array<{ mint: string; units: string[] }>;
|
||||
relays?: string[];
|
||||
pubkey?: string; // P2PK pubkey for receiving
|
||||
}
|
||||
```
|
||||
|
||||
## Wallet Actions
|
||||
|
||||
### CreateWallet
|
||||
|
||||
Creates a new NIP-60 wallet.
|
||||
|
||||
```typescript
|
||||
import { CreateWallet } from "applesauce-wallet/actions";
|
||||
import { generateSecretKey } from "nostr-tools";
|
||||
|
||||
await actions.run(CreateWallet, {
|
||||
mints: ["https://mint1.example.com", "https://mint2.example.com"],
|
||||
privateKey: generateSecretKey(), // For nutzap reception (optional)
|
||||
relays: ["wss://relay1.example.com", "wss://relay2.example.com"]
|
||||
});
|
||||
```
|
||||
|
||||
### UnlockWallet
|
||||
|
||||
Decrypts wallet content using NIP-44.
|
||||
|
||||
```typescript
|
||||
import { UnlockWallet } from "applesauce-wallet/actions";
|
||||
|
||||
await actions.run(UnlockWallet, {
|
||||
history: true, // Also unlock history events
|
||||
tokens: true // Also unlock token events
|
||||
});
|
||||
```
|
||||
|
||||
### ReceiveToken
|
||||
|
||||
Receives a Cashu token and adds it to the wallet.
|
||||
|
||||
```typescript
|
||||
import { ReceiveToken } from "applesauce-wallet/actions";
|
||||
import { getDecodedToken } from "@cashu/cashu-ts";
|
||||
|
||||
const token = getDecodedToken(tokenString);
|
||||
await actions.run(ReceiveToken, token, { couch });
|
||||
```
|
||||
|
||||
### ReceiveNutzaps
|
||||
|
||||
Claims one or more nutzap events.
|
||||
|
||||
```typescript
|
||||
import { ReceiveNutzaps } from "applesauce-wallet/actions";
|
||||
|
||||
// Single nutzap
|
||||
await actions.run(ReceiveNutzaps, nutzapEvent, couch);
|
||||
|
||||
// Multiple nutzaps
|
||||
const nutzapEvents = nutzaps.map(n => n.event);
|
||||
await actions.run(ReceiveNutzaps, nutzapEvents, couch);
|
||||
```
|
||||
|
||||
### TokensOperation
|
||||
|
||||
Generic operation on wallet tokens. Used for sending, swapping, etc.
|
||||
|
||||
```typescript
|
||||
import { TokensOperation } from "applesauce-wallet/actions";
|
||||
|
||||
await actions.run(
|
||||
TokensOperation,
|
||||
amount, // Amount to operate on
|
||||
async ({ selectedProofs, mint, cashuWallet }) => {
|
||||
// cashuWallet is a @cashu/cashu-ts Wallet instance
|
||||
const { keep, send } = await cashuWallet.ops
|
||||
.send(amount, selectedProofs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
change: keep.length > 0 ? keep : undefined
|
||||
};
|
||||
},
|
||||
{ mint: selectedMint, couch } // Options
|
||||
);
|
||||
```
|
||||
|
||||
### SetWalletMints
|
||||
|
||||
Updates the wallet's configured mints.
|
||||
|
||||
```typescript
|
||||
import { SetWalletMints } from "applesauce-wallet/actions";
|
||||
|
||||
const newMints = ["https://mint1.com", "https://mint2.com"];
|
||||
await actions.run(SetWalletMints, newMints);
|
||||
```
|
||||
|
||||
### SetWalletRelays
|
||||
|
||||
Updates the wallet's relays.
|
||||
|
||||
```typescript
|
||||
import { SetWalletRelays } from "applesauce-wallet/actions";
|
||||
|
||||
const newRelays = ["wss://relay1.com", "wss://relay2.com"];
|
||||
await actions.run(SetWalletRelays, newRelays);
|
||||
```
|
||||
|
||||
### AddNutzapInfoMint / RemoveNutzapInfoMint
|
||||
|
||||
Manages mints in the user's kind:10019 nutzap info.
|
||||
|
||||
```typescript
|
||||
import { AddNutzapInfoMint, RemoveNutzapInfoMint } from "applesauce-wallet/actions";
|
||||
|
||||
// Add mint to nutzap config
|
||||
await actions.run(AddNutzapInfoMint, {
|
||||
url: "https://mint.example.com",
|
||||
units: ["sat"]
|
||||
});
|
||||
|
||||
// Remove mint from nutzap config
|
||||
await actions.run(RemoveNutzapInfoMint, "https://mint.example.com");
|
||||
```
|
||||
|
||||
### ConsolidateTokens
|
||||
|
||||
Merges multiple small tokens into fewer larger ones.
|
||||
|
||||
```typescript
|
||||
import { ConsolidateTokens } from "applesauce-wallet/actions";
|
||||
|
||||
await actions.run(ConsolidateTokens, {
|
||||
unlockTokens: true,
|
||||
couch
|
||||
});
|
||||
```
|
||||
|
||||
### RecoverFromCouch
|
||||
|
||||
Recovers tokens stored in the couch during failed operations.
|
||||
|
||||
```typescript
|
||||
import { RecoverFromCouch } from "applesauce-wallet/actions";
|
||||
|
||||
await actions.run(RecoverFromCouch, couch);
|
||||
```
|
||||
|
||||
## IndexedDBCouch
|
||||
|
||||
The "couch" is temporary storage for proofs during operations that could fail.
|
||||
|
||||
```typescript
|
||||
import { IndexedDBCouch } from "applesauce-wallet/helpers";
|
||||
|
||||
const couch = new IndexedDBCouch();
|
||||
|
||||
// Used in operations
|
||||
await actions.run(ReceiveToken, token, { couch });
|
||||
await actions.run(TokensOperation, amount, callback, { couch });
|
||||
```
|
||||
|
||||
**Why use a couch?**
|
||||
- Prevents losing proofs if app crashes mid-operation
|
||||
- Enables recovery of stuck transactions
|
||||
- Provides atomic operation semantics
|
||||
|
||||
## Subscribing to Wallet Events
|
||||
|
||||
```typescript
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { relaySet } from "applesauce-core/helpers";
|
||||
|
||||
// Subscribe to wallet-related events
|
||||
use$(() => {
|
||||
const relays = relaySet(walletRelays, userOutboxes);
|
||||
if (relays.length === 0) return undefined;
|
||||
|
||||
return pool.subscription(
|
||||
relays,
|
||||
[
|
||||
// Wallet events
|
||||
{
|
||||
kinds: [WALLET_KIND, WALLET_TOKEN_KIND, WALLET_HISTORY_KIND],
|
||||
authors: [user.pubkey]
|
||||
},
|
||||
// Token deletions
|
||||
{
|
||||
kinds: [kinds.EventDeletion],
|
||||
"#k": [String(WALLET_TOKEN_KIND)]
|
||||
}
|
||||
],
|
||||
{ eventStore }
|
||||
);
|
||||
}, [walletRelays, userOutboxes, user.pubkey]);
|
||||
|
||||
// Subscribe to incoming nutzaps
|
||||
use$(() => {
|
||||
const relays = relaySet(nutzapRelays, userInboxes);
|
||||
if (relays.length === 0) return undefined;
|
||||
|
||||
return pool.subscription(
|
||||
relays,
|
||||
{ kinds: [NUTZAP_KIND], "#p": [user.pubkey] },
|
||||
{ eventStore }
|
||||
);
|
||||
}, [nutzapRelays, userInboxes, user.pubkey]);
|
||||
```
|
||||
|
||||
## Complete Send Example
|
||||
|
||||
```typescript
|
||||
import { TokensOperation } from "applesauce-wallet/actions";
|
||||
import { getEncodedToken } from "@cashu/cashu-ts";
|
||||
|
||||
async function sendTokens(amount: number, selectedMint?: string) {
|
||||
let createdToken: string | null = null;
|
||||
|
||||
await actions.run(
|
||||
TokensOperation,
|
||||
amount,
|
||||
async ({ selectedProofs, mint, cashuWallet }) => {
|
||||
const { keep, send } = await cashuWallet.ops
|
||||
.send(amount, selectedProofs)
|
||||
.run();
|
||||
|
||||
// Encode token for sharing
|
||||
createdToken = getEncodedToken({
|
||||
mint,
|
||||
proofs: send,
|
||||
unit: "sat"
|
||||
});
|
||||
|
||||
return {
|
||||
change: keep.length > 0 ? keep : undefined
|
||||
};
|
||||
},
|
||||
{ mint: selectedMint, couch }
|
||||
);
|
||||
|
||||
return createdToken;
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Receive Example
|
||||
|
||||
```typescript
|
||||
import { ReceiveToken } from "applesauce-wallet/actions";
|
||||
import { getDecodedToken } from "@cashu/cashu-ts";
|
||||
|
||||
async function receiveToken(tokenString: string) {
|
||||
const token = getDecodedToken(tokenString.trim());
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Failed to decode token");
|
||||
}
|
||||
|
||||
await actions.run(ReceiveToken, token, { couch });
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Unlock Pattern
|
||||
|
||||
```typescript
|
||||
const unlocking = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (unlocking.current || !autoUnlock) return;
|
||||
|
||||
let needsUnlock = false;
|
||||
|
||||
if (wallet && !wallet.unlocked) needsUnlock = true;
|
||||
if (tokens?.some(t => !t.unlocked)) needsUnlock = true;
|
||||
if (history?.some(h => !h.unlocked)) needsUnlock = true;
|
||||
|
||||
if (needsUnlock) {
|
||||
unlocking.current = true;
|
||||
actions
|
||||
.run(UnlockWallet, { history: true, tokens: true })
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
unlocking.current = false;
|
||||
});
|
||||
}
|
||||
}, [wallet?.unlocked, tokens?.length, history?.length, autoUnlock]);
|
||||
```
|
||||
|
||||
## Nutzap Timeline
|
||||
|
||||
```typescript
|
||||
import { castTimelineStream } from "applesauce-common/observable";
|
||||
import { Nutzap } from "applesauce-wallet/casts";
|
||||
|
||||
const nutzaps = use$(
|
||||
() => eventStore
|
||||
.timeline({ kinds: [NUTZAP_KIND], "#p": [user.pubkey] })
|
||||
.pipe(castTimelineStream(Nutzap, eventStore)),
|
||||
[user.pubkey]
|
||||
);
|
||||
|
||||
// Filter unclaimed nutzaps
|
||||
const unclaimed = useMemo(() => {
|
||||
if (!nutzaps || !received) return nutzaps || [];
|
||||
return nutzaps.filter(n => !received.includes(n.id));
|
||||
}, [nutzaps, received]);
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### getWalletRelays
|
||||
|
||||
Extract relay URLs from a wallet event.
|
||||
|
||||
```typescript
|
||||
import { getWalletRelays } from "applesauce-wallet/helpers";
|
||||
|
||||
const wallet = await firstValueFrom(
|
||||
eventStore.replaceable(WALLET_KIND, pubkey)
|
||||
);
|
||||
const relays = wallet ? getWalletRelays(wallet) : [];
|
||||
```
|
||||
|
||||
## Integration with cashu-ts
|
||||
|
||||
The wallet actions use `@cashu/cashu-ts` internally. In `TokensOperation`:
|
||||
|
||||
```typescript
|
||||
async ({ selectedProofs, mint, cashuWallet }) => {
|
||||
// cashuWallet is a @cashu/cashu-ts Wallet instance
|
||||
// Already initialized and connected to the mint
|
||||
|
||||
// Use cashu-ts WalletOps API
|
||||
const { keep, send } = await cashuWallet.ops
|
||||
.send(amount, selectedProofs)
|
||||
.run();
|
||||
|
||||
// Return change proofs to be stored
|
||||
return { change: keep };
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await actions.run(ReceiveNutzaps, nutzapEvents, couch);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
console.error("Failed to receive nutzaps:", err.message);
|
||||
}
|
||||
// Tokens are safely stored in couch
|
||||
// Can recover with RecoverFromCouch action
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use couch**: Pass `couch` to operations that modify tokens
|
||||
2. **Unlock before operations**: Check `wallet.unlocked` before actions
|
||||
3. **Handle recovery**: Periodically run `RecoverFromCouch` on app start
|
||||
4. **Subscribe to events**: Keep wallet data synced via subscriptions
|
||||
5. **Check mint support**: Verify mint is in wallet config before operations
|
||||
|
||||
## Related Packages
|
||||
|
||||
- `applesauce-core` - Core event store and utilities
|
||||
- `applesauce-actions` - ActionRunner for executing actions
|
||||
- `applesauce-react` - React hooks like `use$`
|
||||
- `applesauce-common` - Common helpers and casts
|
||||
- `@cashu/cashu-ts` - Cashu protocol implementation
|
||||
|
||||
## Official Resources
|
||||
|
||||
- [Applesauce Documentation](https://hzrd149.github.io/applesauce/)
|
||||
- [GitHub Repository](https://github.com/hzrd149/applesauce)
|
||||
- [NIP-60 Specification](https://github.com/nostr-protocol/nips/blob/master/60.md)
|
||||
- [NIP-61 Specification](https://github.com/nostr-protocol/nips/blob/master/61.md)
|
||||
369
.claude/skills/nip-60-61/SKILL.md
Normal file
369
.claude/skills/nip-60-61/SKILL.md
Normal file
@@ -0,0 +1,369 @@
|
||||
---
|
||||
name: nip-60-61
|
||||
description: This skill should be used when implementing NIP-60 Cashu wallets or NIP-61 nutzaps on Nostr. Covers the wallet event structure (kind:17375), token events (kind:7375), spending history (kind:7376), nutzap events (kind:9321), nutzap info (kind:10019), P2PK locking, and wallet workflows.
|
||||
---
|
||||
|
||||
# NIP-60 & NIP-61: Cashu Wallets and Nutzaps
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides expert-level assistance with NIP-60 (Cashu Wallets) and NIP-61 (Nutzaps) - the Nostr protocols for storing ecash wallets on relays and sending P2PK-locked Cashu payments.
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate this skill when:
|
||||
- Implementing a NIP-60 compatible Cashu wallet
|
||||
- Sending or receiving nutzaps (NIP-61)
|
||||
- Working with encrypted wallet events
|
||||
- Managing ecash proofs on Nostr relays
|
||||
- Implementing P2PK-locked Cashu transfers
|
||||
- Building nutzap-enabled Nostr clients
|
||||
|
||||
## NIP-60: Cashu Wallets
|
||||
|
||||
### Overview
|
||||
|
||||
NIP-60 enables Cashu-based wallets with information stored on Nostr relays, providing:
|
||||
- **Ease of use**: Users can receive funds without external accounts
|
||||
- **Interoperability**: Wallet follows users across applications
|
||||
- **Privacy**: Content encrypted with NIP-44
|
||||
|
||||
### Event Kinds
|
||||
|
||||
| Kind | Name | Purpose |
|
||||
|------|------|---------|
|
||||
| **17375** | Wallet | Replaceable wallet configuration |
|
||||
| **7375** | Token | Unspent proof storage |
|
||||
| **7376** | History | Spending/receiving history |
|
||||
| **7374** | Quote | Pending mint quotes |
|
||||
| **10019** | Nutzap Info | Recipient preferences (NIP-61) |
|
||||
|
||||
### Wallet Event (kind:17375)
|
||||
|
||||
Replaceable event storing wallet configuration. Content is NIP-44 encrypted.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 17375,
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
"tags": [
|
||||
["mint", "https://mint1.example.com", "sat"],
|
||||
["mint", "https://mint2.example.com", "sat"],
|
||||
["relay", "wss://relay1.example.com"],
|
||||
["relay", "wss://relay2.example.com"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Encrypted content structure:**
|
||||
```json
|
||||
{
|
||||
"privkey": "<hex private key for P2PK>"
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `privkey` is a **separate key** exclusively for the wallet, NOT the user's Nostr private key. Used for:
|
||||
- Receiving NIP-61 nutzaps (P2PK-locked tokens)
|
||||
- Unlocking tokens locked to the wallet's pubkey
|
||||
|
||||
### Token Event (kind:7375)
|
||||
|
||||
Records unspent Cashu proofs. Multiple events can exist per mint.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 7375,
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
"tags": []
|
||||
}
|
||||
```
|
||||
|
||||
**Encrypted content structure:**
|
||||
```json
|
||||
{
|
||||
"mint": "https://mint.example.com",
|
||||
"unit": "sat",
|
||||
"proofs": [
|
||||
{
|
||||
"id": "009a1f293253e41e",
|
||||
"amount": 8,
|
||||
"secret": "secret_string",
|
||||
"C": "02abc..."
|
||||
}
|
||||
],
|
||||
"del": ["<token_event_id_1>", "<token_event_id_2>"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `mint` | Mint URL |
|
||||
| `unit` | Currency unit (default: "sat") |
|
||||
| `proofs` | Array of unspent Cashu proofs |
|
||||
| `del` | IDs of deleted/spent token events |
|
||||
|
||||
### Spending History Event (kind:7376)
|
||||
|
||||
Optional events documenting transactions.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 7376,
|
||||
"content": "<NIP-44 encrypted JSON>",
|
||||
"tags": [
|
||||
["e", "<created_token_id>", "", "created"],
|
||||
["e", "<destroyed_token_id>", "", "destroyed"],
|
||||
["e", "<redeemed_nutzap_id>", "", "redeemed"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Encrypted content structure:**
|
||||
```json
|
||||
{
|
||||
"direction": "in",
|
||||
"amount": 100,
|
||||
"unit": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
**Direction values:**
|
||||
- `"in"` - Received tokens
|
||||
- `"out"` - Sent tokens
|
||||
|
||||
**Tag markers:**
|
||||
- `created` - New token event created
|
||||
- `destroyed` - Token event consumed/deleted
|
||||
- `redeemed` - Nutzap event redeemed (leave unencrypted)
|
||||
|
||||
### Quote Event (kind:7374)
|
||||
|
||||
Tracks pending mint quotes for deposits.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 7374,
|
||||
"content": "<NIP-44 encrypted quote_id>",
|
||||
"tags": [
|
||||
["expiration", "<unix_timestamp>"],
|
||||
["mint", "https://mint.example.com"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expiration typically ~2 weeks. Delete after quote is used or expired.
|
||||
|
||||
### Relay Discovery
|
||||
|
||||
Clients discover wallet relays from:
|
||||
1. Kind 10019 event (preferred)
|
||||
2. Kind 10002 NIP-65 relay list (fallback)
|
||||
|
||||
## NIP-61: Nutzaps
|
||||
|
||||
### Overview
|
||||
|
||||
Nutzaps are P2PK-locked Cashu tokens sent via Nostr. The payment itself serves as the receipt - no Lightning invoices needed.
|
||||
|
||||
Key principle: **"A Nutzap is a P2PK Cashu token in which the payment itself is the receipt."**
|
||||
|
||||
### Nutzap Info Event (kind:10019)
|
||||
|
||||
Recipients publish this to configure nutzap reception.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10019,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["relay", "wss://relay1.example.com"],
|
||||
["relay", "wss://relay2.example.com"],
|
||||
["mint", "https://mint1.example.com", "sat"],
|
||||
["mint", "https://mint2.example.com", "sat", "usd"],
|
||||
["pubkey", "02<compressed_pubkey_hex>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `relay` | Where senders should publish nutzaps |
|
||||
| `mint` | Trusted mints with supported units |
|
||||
| `pubkey` | P2PK pubkey for locking tokens (NOT Nostr key) |
|
||||
|
||||
**Critical**: The pubkey MUST be prefixed with `02` for Nostr-Cashu compatibility.
|
||||
|
||||
### Nutzap Event (kind:9321)
|
||||
|
||||
The actual payment event sent by the payer.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9321,
|
||||
"content": "Optional message/comment",
|
||||
"pubkey": "<sender_pubkey>",
|
||||
"tags": [
|
||||
["proof", "{\"id\":\"...\",\"amount\":8,\"secret\":\"...\",\"C\":\"...\",\"dleq\":{...}}"],
|
||||
["proof", "{\"id\":\"...\",\"amount\":4,\"secret\":\"...\",\"C\":\"...\",\"dleq\":{...}}"],
|
||||
["u", "https://mint.example.com"],
|
||||
["p", "<recipient_nostr_pubkey>"],
|
||||
["e", "<nutzapped_event_id>", "<relay_hint>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `proof` | JSON-stringified Cashu proof with DLEQ |
|
||||
| `u` | Mint URL (must match recipient's config exactly) |
|
||||
| `p` | Recipient's Nostr pubkey |
|
||||
| `e` | Event being nutzapped (optional) |
|
||||
|
||||
**Proof structure (in proof tag):**
|
||||
```json
|
||||
{
|
||||
"id": "009a1f293253e41e",
|
||||
"amount": 8,
|
||||
"secret": "[\"P2PK\",{\"nonce\":\"...\",\"data\":\"02pubkey\"}]",
|
||||
"C": "02abc...",
|
||||
"dleq": {
|
||||
"e": "...",
|
||||
"s": "...",
|
||||
"r": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### P2PK Locking Requirements
|
||||
|
||||
1. **Use recipient's wallet pubkey** from kind:10019, NOT their Nostr pubkey
|
||||
2. **Prefix with '02'** for compatibility: `02<32-byte-hex>`
|
||||
3. **Include DLEQ proofs** for offline verification (NUT-12)
|
||||
4. Lock using NUT-11 P2PK spending condition
|
||||
|
||||
### Validation Rules
|
||||
|
||||
Observers verifying nutzaps must confirm:
|
||||
1. Recipient has kind:10019 listing the mint
|
||||
2. Token is locked to recipient's specified pubkey
|
||||
3. Mint URL matches exactly (case-sensitive)
|
||||
4. DLEQ proof validates offline
|
||||
|
||||
### Sending Workflow
|
||||
|
||||
```
|
||||
1. Fetch recipient's kind:10019
|
||||
↓
|
||||
2. Select a mint from their trusted list
|
||||
↓
|
||||
3. Mint or swap tokens at that mint
|
||||
↓
|
||||
4. P2PK-lock proofs to recipient's pubkey
|
||||
↓
|
||||
5. Publish kind:9321 to recipient's relays
|
||||
```
|
||||
|
||||
### Receiving Workflow
|
||||
|
||||
```
|
||||
1. Subscribe to kind:9321 events tagged with your pubkey
|
||||
↓
|
||||
2. Validate nutzap (mint in config, correct pubkey lock)
|
||||
↓
|
||||
3. Swap proofs at the mint (prevents double-claim)
|
||||
↓
|
||||
4. Create kind:7375 token event with new proofs
|
||||
↓
|
||||
5. Create kind:7376 history event with "redeemed" marker
|
||||
```
|
||||
|
||||
## Wallet Workflows
|
||||
|
||||
### Token Spending
|
||||
|
||||
When spending tokens:
|
||||
|
||||
1. **Select proofs** for the amount to spend
|
||||
2. **Create new token event** with remaining (unspent) proofs
|
||||
3. **Add spent token IDs** to the `del` field
|
||||
4. **Delete original token event** via NIP-09
|
||||
5. **Create history event** (optional) documenting the transaction
|
||||
|
||||
```typescript
|
||||
// Pseudocode for spending
|
||||
const { spend, keep } = await selectProofs(wallet.proofs, amount);
|
||||
|
||||
// Create new token event with change
|
||||
if (keep.length > 0) {
|
||||
await createTokenEvent({
|
||||
proofs: keep,
|
||||
del: [originalTokenEventId]
|
||||
});
|
||||
}
|
||||
|
||||
// Delete original token event
|
||||
await deleteEvent(originalTokenEventId);
|
||||
|
||||
// Record history
|
||||
await createHistoryEvent({
|
||||
direction: 'out',
|
||||
amount: spend.reduce((a, p) => a + p.amount, 0),
|
||||
tags: [['e', originalTokenEventId, '', 'destroyed']]
|
||||
});
|
||||
```
|
||||
|
||||
### Token Receiving
|
||||
|
||||
When receiving tokens (from nutzap or direct transfer):
|
||||
|
||||
1. **Validate token** (correct mint, valid proofs)
|
||||
2. **Swap proofs** at mint (prevents sender reuse)
|
||||
3. **Create token event** with new proofs
|
||||
4. **Create history event** with "redeemed" marker
|
||||
|
||||
### Couch Pattern (Safe Operations)
|
||||
|
||||
For atomic operations that could fail mid-way:
|
||||
|
||||
1. **Store proofs in "couch"** (temporary storage like IndexedDB)
|
||||
2. **Perform mint operation** (swap, melt, etc.)
|
||||
3. **On success**: Delete from couch, create new token event
|
||||
4. **On failure**: Recover from couch
|
||||
|
||||
This prevents losing proofs if the app crashes during an operation.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Separate wallet key**: Never use your Nostr private key for the wallet
|
||||
2. **Encrypt everything**: All token data must be NIP-44 encrypted
|
||||
3. **Verify DLEQ**: Always verify DLEQ proofs when receiving nutzaps
|
||||
4. **Swap immediately**: Swap received tokens to prevent sender double-spending
|
||||
5. **Trusted mints only**: Only accept nutzaps from mints you trust
|
||||
6. **Relay redundancy**: Store wallet data on multiple relays
|
||||
|
||||
## Event Kind Reference
|
||||
|
||||
| Kind | Name | Type | Encryption |
|
||||
|------|------|------|------------|
|
||||
| 7374 | Quote | Regular | NIP-44 |
|
||||
| 7375 | Token | Regular | NIP-44 |
|
||||
| 7376 | History | Regular | NIP-44 (partial) |
|
||||
| 9321 | Nutzap | Regular | None |
|
||||
| 10019 | Nutzap Info | Replaceable | None |
|
||||
| 17375 | Wallet | Replaceable | NIP-44 |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Multiple token events**: Don't put all proofs in one event
|
||||
2. **Consolidate periodically**: Merge small tokens to reduce event count
|
||||
3. **Track history**: Create kind:7376 events for audit trail
|
||||
4. **Handle offline**: Use DLEQ for offline nutzap verification
|
||||
5. **Clean up quotes**: Delete expired kind:7374 events
|
||||
6. **Normalize URLs**: Use consistent mint URL formatting
|
||||
|
||||
## Official Resources
|
||||
|
||||
- [NIP-60 Specification](https://github.com/nostr-protocol/nips/blob/master/60.md)
|
||||
- [NIP-61 Specification](https://github.com/nostr-protocol/nips/blob/master/61.md)
|
||||
- [NIP-44 Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
|
||||
- [Cashu NUTs](https://cashubtc.github.io/nuts/)
|
||||
Reference in New Issue
Block a user