diff --git a/README.md b/README.md index e08fa45..faaebe2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ yarn add @nostr-connect/connect ## 📖 Usage 1. [👩‍💻 For Apps developers](#-for-apps-developers) -2. [🔐 For Remote Signer developers](#-for-wallet-developers) +2. [🔐 For Wallet & Remote Signer developers](#-for-wallet-developers) + + +## 👩‍💻 For Apps developers + ### Create an ephemeral key To use the SDK, you need to create an ephemeral key. This key is used to authenticate your user and to create a session. @@ -48,7 +52,7 @@ await connect.init(); ```typescript const connectURI = new ConnectURI({ target: webPK, - relayURL: 'wss://nostr.vulpem.com', + relay: 'wss://nostr.vulpem.com', metadata: { name: 'My Website', description: 'lorem ipsum dolor sit amet', @@ -70,3 +74,99 @@ const pubkey = await connect.getPublicKey(); // send the sign_event message to the mobile app const sig = await connect.signEvent(event); ``` + +## 🔐 For Wallet developers + +### 🤓 1. Define your methods + +As per [NIP-46](https://github.com/nostr-connect/nips/blob/nostr-connect/46.md), the Signer app **MUST** implement the following RPC methods: + +- `get_public_key` +- `sign_event` + +You need to define these methods in your app, each method must return a `Promise` that resolves to the expected result. + +The NostrRPC class provides access to the Nostr public key via `this.self.pubkey`, the Nostr private key via `this.self.secret` and the full Nostr event that originated the current request. You can access the event using the `this.event` property. + +It's best to ask approval from the user before signing an event. To do so, you can emit an event to the UI and wait for the user to approve or reject the request. + +```typescript +import { NostrRPC } from '@nostr-connect/connect'; +import { getPublicKey, signEvent, nip06 } from 'nostr-tools'; + +const sk = nip06.privateKeyFromSeedWords(myWords); + +class MobileHandler extends NostrRPC { + + async get_public_key(): Promise { + return getPublicKey(sk); + } + + async sign_event(event: Event): Promise { + if (!this.event) throw new Error('No origin event'); + + // emit event to the UI to show a modal + this.events.emit('sign_event_request', { + sender: this.event.pubkey, + event, + }); + + // wait for the user to approve or reject the request + return new Promise((resolve, reject) => { + + // listen for user accept + this.events.on('sign_event_approve', () => { + resolve(signEvent(event, this.self.secret)); + }); + + // or reject + this.events.on('sign_event_reject', () => { + reject(new Error('User rejected request')); + }); + }); + } +} +``` + +2. 🎒 Create a MobileHandler instance + +Generate a key to identify the remote signer, it is used to be reached by the apps. + +```typescript +// random key +const secretKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey()); +// create a new instance of the MobileHandler +const handler = new MobileHandler({ secretKey }); + +// define how to consume the sign_event_request events +remoteHandler.events.on('sign_event_request', + (event: Event) => { + // ⚠️⚠️⚠️ IMPORTANT: always check if the app is connected + if (!remoteHandler.isConnected(event.pubkey)) return; + // do your UI stuff here to ask the user to approve or reject the request + + // UI components can accept the sign + //this.events.emit('sign_event_approve'); + + // or reject + //this.events.emit('sign_event_reject'); + } +); + +// 📡 Listen for incoming requests +await remoteHandler.listen(); +``` + +4. 🚏 Intercept ConnectURI + +Allow user to scan the QR code and extract the ConnectURI, intercept it via deep linking or let use manually copy-paste the URI. + +```typescript +// Show to the user the pubkey +const { target, relay, metadata } = ConnectURI.fromURI(text); + +// if he consents send the connect message +await connectURI.approve(key); +// if rejects could be polite to notify the app of the rejection +await connectURI.reject(key); +``` \ No newline at end of file diff --git a/src/connect.ts b/src/connect.ts index 4eee3c0..0a0b0f6 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -72,7 +72,7 @@ export class ConnectURI { params: [getPublicKey(secretKey)], }, }, - { skipResponse: true } + { skipResponse: false } ); } diff --git a/src/index.ts b/src/index.ts index c37a852..576af54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './connect'; export * from './rpc'; +export * from './nostr'; diff --git a/src/nostr.ts b/src/nostr.ts new file mode 100644 index 0000000..a2bba8a --- /dev/null +++ b/src/nostr.ts @@ -0,0 +1,23 @@ +import { NostrRPC } from './rpc'; + +export class NostrSigner extends NostrRPC { + connectedAppIDs: string[]; + constructor(opts: { relay?: string | undefined; secretKey: string }) { + super(opts); + this.connectedAppIDs = []; + } + + addConnectedApp = (pubkey: string) => { + this.connectedAppIDs.push(pubkey); + }; + + removeConnectedApp = (pubkey: string) => { + this.connectedAppIDs = this.connectedAppIDs.filter( + (id: string) => id !== pubkey + ); + }; + + isConnected(pubkey: string): boolean { + return this.connectedAppIDs.includes(pubkey); + } +} diff --git a/src/rpc.ts b/src/rpc.ts index eff9582..29f1f65 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,3 +1,4 @@ +import EventEmitter from 'events'; import { Event, Filter, @@ -29,6 +30,8 @@ export class NostrRPC { event: Event | undefined; // this is for implementing the response handlers for each method [key: string]: any; + // events + events = new EventEmitter(); constructor(opts: { relay?: string; secretKey: string }) { this.relay = opts.relay || 'wss://nostr.vulpem.com'; diff --git a/test/connect.test.ts b/test/connect.test.ts index 1b324ef..f266066 100644 --- a/test/connect.test.ts +++ b/test/connect.test.ts @@ -1,5 +1,5 @@ -import { getPublicKey } from 'nostr-tools'; -import { Connect, ConnectURI, NostrRPC } from '../src'; +import { getPublicKey, signEvent, Event } from 'nostr-tools'; +import { Connect, ConnectURI, NostrSigner } from '../src'; import { sleep } from './utils'; jest.setTimeout(5000); @@ -16,10 +16,29 @@ const mobileSK = const mobilePK = getPublicKey(mobileSK); console.log('mobilePK', mobilePK); -class MobileHandler extends NostrRPC { +class MobileHandler extends NostrSigner { async get_public_key(): Promise { return getPublicKey(this.self.secret); } + async sign_event(event: any): Promise { + if (!this.event) throw new Error('No origin event'); + + // emit event to the UI to show a modal + this.events.emit('sign_event_request', event); + + // wait for the user to approve or reject the request + return new Promise((resolve, reject) => { + // listen for user acceptance + this.events.on('sign_event_approve', () => { + resolve(signEvent(event, this.self.secret)); + }); + + // or rejection + this.events.on('sign_event_reject', () => { + reject(new Error('User rejected request')); + }); + }); + } } describe('Nostr Connect', () => { @@ -72,6 +91,50 @@ describe('Nostr Connect', () => { const pubkey = await connect.getPublicKey(); expect(pubkey).toBe(mobilePK); }); + + it('returns a signed event', async () => { + // start listening for connect messages on the mobile app + const remoteHandler = new MobileHandler({ + secretKey: mobileSK, + relay: 'wss://nostr.vulpem.com', + }); + + // define how to comnsume the event + + remoteHandler.events.on('sign_event_request', (event: Event) => { + // ⚠️⚠️⚠️ IMPORTANT: always check if the app is connected + console.log(remoteHandler.connectedAppIDs); + if (!remoteHandler.isConnected(event.pubkey)) return; + // assume user clicks on approve button on the UI + remoteHandler.events.emit('sign_event_approve'); + }); + + // add app as connected app + remoteHandler.addConnectedApp(webPK); + + // start listening for request messages on the mobile app + await remoteHandler.listen(); + + await sleep(1000); + + // start listening for connect messages on the web app + const connect = new Connect({ + secretKey: webSK, + target: mobilePK, + }); + await connect.init(); + + await sleep(1000); + + const event = await connect.signEvent({ + kind: 1, + pubkey: mobilePK, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: '🏃‍♀️ Testing Nostr Connect', + }); + expect(event).toBeDefined(); + }); }); /*