From b176cba1a11a1ae8fabe58bd589caaf8256a8d9c Mon Sep 17 00:00:00 2001 From: Marco Argentieri <3596602+tiero@users.noreply.github.com> Date: Wed, 22 Feb 2023 18:41:30 +0100 Subject: [PATCH] add NIP-26 delegation (#7) * add delegate method * enable ci on PR * enable test on ci * lint * lint * off flaky tests --- .github/workflows/main.yml | 11 +-- example/index.tsx | 35 ++++----- src/connect.ts | 65 +++++++++++++++++ test/connect.test.ts | 146 +++++++++++++++++-------------------- 4 files changed, 153 insertions(+), 104 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2ea904e..2ab5465 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,9 @@ name: CI -on: [push] +on: + push: + branches: [master] + pull_request: + branches: [master] jobs: build: name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} @@ -7,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: ['14.x', '16.x', '18.x'] + node: ['14.x', '16.x'] os: [ubuntu-latest] steps: @@ -25,8 +29,5 @@ jobs: - name: Lint run: yarn lint - #- name: Test - # run: yarn test --ci --coverage --maxWorkers=2 - - name: Build run: yarn build diff --git a/example/index.tsx b/example/index.tsx index 034fa8e..abe7dd5 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -3,20 +3,20 @@ import { useEffect, useState } from 'react'; import { useStatePersist } from 'use-state-persist'; import * as ReactDOM from 'react-dom'; -import { broadcastToRelay, Connect, connectToRelay, ConnectURI } from '@nostr-connect/connect'; +import { broadcastToRelay, Connect, connectToRelay, ConnectURI, TimeRanges } from '../src/index'; import { QRCodeSVG } from 'qrcode.react'; -import { getEventHash, getPublicKey, Event } from 'nostr-tools'; +import { getEventHash, getPublicKey, Event, nip19 } from 'nostr-tools'; const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3"; const connectURI = new ConnectURI({ target: getPublicKey(secretKey), relay: 'wss://nostr.vulpem.com', metadata: { - name: 'Example', - description: '🔉🔉🔉', - url: 'https://example.com', - icons: ['https://example.com/icon.png'], + name: 'Vulpem', + description: 'Bitcoin company', + url: 'https://vulpem.com', + icons: ['https://vulpem.com/favicon.ico'], }, }); @@ -54,7 +54,7 @@ const App = () => { target: pubkey, }); const pk = await connect.getPublicKey(); - setGetPublicKeyReply(pk); + setGetPublicKeyReply(nip19.npubEncode(pk)); } const sendMessage = async () => { @@ -116,19 +116,13 @@ const App = () => { target: pubkey, }); - const sig = await connect.rpc.call({ - target: pubkey, - request: { - method: 'delegate', - params: [ - getPublicKey(secretKey), - { - kind: 0, - until: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, - } - ], + const sig = await connect.delegate( + getPublicKey(secretKey), + { + kind: 1, + until: TimeRanges.ONE_DAY, } - }); + ); setDelegateSig(sig); } catch (error) { console.error(error); @@ -158,6 +152,7 @@ const App = () => { }); } + const appEpehemeralPubKey = nip19.npubEncode(getPublicKey(secretKey)); return (
@@ -165,7 +160,7 @@ const App = () => {

Nostr Connect Playground

-

Nostr ID {getPublicKey(secretKey)}

+

Nostr ID (ephemeral) {appEpehemeralPubKey}

Status {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}

diff --git a/src/connect.ts b/src/connect.ts index c9043ac..95aa23f 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -10,6 +10,23 @@ export interface Metadata { icons?: string[]; } +export enum TimeRanges { + FIVE_MINS = '5mins', + ONE_HR = '1hour', + ONE_DAY = '1day', + ONE_WEEK = '1week', + ONE_MONTH = '1month', + ONE_YEAR = '1year', +} +export const TimeRangeToUnix: Record = { + [TimeRanges.FIVE_MINS]: Math.round(Date.now() / 1000) + 60 * 5, + [TimeRanges.ONE_HR]: Math.round(Date.now() / 1000) + 60 * 60, + [TimeRanges.ONE_DAY]: Math.round(Date.now() / 1000) + 60 * 60 * 24, + [TimeRanges.ONE_WEEK]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 7, + [TimeRanges.ONE_MONTH]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30, + [TimeRanges.ONE_YEAR]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365, +}; + export class ConnectURI { target: string; metadata: Metadata; @@ -209,6 +226,54 @@ export class Connect { return signature as string; } + async describe(): Promise { + if (!this.target) throw new Error('Not connected'); + + const response = await this.rpc.call({ + target: this.target, + request: { + method: 'describe', + params: [], + }, + }); + return response as string[]; + } + + async delegate( + delegatee: string = this.rpc.self.pubkey, + conditions: { + kind?: number; + until?: number | TimeRanges; + since?: number | TimeRanges; + } + ): Promise { + if (!this.target) throw new Error('Not connected'); + + if (conditions.until && typeof conditions.until !== 'number') { + if (!Object.keys(TimeRangeToUnix).includes(conditions.until)) + throw new Error( + 'conditions.until must be either a number or a valid TimeRange' + ); + conditions.until = TimeRangeToUnix[conditions.until]; + } + if (conditions.since && typeof conditions.since !== 'number') { + if (!Object.keys(TimeRangeToUnix).includes(conditions.since)) + throw new Error( + 'conditions.since must be either a number or a valid TimeRange' + ); + conditions.since = TimeRangeToUnix[conditions.since]; + } + + const sig = await this.rpc.call({ + target: this.target, + request: { + method: 'delegate', + params: [delegatee, conditions], + }, + }); + return sig as string; + } + async getRelays(): Promise<{ [url: string]: { read: boolean; write: boolean }; }> { diff --git a/test/connect.test.ts b/test/connect.test.ts index 5a0cd58..874f12e 100644 --- a/test/connect.test.ts +++ b/test/connect.test.ts @@ -1,8 +1,8 @@ -import { getPublicKey, signEvent, Event } from 'nostr-tools'; -import { Connect, ConnectURI, NostrSigner } from '../src'; +import { getPublicKey, signEvent, Event, nip26 } from 'nostr-tools'; +import { Connect, ConnectURI, NostrSigner, TimeRanges } from '../src'; import { sleep } from './utils'; -jest.setTimeout(5000); +jest.setTimeout(7500); // web app (this is ephemeral and represents the currention session) const webSK = @@ -21,27 +21,34 @@ class MobileHandler extends NostrSigner { 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')); - }); - }); + const sigEvt = signEvent(event, this.self.secret); + return Promise.resolve(sigEvt); + } + async delegate( + delegatee: string, + conditions: { + kind?: number; + until?: number; + since?: number; + } + ): Promise { + const delegateParameters: nip26.Parameters = { + pubkey: delegatee, + kind: conditions.kind, + since: conditions.since || Math.round(Date.now() / 1000), + until: + conditions.until || + Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */, + }; + const delegation = nip26.createDelegation( + this.self.secret, + delegateParameters + ); + return Promise.resolve(delegation.sig); } } -describe('Nostr Connect', () => { +describe('ConnectURI', () => { it('roundtrip connectURI', async () => { const connectURI = new ConnectURI({ target: `b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4`, @@ -70,6 +77,45 @@ describe('Nostr Connect', () => { 'https://vulpem.com/1000x860-p-500.422be1bc.png' ); }); +}); + +describe('Connect', () => { + beforeAll(async () => { + try { + // start listening for connect messages on the mobile app + const remoteHandler = new MobileHandler({ + secretKey: mobileSK, + relay: 'wss://nostr.vulpem.com', + }); + await remoteHandler.listen(); + } catch (error) { + console.error(error); + throw error; + } + }); + + it('returns pubkey and delegation', async () => { + // start listening for connect messages on the web app + const connect = new Connect({ + secretKey: webSK, + target: mobilePK, + }); + await connect.init(); + + sleep(1000); + + // send the get_public_key message to the mobile app from the web + const pubkey = await connect.getPublicKey(); + expect(pubkey).toBe(mobilePK); + + // send the delegate message to the mobile app from the web to ask for permission to sign kind 1 notes on behalf of the user for 5 mins + const sig = await connect.delegate(webPK, { + kind: 1, + until: TimeRanges.FIVE_MINS, + }); + expect(sig).toBeTruthy(); + }); + it.skip('connect', async () => { const testHandler = jest.fn(); @@ -95,31 +141,6 @@ describe('Nostr Connect', () => { expect(testHandler).toBeCalledTimes(1); }); - - it('returns pubkey', async () => { - // start listening for connect messages on the mobile app - const remoteHandler = new MobileHandler({ - secretKey: mobileSK, - relay: 'wss://nostr.vulpem.com', - }); - 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); - - // send the get_public_key message to the mobile app from the web - const pubkey = await connect.getPublicKey(); - expect(pubkey).toBe(mobilePK); - }); - it.skip('returns a signed event', async () => { // start listening for connect messages on the mobile app const remoteHandler = new MobileHandler({ @@ -164,36 +185,3 @@ describe('Nostr Connect', () => { expect(event).toBeDefined(); }); }); - -/* - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ - type: ConnectMessageType.PAIRED, - value: { - pubkey: mobilePK, - } - }); - - const pubkey = await connect.getPublicKey(); - expect(pubkey).toBe(mobilePK); - const signedEvt = await connect.signEvent({}); - const relays = await connect.getRelays(); - - const plainText = "hello 🌍"; - const cipherText = await connect.nip04.encrypt(childPK, plainText); - const plainText2 = await connect.nip04.decrypt(childPK, cipherText); - expect(plainText === plainText2).toBeTruthy(); - - await connect.request({ - method: 'signSchnorr', - params: [ - '0x000000', - '0x000000' - ] - }); - - - sessionWeb.on(ConnectMessageType.UNPAIRED, () => { - }); - - */