@nostr-dev-kit/ndk-wallet
Version:
1,009 lines (801 loc) • 41.4 kB
text/typescript
import { CashuMint, CashuWallet } from "@cashu/cashu-ts";
import NDK, {
NDKCashuMintList,
NDKEvent,
type NDKEventId,
NDKNutzap,
NDKNutzapState,
NDKPrivateKeySigner,
type NDKUser,
} from "@nostr-dev-kit/ndk";
import { mockNutzap } from "@nostr-dev-kit/ndk/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as CashuMintModule from "../wallets/cashu/mint.js";
import { NDKCashuWallet } from "../wallets/cashu/wallet/index.js";
import { NDKCashuWalletBackup } from "../wallets/cashu/wallet/index.js";
import { fetchPage } from "./fetch-page.js";
import { NDKNutzapMonitor, type NDKNutzapMonitorStore } from "./index";
import * as SpendStatusModule from "./spend-status.js";
// Mock the modules we don't want to actually call
vi.mock("./fetch-page.js");
vi.mock("./spend-status.js");
vi.mock("../wallets/cashu/mint.js");
// Define the extended store type with our test spy
interface MockStore extends NDKNutzapMonitorStore {
setNutzapStateSpy: ReturnType<typeof vi.fn>;
}
// Mock store for testing
const createMockStore = (): MockStore => {
const nutzapStates = new Map<NDKEventId, NDKNutzapState>();
const setNutzapStateSpy = vi.fn();
return {
getAllNutzaps: async (): Promise<Map<NDKEventId, NDKNutzapState>> => nutzapStates,
setNutzapState: async (id: NDKEventId, stateChange: Partial<NDKNutzapState>): Promise<void> => {
const currentState = nutzapStates.get(id) || ({} as NDKNutzapState);
nutzapStates.set(id, { ...currentState, ...stateChange });
setNutzapStateSpy(id, stateChange);
},
setNutzapStateSpy,
};
};
// Set a longer timeout for all tests in this file
vi.setConfig({ testTimeout: 15000 });
describe("NDKNutzapMonitor", () => {
let ndk: NDK;
let user: NDKUser;
let mintList: NDKCashuMintList;
let mockStore: MockStore;
let monitor: NDKNutzapMonitor;
beforeEach(async () => {
// Setup NDK with a signer
ndk = new NDK({
signer: NDKPrivateKeySigner.generate(),
explicitRelayUrls: ["wss://relay1.com"],
});
user = await ndk.signer?.user()!;
// Setup a mint list
mintList = new NDKCashuMintList(ndk);
mintList.mints = ["https://testmint.com"];
// Mock CashuWallet
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(mockCashuWallet, "checkProofsStates").mockResolvedValue([]);
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Setup mock store
mockStore = createMockStore();
// Create the monitor
monitor = new NDKNutzapMonitor(ndk, user, { mintList, store: mockStore });
// Make updateNutzapState public for testing
(monitor as any).updateNutzapState = monitor.updateNutzapState.bind(monitor);
// Setup mock NDKCashuWallet
const mockNDKCashuWallet = new NDKCashuWallet(ndk);
mockNDKCashuWallet.redeemNutzaps = vi.fn().mockResolvedValue(100);
monitor.wallet = mockNDKCashuWallet;
// Mock console methods
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("addPrivkey", () => {
it("should add a private key to the monitor", async () => {
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
await monitor.addPrivkey(signer);
expect(monitor.privkeys.has(pubkey)).toBe(true);
});
it("should not add duplicate private keys", async () => {
const signer = NDKPrivateKeySigner.generate();
const _pubkey = (await signer.user()).pubkey;
await monitor.addPrivkey(signer);
const initialSize = monitor.privkeys.size;
// Try to add the same key again
await monitor.addPrivkey(signer);
expect(monitor.privkeys.size).toBe(initialSize);
});
it.skip("should attempt to redeem nutzaps that were previously locked to a privkey that is now available", async () => {
// No need for individual timeout with vitest
// Create a signer
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
// Create a nutzap that requires this privkey
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey,
});
// Set the nutzap state to MISSING_PRIVKEY
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.MISSING_PRIVKEY,
});
// Mock getProofSpendState to return the nutzap as unspent
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: nutzap.proofs,
spentProofs: [],
nutzapsWithUnspentProofs: [nutzap],
nutzapsWithSpentProofs: [],
});
// Spy on redeemNutzaps
const redeemSpy = vi.spyOn(monitor, "redeemNutzaps").mockResolvedValue(undefined);
// Add the privkey (this should trigger a check for nutzaps that can now be redeemed)
await monitor.addPrivkey(signer);
// Start the monitor to trigger the check for nutzaps
await monitor.start({ filter: {} });
// Check that redeemNutzaps was called with the right arguments
expect(redeemSpy).toHaveBeenCalled();
const callArgs = redeemSpy.mock.calls[0];
expect(callArgs[0]).toBe(nutzap.mint);
expect(callArgs[1]).toContainEqual(expect.objectContaining({ id: nutzap.id }));
});
});
describe("getBackupKeys", () => {
it("should load private keys from backup events", async () => {
// Create backup event
const backupSigner1 = NDKPrivateKeySigner.generate();
const backupSigner2 = NDKPrivateKeySigner.generate();
// Mock the fetchEvents function
const mockBackupEvent = new NDKEvent(ndk);
const backup = new NDKCashuWalletBackup(ndk);
backup.privkeys = [backupSigner1.privateKey!, backupSigner2.privateKey!];
// Mock the fetchEvents method
ndk.fetchEvents = vi.fn().mockResolvedValue(new Set([mockBackupEvent]));
// Mock the from method to return our backup
vi.spyOn(NDKCashuWalletBackup, "from").mockResolvedValue(backup);
// Call getBackupKeys
await monitor.getBackupKeys();
// Check that the private keys were added
const pubkey1 = (await backupSigner1.user()).pubkey;
const pubkey2 = (await backupSigner2.user()).pubkey;
expect(monitor.privkeys.has(pubkey1)).toBe(true);
expect(monitor.privkeys.has(pubkey2)).toBe(true);
});
it.skip("should handle errors when loading backup events", async () => {
// Mock the fetchEvents method to throw an error
ndk.fetchEvents = vi.fn().mockRejectedValue(new Error("Network error"));
// Spy on console.error
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Call the method
await monitor.getBackupKeys();
// Verify error was logged
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.calls[0][0]).toContain("Error loading backup events");
});
it("should create a new backup if new private keys were added but not found in backups", async () => {
// Create a signer to add
const newSigner = NDKPrivateKeySigner.generate();
await monitor.addPrivkey(newSigner);
// Mock empty fetchEvents result
ndk.fetchEvents = vi.fn().mockResolvedValue(new Set());
// Spy on NDKCashuWalletBackup.save
const saveSpy = vi.fn().mockResolvedValue(undefined);
vi.spyOn(NDKCashuWalletBackup.prototype, "save").mockImplementation(saveSpy);
// Call getBackupKeys
await monitor.getBackupKeys();
// Check that a backup was created and saved
expect(saveSpy).toHaveBeenCalled();
});
});
describe("redeemNutzap", () => {
it("should set state to MISSING_PRIVKEY when no privkey is available", async () => {
// Create a nutzap with a pubkey we don't have
const unknownSigner = NDKPrivateKeySigner.generate();
const unknownPubkey = (await unknownSigner.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: unknownPubkey,
});
// Try to redeem the nutzap
await monitor.redeemNutzap(nutzap);
// Check the state is set to MISSING_PRIVKEY
const state = monitor.nutzapStates.get(nutzap.id);
expect(state?.status).toBe(NdkNutzapStatus.MISSING_PRIVKEY);
// Check the store was updated
expect(mockStore.setNutzapStateSpy).toHaveBeenCalledWith(
nutzap.id,
expect.objectContaining({ status: NdkNutzapStatus.MISSING_PRIVKEY }),
);
});
it("should transition through states and set redeemedAmount when successfully redeemed", async () => {
// Mock the wallet for mints
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Get the user's pubkey and create a nutzap for them
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
// Create a nutzap for the user
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
// Add the user's privkey to the monitor
await monitor.addPrivkey(userSigner);
// Mock redeemNutzaps to simulate successful redemption
vi.spyOn(monitor, "redeemNutzaps").mockImplementation(async (_mint, nutzaps, _proofs) => {
// Simulate successful redemption
for (const nutzap of nutzaps) {
(monitor as any).updateNutzapState(nutzap.id, {
status: NdkNutzapStatus.REDEEMED,
redeemedAmount: 100,
});
}
monitor.emit("redeemed", nutzaps, 100);
});
// Try to redeem the nutzap
await monitor.redeemNutzap(nutzap);
// Check final state
const finalState = monitor.nutzapStates.get(nutzap.id);
expect(finalState?.status).toBe(NdkNutzapStatus.REDEEMED);
expect(finalState?.redeemedAmount).toBe(100);
});
it.skip("should mark nutzap as INVALID_NUTZAP when it's not valid", async () => {
// Mock an invalid nutzap
const invalidNutzap = await mockNutzap("https://testmint.com", 100, ndk);
Object.defineProperty(invalidNutzap, "isValid", { get: () => false });
// Try to redeem the nutzap
await monitor.redeemNutzap(invalidNutzap);
// Check final state
const finalState = monitor.nutzapStates.get(invalidNutzap.id);
expect(finalState?.status).toBe(NdkNutzapStatus.INVALID_NUTZAP);
expect(finalState?.errorMessage).toBe("Invalid nutzap");
});
it.skip("should mark nutzap as INVALID_NUTZAP when p2pk is missing", async () => {
// Mock a nutzap with missing p2pk
const nutzap = await mockNutzap("https://testmint.com", 100, ndk);
Object.defineProperty(nutzap, "rawP2pk", { get: () => null });
Object.defineProperty(nutzap, "isValid", { get: () => true });
// Try to redeem the nutzap
await monitor.redeemNutzap(nutzap);
// Check final state
const finalState = monitor.nutzapStates.get(nutzap.id);
expect(finalState?.status).toBe(NdkNutzapStatus.INVALID_NUTZAP);
expect(finalState?.errorMessage).toContain("Invalid nutzap: locked to an invalid public key");
});
it("should mark nutzap as PERMANENT_ERROR when 'unknown public key size' error occurs", async () => {
// Mock the wallet for mints
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Get the user's pubkey and create a nutzap for them
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
// Create a nutzap for the user
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
// Add the user's privkey to the monitor
await monitor.addPrivkey(userSigner);
// Mock redeemNutzaps to throw "unknown public key size" error
vi.spyOn(monitor.wallet as NDKCashuWallet, "redeemNutzaps").mockImplementation(() => {
throw new Error("unknown public key size");
});
// Listen for failed emissions
const failedEvents: Array<{ nutzap: NDKNutzap; error: string }> = [];
monitor.on("failed", (nutzap, error) => {
failedEvents.push({ nutzap, error });
});
// Try to redeem the nutzap
await monitor.redeemNutzap(nutzap);
// Check that the failed event was emitted with correct error message
expect(failedEvents.length).toBe(1);
expect(failedEvents[0].nutzap.id).toBe(nutzap.id);
expect(failedEvents[0].error).toBe("Invalid p2pk: unknown public key size");
// Check that the nutzap state was updated correctly
const finalState = monitor.nutzapStates.get(nutzap.id);
expect(finalState?.status).toBe(NdkNutzapStatus.PERMANENT_ERROR);
expect(finalState?.errorMessage).toBe("Invalid p2pk: unknown public key size");
});
it("should emit 'failed' event for other errors during redemption", async () => {
// Mock the wallet for mints
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Get the user's pubkey and create a nutzap for them
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
// Create a nutzap for the user
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
// Add the user's privkey to the monitor
await monitor.addPrivkey(userSigner);
// Mock redeemNutzaps to throw a generic error
vi.spyOn(monitor.wallet as NDKCashuWallet, "redeemNutzaps").mockImplementation(() => {
throw new Error("Mint server error");
});
// Listen for failed emissions
const failedEvents: Array<{ nutzap: NDKNutzap; error: string }> = [];
monitor.on("failed", (nutzap, error) => {
failedEvents.push({ nutzap, error });
});
// Try to redeem the nutzap
await monitor.redeemNutzap(nutzap);
// Check that the failed event was emitted
expect(failedEvents.length).toBe(1);
expect(failedEvents[0].nutzap.id).toBe(nutzap.id);
expect(failedEvents[0].error).toBe("Mint server error");
});
});
describe("processAccumulatedNutzaps", () => {
it("should mark spent nutzaps as SPENT and not attempt to redeem them", async () => {
// Get the user's pubkey
const userPubkey = (await ndk.signer?.user()).pubkey;
// Create nutzaps - one spent, one unspent
const unspentNutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
const spentNutzap = await mockNutzap("https://testmint.com", 200, ndk, {
recipientPubkey: userPubkey,
});
// Setup fetchPage mock
(fetchPage as vi.Mock).mockResolvedValue([unspentNutzap, spentNutzap]);
// Setup getProofSpendState mock to show one nutzap as spent and one as unspent
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: unspentNutzap.proofs,
spentProofs: spentNutzap.proofs,
nutzapsWithUnspentProofs: [unspentNutzap],
nutzapsWithSpentProofs: [spentNutzap],
});
// Add user's private key
await monitor.addPrivkey(ndk.signer as NDKPrivateKeySigner);
// Directly call checkAndRedeemGroup to ensure state changes are emitted
const group = { mint: "https://testmint.com", nutzaps: [unspentNutzap, spentNutzap] };
// Listen for state changes
const stateChanges = new Map<NDKEventId, NdkNutzapStatus>();
monitor.on("state_changed", (id, status) => {
stateChanges.set(id, status);
});
// Process the nutzaps
await (monitor as any).checkAndRedeemGroup(group);
// Check that spent nutzap was marked as SPENT
expect(monitor.nutzapStates.get(spentNutzap.id)?.status).toBe(NdkNutzapStatus.SPENT);
});
it("should process multiple pages of nutzaps when there are unspent nutzaps", async () => {
// Get the user's pubkey
const userPubkey = (await ndk.signer?.user()).pubkey;
// Create nutzaps for page 1
const nutzap1 = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
nutzap1.created_at = 1000;
// Create nutzaps for page 2
const nutzap2 = await mockNutzap("https://testmint.com", 200, ndk, {
recipientPubkey: userPubkey,
});
nutzap2.created_at = 500; // Older nutzap for page 2
// Setup fetchPage mock to return different results based on filter
let fetchPageCallCount = 0;
(fetchPage as vi.Mock).mockImplementation((_ndk, _filter) => {
fetchPageCallCount++;
if (fetchPageCallCount === 1) {
return [nutzap1];
}
return [nutzap2];
});
// Setup getProofSpendState mock to return all nutzaps as unspent
vi.spyOn(SpendStatusModule, "getProofSpendState").mockImplementation(async (_wallet, nutzaps) => {
return {
unspentProofs: nutzaps.flatMap((n) => n.proofs),
spentProofs: [],
nutzapsWithUnspentProofs: nutzaps,
nutzapsWithSpentProofs: [],
};
});
// Add user's private key
await monitor.addPrivkey(ndk.signer as NDKPrivateKeySigner);
// Mock processAccumulatedNutzaps to avoid infinite recursion in tests
const originalProcessAccumulatedNutzaps = monitor.processAccumulatedNutzaps;
monitor.processAccumulatedNutzaps = vi.fn().mockImplementation(async (filter = {}) => {
// Call the original once to process the first page
if (!filter.since) {
await originalProcessAccumulatedNutzaps.call(monitor, filter);
// Manually call with the second filter to simulate recursion
await originalProcessAccumulatedNutzaps.call(monitor, {
...filter,
since: 999,
});
}
});
// Spy on processNutzaps
const processSpy = vi.spyOn(monitor as any, "processNutzaps");
// Process the nutzaps
await monitor.processAccumulatedNutzaps();
// fetchPage should have been called twice - once for each page
expect(fetchPage).toHaveBeenCalledTimes(2);
// processNutzaps should have been called twice
expect(processSpy).toHaveBeenCalledTimes(2);
// Check both nutzaps were processed
expect(monitor.nutzapStates.has(nutzap1.id)).toBe(true);
expect(monitor.nutzapStates.has(nutzap2.id)).toBe(true);
});
});
describe("start", () => {
it.skip("should load nutzap states from the store and process redeemable nutzaps", async () => {
// Create a nutzap and add it to the store
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
await mockStore.setNutzapState(nutzap.id, {
nutzap,
status: NdkNutzapStatus.INITIAL,
});
// Mock getProofSpendState to return unspent proofs
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: nutzap.proofs,
spentProofs: [],
nutzapsWithUnspentProofs: [nutzap],
nutzapsWithSpentProofs: [],
});
// Mock fetchPage to return empty array
(fetchPage as vi.Mock).mockResolvedValue([]);
// Add the user's private key
await monitor.addPrivkey(userSigner);
// Spy on redeemNutzaps
const redeemSpy = vi.spyOn(monitor, "redeemNutzaps").mockResolvedValue(undefined);
// Start the monitor
await monitor.start({ filter: {} });
// Check that redeemNutzaps was called for the nutzap from the store
expect(redeemSpy).toHaveBeenCalled();
const callArgs = redeemSpy.mock.calls[0];
expect(callArgs[0]).toBe(nutzap.mint);
expect(callArgs[1]).toContainEqual(expect.objectContaining({ id: nutzap.id }));
});
it.skip("should start a subscription and process incoming nutzaps", async () => {
// Mock fetchPage to return empty array
(fetchPage as vi.Mock).mockResolvedValue([]);
// Spy on NDK.subscribe
const subscribeSpy = vi.spyOn(ndk, "subscribe");
// Start the monitor
await monitor.start({ filter: {} });
// Check that a subscription was started
expect(subscribeSpy).toHaveBeenCalled();
// Create a nutzap coming in through the subscription
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: userPubkey,
});
// Mock the event handler
const eventHandlerSpy = vi.spyOn(monitor as any, "eventHandler");
// Access NDKNutzap.from directly to preserve its type
const originalFrom = NDKNutzap.from;
// Use ts-ignore here because we're intentionally mocking a static method
// @ts-ignore - mocking static method
NDKNutzap.from = vi.fn().mockImplementation(async (_event: NDKEvent) => {
// Return our mock nutzap
return nutzap;
});
// Extract the subscription callback
const subOptions = subscribeSpy.mock.calls[0][3];
// Call the subscription callback with a new event
if (subOptions && typeof subOptions === "object" && "onEvent" in subOptions) {
const nutzapEvent = new NDKEvent(ndk);
nutzapEvent.id = nutzap.id;
// @ts-ignore - suppressing type error for testing
subOptions.onEvent(nutzapEvent);
}
// Check that the event handler was called
expect(eventHandlerSpy).toHaveBeenCalled();
// Restore the original function
// @ts-ignore - restoring original method
NDKNutzap.from = originalFrom;
});
it.skip("should handle exceptions when loading data from the store", async () => {
// Create a store that throws an error when getAllNutzaps is called
const errorStore: NDKNutzapMonitorStore = {
getAllNutzaps: vi.fn().mockRejectedValue(new Error("Database error")),
setNutzapState: vi.fn().mockResolvedValue(undefined),
};
// Create monitor with error store
const errorMonitor = new NDKNutzapMonitor(ndk, user, { mintList, store: errorStore });
// Mock fetchPage to return empty array
(fetchPage as vi.Mock).mockResolvedValue([]);
// Spy on console.error
const errorSpy = vi.spyOn(console, "error");
// Start the monitor
await errorMonitor.start({ filter: {} });
// Check that the error was logged
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to load nutzaps from store"),
expect.any(Error),
);
});
});
describe("store integration", () => {
it.skip("should load nutzap states from the store on startup", async () => {
// Create a new mock store with pre-populated state
const newStore = createMockStore();
const existingNutzap = await mockNutzap("https://testmint.com", 100, ndk);
// Add a nutzap state to the store
await newStore.setNutzapState(existingNutzap.id, {
nutzap: existingNutzap,
status: NdkNutzapStatus.REDEEMED,
redeemedAmount: 100,
});
// Create a new monitor with this store
const newMonitor = new NDKNutzapMonitor(ndk, user, { mintList, store: newStore });
// Mock fetchPage to return empty array (so we don't process new nutzaps)
(fetchPage as vi.Mock).mockResolvedValue([]);
// Start the monitor (this should load states from the store)
await newMonitor.start({ filter: {} });
// Check that the state was loaded from the store
const state = newMonitor.nutzapStates.get(existingNutzap.id);
expect(state?.status).toBe(NdkNutzapStatus.REDEEMED);
expect(state?.redeemedAmount).toBe(100);
});
it("should update the store when nutzap states change", async () => {
// Create a nutzap
const nutzap = await mockNutzap("https://testmint.com", 100, ndk);
// Create a method to access the private updateNutzapState method
const updateState = (id: string, state: Partial<NDKNutzapState>) => {
// @ts-ignore - accessing private method for testing
return monitor.updateNutzapState(id, state);
};
// Update the nutzap state
updateState(nutzap.id, {
nutzap,
status: NdkNutzapStatus.PROCESSING,
});
// Check that the store was updated
expect(mockStore.setNutzapStateSpy).toHaveBeenCalledWith(
nutzap.id,
expect.objectContaining({ status: NdkNutzapStatus.PROCESSING }),
);
});
});
describe("shouldTryRedeem", () => {
it("should return false for nutzaps with PERMANENT_ERROR status", async () => {
// Create a nutzap
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey,
});
// Set it to PERMANENT_ERROR state
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.PERMANENT_ERROR,
errorMessage: "Invalid p2pk: unknown public key size",
});
// It should not be retried
expect(monitor.shouldTryRedeem(nutzap)).toBe(false);
});
it("should return true for nutzaps with INITIAL status", async () => {
// Create a nutzap
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey,
});
// Set it to INITIAL state
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.INITIAL,
});
// It should be retried
expect(monitor.shouldTryRedeem(nutzap)).toBe(true);
});
it("should return true for unknown nutzaps", async () => {
// Create a nutzap without setting any state
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey,
});
// It should be retried since it's unknown
expect(monitor.shouldTryRedeem(nutzap)).toBe(true);
});
it("should return true for nutzaps in MISSING_PRIVKEY state when we now have the key", async () => {
// Create a signer
const signer = NDKPrivateKeySigner.generate();
const pubkey = (await signer.user()).pubkey;
// Create a nutzap that requires this privkey
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey,
});
// Set it to MISSING_PRIVKEY state
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.MISSING_PRIVKEY,
});
// Add the privkey
await monitor.addPrivkey(signer);
// It should be retried now that we have the key
expect(monitor.shouldTryRedeem(nutzap)).toBe(true);
});
it("should return false for nutzaps that have been redeemed or spent", async () => {
// Create a nutzap
const nutzap = await mockNutzap("https://testmint.com", 100, ndk);
// Set it to REDEEMED state
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.REDEEMED,
redeemedAmount: 100,
});
// It should not be retried
expect(monitor.shouldTryRedeem(nutzap)).toBe(false);
// Set it to SPENT state
monitor.nutzapStates.set(nutzap.id, {
nutzap,
status: NdkNutzapStatus.SPENT,
});
// It should not be retried
expect(monitor.shouldTryRedeem(nutzap)).toBe(false);
});
});
describe("processNutzaps", () => {
it.skip("should correctly identify the oldest unspent nutzap", async () => {
// Create nutzaps with different timestamps
const olderNutzap = await mockNutzap("https://testmint.com", 100, ndk);
olderNutzap.created_at = 1000; // Older timestamp
const newerNutzap = await mockNutzap("https://testmint.com", 200, ndk);
newerNutzap.created_at = 2000; // Newer timestamp
// Add to the monitor
monitor.nutzapStates.set(olderNutzap.id, { status: NdkNutzapStatus.INITIAL });
monitor.nutzapStates.set(newerNutzap.id, { status: NdkNutzapStatus.INITIAL });
// Mock fetchPage to return these nutzaps
(fetchPage as vi.Mock).mockResolvedValue([olderNutzap, newerNutzap]);
// Call the method
const result = await monitor.processNutzaps({ cashuPubkey: "pubkey123" });
// Should return the timestamp of the oldest nutzap
expect(result).toBe(1000);
});
});
});
describe("NIP-61 specific functionality", () => {
let ndk: NDK;
let user: NDKUser;
let mintList: NDKCashuMintList;
let mockStore: MockStore;
let monitor: NDKNutzapMonitor;
beforeEach(async () => {
// Setup NDK with a signer
ndk = new NDK({
signer: NDKPrivateKeySigner.generate(),
explicitRelayUrls: ["wss://relay1.com"],
});
user = await ndk.signer?.user();
// Setup a mint list
mintList = new NDKCashuMintList(ndk);
mintList.mints = ["https://testmint.com"];
// Mock CashuWallet
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(mockCashuWallet, "checkProofsStates").mockResolvedValue([]);
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Setup mock store
mockStore = createMockStore();
// Create the monitor
monitor = new NDKNutzapMonitor(ndk, user, { mintList, store: mockStore });
// Setup mock NDKCashuWallet
const mockNDKCashuWallet = new NDKCashuWallet(ndk);
mockNDKCashuWallet.redeemNutzaps = vi.fn().mockResolvedValue(100);
monitor.wallet = mockNDKCashuWallet;
// Mock console methods
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should handle nutzaps with different p2pk formats correctly", async () => {
// Create nutzaps with different p2pk formats (compressed and uncompressed keys)
const signer1 = NDKPrivateKeySigner.generate(); // First private key
const signer2 = NDKPrivateKeySigner.generate(); // Second private key
const pubkey1 = (await signer1.user()).pubkey;
const pubkey2 = (await signer2.user()).pubkey;
// Mock different p2pk formats (one with 02 prefix, one without)
const nutzap1 = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey1,
});
const nutzap2 = await mockNutzap("https://testmint.com", 200, ndk, {
recipientPubkey: pubkey2,
});
// Add both signers to the monitor
await monitor.addPrivkey(signer1);
await monitor.addPrivkey(signer2);
// Mock CashuWallet
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Mock getProofSpendState to return unspent proofs
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: [...nutzap1.proofs, ...nutzap2.proofs],
spentProofs: [],
nutzapsWithUnspentProofs: [nutzap1, nutzap2],
nutzapsWithSpentProofs: [],
});
// Spy on redeemNutzaps to verify it gets called for both nutzaps
const redeemSpy = vi.spyOn(monitor, "redeemNutzaps");
// Process the nutzaps
await (monitor as any).processNutzaps([nutzap1, nutzap2]);
// redeemNutzaps should be called for both nutzaps
expect(redeemSpy).toHaveBeenCalled();
// Check that we have states for both nutzaps
expect(monitor.nutzapStates.has(nutzap1.id)).toBe(true);
expect(monitor.nutzapStates.has(nutzap2.id)).toBe(true);
});
it("should emit 'seen_in_unknown_mint' event when nutzap is for an unknown mint", async () => {
// Create a nutzap with a mint not in the mintList
const unknownMint = "https://unknown-mint.com";
const userSigner = ndk.signer as NDKPrivateKeySigner;
const userPubkey = (await userSigner.user()).pubkey;
const nutzap = await mockNutzap(unknownMint, 100, ndk, { recipientPubkey: userPubkey });
// Spy on the event
const seenInUnknownMintSpy = vi.fn();
monitor.on("seen_in_unknown_mint", seenInUnknownMintSpy);
// Add the event handler manually (simulating a subscription)
await (monitor as any).eventHandler(nutzap);
// The event should be emitted
expect(seenInUnknownMintSpy).toHaveBeenCalled();
const calledWithNutzap = seenInUnknownMintSpy.mock.calls[0][0];
expect(calledWithNutzap.id).toBe(nutzap.id);
expect(calledWithNutzap.mint).toBe(unknownMint);
});
it("should handle nutzaps with multiple proofs with different p2pk values", async () => {
// Create two signers
const signer1 = NDKPrivateKeySigner.generate();
const signer2 = NDKPrivateKeySigner.generate();
const pubkey1 = (await signer1.user()).pubkey;
const pubkey2 = (await signer2.user()).pubkey;
// Add both keys to the monitor
await monitor.addPrivkey(signer1);
await monitor.addPrivkey(signer2);
// Create a nutzap with proofs for both keys
const nutzap1 = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: pubkey1,
});
const nutzap2 = await mockNutzap("https://testmint.com", 200, ndk, {
recipientPubkey: pubkey2,
});
// Mock CashuWallet
const mockCashuWallet = new CashuWallet(new CashuMint("https://testmint.com"));
vi.spyOn(CashuMintModule, "walletForMint").mockResolvedValue(mockCashuWallet);
// Mock getProofSpendState to return unspent proofs
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: [...nutzap1.proofs, ...nutzap2.proofs],
spentProofs: [],
nutzapsWithUnspentProofs: [nutzap1, nutzap2],
nutzapsWithSpentProofs: [],
});
// Spy on redeemNutzaps
const redeemSpy = vi.spyOn(monitor, "redeemNutzaps");
// Process the nutzaps
await (monitor as any).processNutzaps([nutzap1, nutzap2]);
// redeemNutzaps should be called for both nutzaps
expect(redeemSpy).toHaveBeenCalled();
// Check that we have states for both nutzaps
expect(monitor.nutzapStates.has(nutzap1.id)).toBe(true);
expect(monitor.nutzapStates.has(nutzap2.id)).toBe(true);
});
it("should correctly identify nutzaps that need to be retrieved from the backup", async () => {
// Create a signer not initially in the monitor
const backupSigner = NDKPrivateKeySigner.generate();
const backupPubkey = (await backupSigner.user()).pubkey;
// Create a nutzap p2pked to this signer
const nutzap = await mockNutzap("https://testmint.com", 100, ndk, {
recipientPubkey: backupPubkey,
});
// Setup a mock backup event
const backupEvent = new NDKEvent(ndk);
const backup = new NDKCashuWalletBackup(ndk);
backup.privkeys = [backupSigner.privateKey!];
// Mock NDKCashuWalletBackup.from to return our backup
vi.spyOn(NDKCashuWalletBackup, "from").mockResolvedValue(backup);
// Mock fetchEvents to return our backup event
ndk.fetchEvents = vi.fn().mockResolvedValue(new Set([backupEvent]));
// Try to redeem the nutzap (should initially fail due to missing key)
await monitor.redeemNutzap(nutzap);
// Check that the nutzap is in MISSING_PRIVKEY state
expect(monitor.nutzapStates.get(nutzap.id)?.status).toBe(NdkNutzapStatus.MISSING_PRIVKEY);
// Mock getProofSpendState to return unspent proofs when we retry
vi.spyOn(SpendStatusModule, "getProofSpendState").mockResolvedValue({
unspentProofs: nutzap.proofs,
spentProofs: [],
nutzapsWithUnspentProofs: [nutzap],
nutzapsWithSpentProofs: [],
});
// Manually add the private key to simulate what getBackupKeys would do
await monitor.addPrivkey(backupSigner);
// Check that the privkey was added to the monitor
expect(monitor.privkeys.has(backupPubkey)).toBe(true);
// Try to redeem the nutzap again
const result = monitor.shouldTryRedeem(nutzap);
// It should now return true since we now have the key
expect(result).toBe(true);
});
});