@nostr-dev-kit/ndk-wallet
Version:
246 lines (206 loc) • 7.12 kB
text/typescript
import type { Proof } from "@cashu/cashu-ts";
import {
NDKCashuToken,
NDKEvent,
type NDKEventId,
NDKKind,
type NDKRelay,
type NDKRelaySet,
type NostrEvent,
} from "@nostr-dev-kit/ndk";
import type { JournalEntry, ProofC, WalletState, WalletTokenChange } from ".";
import type { MintUrl } from "../../mint/utils";
import type { WalletProofChange } from "./index.js";
export type UpdateStateResult = {
/**
* Tokens that were created as the result of a state change
*/
created?: NDKCashuToken;
/**
* Tokens that were reserved as the result of a state change
*/
reserved?: NDKCashuToken;
/**
* Tokens that were deleted as the result of a state change
*/
deleted?: NDKEventId[];
};
/**
*
* @param this
* @param stateChange
*/
export async function update(
this: WalletState,
stateChange: WalletProofChange,
_memo?: string,
): Promise<UpdateStateResult> {
updateInternalState(this, stateChange);
this.wallet.emit("balance_updated");
return updateExternalState(this, stateChange);
}
/**
* This function immediately reflects a state update
*/
function updateInternalState(walletState: WalletState, stateChange: WalletProofChange) {
if (stateChange.store && stateChange.store.length > 0) {
for (const proof of stateChange.store) {
walletState.addProof({
mint: stateChange.mint,
state: "available",
proof,
timestamp: Date.now(),
});
}
}
if (stateChange.destroy && stateChange.destroy.length > 0) {
for (const proof of stateChange.destroy) {
walletState.updateProof(proof, { state: "deleted" });
}
}
if (stateChange.reserve && stateChange.reserve.length > 0) {
throw new Error("BUG: Proofs should not be reserved via update");
}
}
/**
* This function updates tokens on relays.
*/
async function updateExternalState(
walletState: WalletState,
stateChange: WalletProofChange,
): Promise<UpdateStateResult> {
const newState = calculateNewState(walletState, stateChange);
if (newState.deletedTokenIds.size > 0) {
const deleteEvent = new NDKEvent(walletState.wallet.ndk, {
kind: NDKKind.EventDeletion,
tags: [
["k", NDKKind.CashuToken.toString()],
...Array.from(newState.deletedTokenIds).map((id) => ["e", id]),
],
} as NostrEvent);
await deleteEvent.sign();
publishWithRetry(walletState, deleteEvent, walletState.wallet.relaySet);
// remove the tokens from the wallet
for (const tokenId of newState.deletedTokenIds) {
walletState.removeTokenId(tokenId);
}
}
// execute the state change
const res: UpdateStateResult = {};
if (newState.saveProofs.length > 0) {
const newToken = await createTokenEvent(walletState, stateChange.mint, newState);
res.created = newToken;
}
return res;
}
/**
* Publishes an event to a relay set, retrying if necessary.
* @param event
* @param relaySet
*/
async function publishWithRetry(
walletState: WalletState,
event: NDKEvent,
relaySet?: NDKRelaySet,
retryTimeout = 10 * 1000, // 10 seconds
) {
let publishResult: Set<NDKRelay> | undefined;
publishResult = await event.publish(relaySet);
let type: string | undefined;
if (event.kind === NDKKind.EventDeletion) type = "deletion";
if (event.kind === NDKKind.CashuToken) type = "token";
if (event.kind === NDKKind.CashuWallet) type = "wallet";
const journalEntryMetadata: JournalEntry["metadata"] = {
type,
id: event.id,
relayUrl: relaySet?.relayUrls.join(","),
};
if (publishResult) {
walletState.journal.push({
memo: `Publish kind:${event.kind} succeesfully`,
timestamp: Date.now(),
metadata: journalEntryMetadata,
});
return publishResult;
}
walletState.journal.push({
memo: "Publish failed",
timestamp: Date.now(),
metadata: journalEntryMetadata,
});
setTimeout(() => {
publishWithRetry(walletState, event, relaySet, retryTimeout);
}, retryTimeout);
}
/**
* Creates a token event as part of a state transition.
*/
async function createTokenEvent(walletState: WalletState, mint: MintUrl, newState: WalletTokenChange) {
const newToken = new NDKCashuToken(walletState.wallet.ndk);
newToken.mint = mint;
newToken.proofs = newState.saveProofs;
// create the event id
await newToken.toNostrEvent();
// immediately add the token to the wallet before signing it
walletState.addToken(newToken);
// add the deleted tokens to the new token
newToken.deletedTokens = Array.from(newState.deletedTokenIds);
// sign it
await newToken.sign();
// update the token in place, no need to affect proofs since they already have the right token id
walletState.addToken(newToken);
// publish it
publishWithRetry(walletState, newToken, walletState.wallet.relaySet);
return newToken;
}
export function calculateNewState(walletState: WalletState, stateChange: WalletProofChange): WalletTokenChange {
/**
* This tracks the proofs that we know we need to destroy.
*/
const destroyProofs = new Set<ProofC>();
for (const proof of stateChange.destroy || []) destroyProofs.add(proof.C);
/**
* This tracks the proofs that we need to store.
*/
const proofsToStore = new Map<ProofC, Proof>();
/**
* This tracks the tokens that we need to delete.
*/
let tokensToDelete: Map<NDKEventId, NDKCashuToken>;
// add all proofs from stateChange.store to proofsToStore
for (const proof of stateChange.store || []) proofsToStore.set(proof.C, proof);
// get tokens where proofs to be deleted are stored
tokensToDelete = getAffectedTokens(walletState, stateChange);
// get proofs from tokens that are not deleted that we need to store
for (const token of tokensToDelete.values()) {
for (const proof of token.proofs) {
if (destroyProofs.has(proof.C)) continue;
proofsToStore.set(proof.C, proof);
}
}
return {
deletedTokenIds: new Set(tokensToDelete.keys()),
deletedProofs: destroyProofs,
reserveProofs: [],
saveProofs: Array.from(proofsToStore.values()),
};
}
function getAffectedTokens(walletState: WalletState, stateChange: WalletProofChange) {
const tokens = new Map<NDKEventId, NDKCashuToken>();
for (const proof of stateChange.destroy || []) {
const proofEntry = walletState.proofs.get(proof.C);
if (!proofEntry) {
continue;
}
const tokenId = proofEntry.tokenId;
if (!tokenId) {
continue;
}
const tokenEntry = walletState.tokens.get(tokenId);
if (!tokenEntry?.token) {
continue;
}
tokens.set(tokenId, tokenEntry.token);
}
return tokens;
}