import { nanoid } from "nanoid"; import { BehaviorSubject } from "rxjs"; import { getEventHash } from "nostr-tools"; /** Wraps a promise in an abort signal */ function wrapInSignal(promise, signal) { return new Promise((res, rej) => { signal.throwIfAborted(); let done = false; // reject promise if abort signal is triggered signal.addEventListener("abort", () => { if (!done) rej(signal.reason || undefined); done = true; }); return promise.then((v) => { if (!done) res(v); done = true; }, (err) => { if (!done) rej(err); done = true; }); }); } /** An error thrown when a signer is used with the wrong pubkey */ export class SignerMismatchError extends Error { } /** A base class for all accounts */ export class BaseAccount { pubkey; signer; id = nanoid(8); get type() { const cls = Reflect.getPrototypeOf(this).constructor; return cls.type; } /** Disable request queueing */ disableQueue; metadata$ = new BehaviorSubject(undefined); get metadata() { return this.metadata$.value; } set metadata(metadata) { this.metadata$.next(metadata); } get nip04() { if (!this.signer.nip04) return undefined; return { encrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip04.encrypt(pubkey, plaintext)), decrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip04.decrypt(pubkey, plaintext)), }; } get nip44() { if (!this.signer.nip44) return undefined; return { encrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip44.encrypt(pubkey, plaintext)), decrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip44.decrypt(pubkey, plaintext)), }; } constructor(pubkey, signer) { this.pubkey = pubkey; this.signer = signer; } // This should be overwritten by a sub class toJSON() { throw new Error("Not implemented"); } /** A method that wraps any signer interaction, this allows the account to wait for unlock or queue requests */ operation(operation) { // If the queue is enabled, wait our turn in the queue return this.waitForQueue(operation); } /** Adds the common fields to the serialized output of a toJSON method */ saveCommonFields(json) { return { ...json, id: this.id, pubkey: this.pubkey, metadata: this.metadata, type: this.type }; } /** Sets an accounts id and metadata. NOTE: This should only be used in fromJSON methods */ static loadCommonFields(account, json) { if (json.id) account.id = json.id; if (json.metadata) account.metadata = json.metadata; return account; } /** Gets the pubkey from the signer */ getPublicKey() { return this.operation(async () => { const result = await this.signer.getPublicKey(); if (this.pubkey !== result) throw new SignerMismatchError("Account signer mismatch"); return result; }); } /** sign the event and make sure its signed with the correct pubkey */ signEvent(template) { // If the template does not have a pubkey, set it to the accounts pubkey if (!Reflect.has(template, "pubkey")) Reflect.set(template, "pubkey", this.pubkey); return this.operation(async () => { const id = getEventHash(template); const result = await this.signer.signEvent(template); if (result.pubkey !== this.pubkey) throw new SignerMismatchError("Signer signed with wrong pubkey"); if (result.id !== id) throw new SignerMismatchError("Signer modified event"); return result; }); } /** Aborts all pending requests in the queue */ abortQueue(reason) { if (this.abort) this.abort.abort(reason); } /** internal queue */ queueLength = 0; lock = null; abort = null; reduceQueue() { // shorten the queue this.queueLength--; // if this was the last request, remove the lock if (this.queueLength === 0) { this.lock = null; this.abort = null; } } waitForQueue(operation) { if (this.disableQueue) return operation(); // if there is already a pending request, wait for it if (this.lock && this.abort) { // create a new promise that runs after the lock const p = wrapInSignal(this.lock.then(() => { // if the abort signal is triggered, don't call the signer this.abort?.signal.throwIfAborted(); return operation(); }), this.abort.signal); // set the lock the new promise that ignores errors this.lock = p.catch(() => { }).finally(this.reduceQueue.bind(this)); this.queueLength++; return p; } else { const result = operation(); // if the result is async, set the new lock if (result instanceof Promise) { this.abort = new AbortController(); const p = wrapInSignal(result, this.abort.signal); // set the lock the new promise that ignores errors this.lock = p.catch(() => { }).finally(this.reduceQueue.bind(this)); this.queueLength = 1; } return result; } } }