Skip to content

Custom Signer Development

One of the key design features of the @contextvm/sdk is its modularity, which is exemplified by the NostrSigner interface. By creating your own implementation of this interface, you can integrate the SDK with any key management system, such as a hardware wallet, a remote signing service (like an HSM), or a browser extension.

While the PrivateKeySigner is a common choice for most applications, there are cases where you may need to use a different approach:

  • Security is paramount: You need to keep private keys isolated from the main application logic, for example, in a hardware security module (HSM) or a secure enclave.
  • Interacting with external wallets: Your application needs to request signatures from a user’s wallet, such as a browser extension (e.g., Alby, Noster) or a mobile wallet.
  • Complex key management: Your application uses a more complex key management architecture that doesn’t involve direct access to raw private keys.

To create a custom signer, you need to create a class that implements the NostrSigner interface. This involves implementing two main methods: getPublicKey() and signEvent(), as well as an optional nip44 object for encryption.

Example: A NIP-07 Browser Signer (window.nostr)

Section titled “Example: A NIP-07 Browser Signer (window.nostr)”

A common use case for a custom signer is in a web application that needs to interact with a Nostr browser extension (like Alby, nos2x, or Blockcore) that exposes the window.nostr object according to NIP-07. This allows the application to request signatures and encryption from the user’s wallet without ever handling private keys directly.

Here is how you could implement a NostrSigner that wraps the window.nostr object:

import { NostrSigner } from "@ctxvm/sdk/core";
import { UnsignedEvent, NostrEvent } from "nostr-tools";
// Define the NIP-07 window.nostr interface for type-safety
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<NostrEvent>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
};
}
}
class Nip07Signer implements NostrSigner {
constructor() {
if (!window.nostr) {
throw new Error("NIP-07 compatible browser extension not found.");
}
}
async getPublicKey(): Promise<string> {
if (!window.nostr) throw new Error("window.nostr not found.");
return await window.nostr.getPublicKey();
}
async signEvent(event: UnsignedEvent): Promise<NostrEvent> {
if (!window.nostr) throw new Error("window.nostr not found.");
return await window.nostr.signEvent(event);
}
nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
if (!window.nostr?.nip44) {
throw new Error("The extension does not support NIP-44 encryption.");
}
return await window.nostr.nip44.encrypt(pubkey, plaintext);
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
if (!window.nostr?.nip44) {
throw new Error("The extension does not support NIP-44 decryption.");
}
return await window.nostr.nip44.decrypt(pubkey, ciphertext);
},
};
}

When using a NIP-07 signer, the nip44 implementation is straightforward, as you can see in the example above. You simply delegate the calls to the window.nostr.nip44 object.

It’s important to include checks to ensure that the user’s browser extension supports nip44, as it is an optional part of the NIP-07 specification. If the extension does not support it, you should throw an error to prevent unexpected behavior.

Once your custom signer class is created, you can instantiate it and pass it to any component that requires a NostrSigner, such as the NostrClientTransport or NostrServerTransport. The rest of the SDK will use your custom implementation seamlessly.

With the Signer component covered, let’s move on to the Relay component, which handles the connection and management of Nostr relays.