add NIP-26 delegation (#7)

* add delegate method

* enable ci on PR

* enable test on ci

* lint

* lint

* off flaky tests
This commit is contained in:
Marco Argentieri
2023-02-22 18:41:30 +01:00
committed by GitHub
parent 39c9d8f071
commit b176cba1a1
4 changed files with 153 additions and 104 deletions

View File

@@ -1,5 +1,9 @@
name: CI name: CI
on: [push] on:
push:
branches: [master]
pull_request:
branches: [master]
jobs: jobs:
build: build:
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
@@ -7,7 +11,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
node: ['14.x', '16.x', '18.x'] node: ['14.x', '16.x']
os: [ubuntu-latest] os: [ubuntu-latest]
steps: steps:
@@ -25,8 +29,5 @@ jobs:
- name: Lint - name: Lint
run: yarn lint run: yarn lint
#- name: Test
# run: yarn test --ci --coverage --maxWorkers=2
- name: Build - name: Build
run: yarn build run: yarn build

View File

@@ -3,20 +3,20 @@ import { useEffect, useState } from 'react';
import { useStatePersist } from 'use-state-persist'; import { useStatePersist } from 'use-state-persist';
import * as ReactDOM from 'react-dom'; 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 { QRCodeSVG } from 'qrcode.react';
import { getEventHash, getPublicKey, Event } from 'nostr-tools'; import { getEventHash, getPublicKey, Event, nip19 } from 'nostr-tools';
const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3"; const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
const connectURI = new ConnectURI({ const connectURI = new ConnectURI({
target: getPublicKey(secretKey), target: getPublicKey(secretKey),
relay: 'wss://nostr.vulpem.com', relay: 'wss://nostr.vulpem.com',
metadata: { metadata: {
name: 'Example', name: 'Vulpem',
description: '🔉🔉🔉', description: 'Bitcoin company',
url: 'https://example.com', url: 'https://vulpem.com',
icons: ['https://example.com/icon.png'], icons: ['https://vulpem.com/favicon.ico'],
}, },
}); });
@@ -54,7 +54,7 @@ const App = () => {
target: pubkey, target: pubkey,
}); });
const pk = await connect.getPublicKey(); const pk = await connect.getPublicKey();
setGetPublicKeyReply(pk); setGetPublicKeyReply(nip19.npubEncode(pk));
} }
const sendMessage = async () => { const sendMessage = async () => {
@@ -116,19 +116,13 @@ const App = () => {
target: pubkey, target: pubkey,
}); });
const sig = await connect.rpc.call({ const sig = await connect.delegate(
target: pubkey,
request: {
method: 'delegate',
params: [
getPublicKey(secretKey), getPublicKey(secretKey),
{ {
kind: 0, kind: 1,
until: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, until: TimeRanges.ONE_DAY,
} }
], );
}
});
setDelegateSig(sig); setDelegateSig(sig);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -158,6 +152,7 @@ const App = () => {
}); });
} }
const appEpehemeralPubKey = nip19.npubEncode(getPublicKey(secretKey));
return ( return (
<div className='hero is-fullheight has-background-black has-text-white'> <div className='hero is-fullheight has-background-black has-text-white'>
<section className="container"> <section className="container">
@@ -165,7 +160,7 @@ const App = () => {
<h1 className='title has-text-white'>Nostr Connect Playground</h1> <h1 className='title has-text-white'>Nostr Connect Playground</h1>
</div> </div>
<div className='content'> <div className='content'>
<p className='subtitle is-6 has-text-white'><b>Nostr ID</b> {getPublicKey(secretKey)}</p> <p className='subtitle is-6 has-text-white'><b>Nostr ID (ephemeral)</b> {appEpehemeralPubKey}</p>
</div> </div>
<div className='content'> <div className='content'>
<p className='subtitle is-6 has-text-white'><b>Status</b> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p> <p className='subtitle is-6 has-text-white'><b>Status</b> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p>

View File

@@ -10,6 +10,23 @@ export interface Metadata {
icons?: string[]; 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, number> = {
[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 { export class ConnectURI {
target: string; target: string;
metadata: Metadata; metadata: Metadata;
@@ -209,6 +226,54 @@ export class Connect {
return signature as string; return signature as string;
} }
async describe(): Promise<string[]> {
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<string> {
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<{ async getRelays(): Promise<{
[url: string]: { read: boolean; write: boolean }; [url: string]: { read: boolean; write: boolean };
}> { }> {

View File

@@ -1,8 +1,8 @@
import { getPublicKey, signEvent, Event } from 'nostr-tools'; import { getPublicKey, signEvent, Event, nip26 } from 'nostr-tools';
import { Connect, ConnectURI, NostrSigner } from '../src'; import { Connect, ConnectURI, NostrSigner, TimeRanges } from '../src';
import { sleep } from './utils'; import { sleep } from './utils';
jest.setTimeout(5000); jest.setTimeout(7500);
// web app (this is ephemeral and represents the currention session) // web app (this is ephemeral and represents the currention session)
const webSK = const webSK =
@@ -21,27 +21,34 @@ class MobileHandler extends NostrSigner {
return getPublicKey(this.self.secret); return getPublicKey(this.self.secret);
} }
async sign_event(event: any): Promise<string> { async sign_event(event: any): Promise<string> {
if (!this.event) throw new Error('No origin event'); const sigEvt = signEvent(event, this.self.secret);
return Promise.resolve(sigEvt);
// emit event to the UI to show a modal }
this.events.emit('sign_event_request', event); async delegate(
delegatee: string,
// wait for the user to approve or reject the request conditions: {
return new Promise((resolve, reject) => { kind?: number;
// listen for user acceptance until?: number;
this.events.on('sign_event_approve', () => { since?: number;
resolve(signEvent(event, this.self.secret)); }
}); ): Promise<string> {
const delegateParameters: nip26.Parameters = {
// or rejection pubkey: delegatee,
this.events.on('sign_event_reject', () => { kind: conditions.kind,
reject(new Error('User rejected request')); 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 () => { it('roundtrip connectURI', async () => {
const connectURI = new ConnectURI({ const connectURI = new ConnectURI({
target: `b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4`, target: `b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4`,
@@ -70,6 +77,45 @@ describe('Nostr Connect', () => {
'https://vulpem.com/1000x860-p-500.422be1bc.png' '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 () => { it.skip('connect', async () => {
const testHandler = jest.fn(); const testHandler = jest.fn();
@@ -95,31 +141,6 @@ describe('Nostr Connect', () => {
expect(testHandler).toBeCalledTimes(1); 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 () => { it.skip('returns a signed event', async () => {
// start listening for connect messages on the mobile app // start listening for connect messages on the mobile app
const remoteHandler = new MobileHandler({ const remoteHandler = new MobileHandler({
@@ -164,36 +185,3 @@ describe('Nostr Connect', () => {
expect(event).toBeDefined(); 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, () => {
});
*/