diff --git a/.rubocop.yml b/.rubocop.yml index a5b30b0..b36dbf4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,5 @@ +inherit_from: .rubocop_todo.yml + require: - rubocop-rake - rubocop-rspec diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..f9d5a9f --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,23 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2023-01-12 10:00:10 UTC using RuboCop version 1.42.0. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes. +Metrics/AbcSize: + Max: 23 + +# Offense count: 2 +# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods. +Metrics/MethodLength: + Max: 16 + +# Offense count: 4 +# Configuration parameters: AssignmentOnly. +RSpec/InstanceVariable: + Exclude: + - 'spec/nostr/client_spec.rb' diff --git a/README.md b/README.md index d31c505..0bed75a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/c7633eb2c89eb95ee7f2/maintainability)](https://codeclimate.com/github/wilsonsilva/nostr/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/c7633eb2c89eb95ee7f2/test_coverage)](https://codeclimate.com/github/wilsonsilva/nostr/test_coverage) -Nostr client. Please note that the API is likely to change as the gem is still in development and has not yet reached a -stable release. Use with caution. +Asynchronous Nostr client. Please note that the API is likely to change as the gem is still in development and +has not yet reached a stable release. Use with caution. ## Installation @@ -19,10 +19,171 @@ If bundler is not being used to manage dependencies, install the gem by executin ## Usage +### Requiring the gem + +All examples below assume that the gem has been required. + ```ruby require 'nostr' ``` + +### Generating a keypair + +```ruby +keygen = Nostr::Keygen.new +keypair = keygen.generate_keypair + +keypair.private_key +keypair.public_key +``` + +### Generating a private key and a public key + +```ruby +keygen = Nostr::Keygen.new + +private_key = keygen.generate_private_key +public_key = keygen.extract_public_key(private_key) +``` + +### Connecting to a Relay + +Clients can connect to multiple Relays. In this version, a Client can only connect to a single Relay at a time. + +You may instantiate multiple Clients and multiple Relays. + +```ruby +client = Nostr::Client.new +relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') + +client.connect(relay) +``` + +### WebSocket events + +All communication between clients and relays happen in WebSockets. + +The `:connect` event is fired when a connection with a WebSocket is opened. You must call `Nostr::Client#connect` first. + +```ruby +client.on :connect do + # all the code goes here +end +``` + +The `:close` event is fired when a connection with a WebSocket has been closed because of an error. + +```ruby +client.on :error do |error_message| + puts error_message +end + +# > Network error: wss://rsslay.fiatjaf.com: Unable to verify the server certificate for 'rsslay.fiatjaf.com' +``` + +The `:message` event is fired whenwhen data is received through a WebSocket. + +```ruby +client.on :message do |message| + puts message +end + +# > Network error: wss://rsslay.fiatjaf.com: Unable to verify the server certificate for 'rsslay.fiatjaf.com' +``` + +The `:close` event is fired when a connection with a WebSocket is closed. + +```ruby +client.on :close do |code, reason| + # you may attempt to reconnect + + client.connect(relay) +end +``` + +### Requesting for events / creating a subscription + +A client can request events and subscribe to new updates after it has established a connection with the Relay. + +You may use a `Nostr::Filter` instance with as many attributes as you wish: + +```ruby +client.on :connect do + filter = Nostr::Filter.new( + ids: ['8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8'], + authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], + kinds: [Nostr::EventKind::TEXT_NOTE], + e: ["f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52"], + p: ["f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8"], + since: 1230981305, + until: 1292190341, + limit: 420, + ) + + subscription = client.subscribe('a_random_subscription_id', filter) +end +``` + +With just a few: + +```ruby +client.on :connect do + filter = Nostr::Filter.new(kinds: [Nostr::EventKind::TEXT_NOTE]) + subscription = client.subscribe('a_random_subscription_id', filter) +end +``` + +Or omit the filter: + +```ruby +client.on :connect do + subscription = client.subscribe('a_random_subscription_id') +end +``` + +Or even omit the subscription id: + +```ruby +client.on :connect do + subscription = client.subscribe('a_random_subscription_id') +end +``` + +### Stop previous subscriptions + +You can stop receiving messages from a subscription by calling `#unsubscribe`: + +```ruby +client.unsubscribe('your_subscription_id') +``` + +### Publishing an event + +To publish an event you need a keypair. + +```ruby +# Set up the private key +private_key = 'a630b06e2f883378d0aa335b9adaf7734603e00433350b684fe53e184f08c58f' +user = Nostr::User.new(private_key) + +# Create a signed event +event = user.create_event( + created_at: 1667422587, # optional, defaults to the current time + kind: Nostr::EventKind::TEXT_NOTE, + tags: [], # optional, defaults to [] + content: 'Your feedback is appreciated, now pay $8' +) + +# Send it to the Relay +client.publish(event) +``` + +## NIPS + +- [x] [NIP-01 - Client](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [ ] [NIP-01 - Relay](https://github.com/nostr-protocol/nips/blob/master/01.md) + ## Development After checking out the repo, run `bin/setup` to install dependencies. diff --git a/lib/nostr.rb b/lib/nostr.rb index 179b7dd..325464a 100644 --- a/lib/nostr.rb +++ b/lib/nostr.rb @@ -1,6 +1,17 @@ # frozen_string_literal: true require_relative 'nostr/version' +require_relative 'nostr/keygen' +require_relative 'nostr/client_message_type' +require_relative 'nostr/filter' +require_relative 'nostr/subscription' +require_relative 'nostr/relay' +require_relative 'nostr/key_pair' +require_relative 'nostr/event_kind' +require_relative 'nostr/event_fragment' +require_relative 'nostr/event' +require_relative 'nostr/client' +require_relative 'nostr/user' # Encapsulates all the gem's logic module Nostr diff --git a/lib/nostr/client.rb b/lib/nostr/client.rb new file mode 100644 index 0000000..99125eb --- /dev/null +++ b/lib/nostr/client.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'event_emitter' +require 'faye/websocket' + +module Nostr + # Clients can talk with relays and can subscribe to any set of events using a subscription filters. + # The filter represents all the set of nostr events that a client is interested in. + # + # There is no sign-up or account creation for a client. Every time a client connects to a relay, it submits its + # subscription filters and the relay streams the "interested events" to the client as long as they are connected. + # + class Client + include EventEmitter + + # Instantiates a new Client + # + # @api public + # + # @example Instantiating a client that logs all the events it sends and receives + # client = Nostr::Client.new(debug: true) + # + def initialize + @subscriptions = {} + + initialize_channels + end + + # Connects to the Relay's websocket endpoint + # + # @api public + # + # @example Connecting to a relay + # relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') + # client.connect(relay) + # + # @param [Relay] relay The relay to connect to + # + # @return [void] + # + def connect(relay) + execute_within_an_em_thread do + client = Faye::WebSocket::Client.new(relay.url, [], { tls: { verify_peer: false } }) + parent_to_child_channel.subscribe { |msg| client.send(msg) } + + client.on :open do + child_to_parent_channel.push(type: :open) + end + + client.on :message do |event| + child_to_parent_channel.push(type: :message, data: event.data) + end + + client.on :error do |event| + child_to_parent_channel.push(type: :error, message: event.message) + end + + client.on :close do |event| + child_to_parent_channel.push(type: :close, code: event.code, reason: event.reason) + end + end + end + + # Subscribes to a set of events using a filter + # + # @api public + # + # @example Creating a subscription with no id and no filters + # subscription = client.subscribe + # + # @example Creating a subscription with an ID + # subscription = client.subscribe(subscription_id: 'my-subscription') + # + # @example Subscribing to all events created after a certain time + # subscription = client.subscribe(filter: Nostr::Filter.new(since: 1230981305)) + # + # @param subscription_id [String] The subscription id. A random string. + # @param filter [Filter] A set of attributes that represent the events that the client is interested in. + # + # @return [Subscription] The subscription object + # + def subscribe(subscription_id: SecureRandom.hex, filter: Filter.new) + subscriptions[subscription_id] = Subscription.new(id: subscription_id, filter:) + parent_to_child_channel.push([ClientMessageType::REQ, subscription_id, filter.to_h].to_json) + subscriptions[subscription_id] + end + + # Stops a previous subscription + # + # @api public + # + # @example Stopping a subscription + # client.unsubscribe(subscription.id) + # + # @example Stopping a subscription + # client.unsubscribe('my-subscription') + # + # @param subscription_id [String] ID of a previously created subscription. + # + # @return [void] + # + def unsubscribe(subscription_id) + subscriptions.delete(subscription_id) + parent_to_child_channel.push([ClientMessageType::CLOSE, subscription_id].to_json) + end + + # Sends an event to a Relay + # + # @api public + # + # @example Sending an event to a relay + # client.publish(event) + # + # @param event [Event] The event to be sent to a Relay + # + # @return [void] + # + def publish(event) + parent_to_child_channel.push([ClientMessageType::EVENT, event.to_h].to_json) + end + + private + + # The subscriptions that the client has created + # + # @api private + # + # @return [Hash{String=>Subscription}>] + # + attr_reader :subscriptions + + # The channel that the parent thread uses to send messages to the child thread + # + # @api private + # + # @return [EventMachine::Channel] + # + attr_reader :parent_to_child_channel + + # The channel that the child thread uses to send messages to the parent thread + # + # @api private + # + # @return [EventMachine::Channel] + # + attr_reader :child_to_parent_channel + + # Executes a block of code within the EventMachine thread + # + # @api private + # + # @return [Thread] + # + def execute_within_an_em_thread(&block) + Thread.new { EventMachine.run(block) } + end + + # Creates the communication channels between threads + # + # @api private + # + # @return [void] + # + def initialize_channels + @parent_to_child_channel = EventMachine::Channel.new + @child_to_parent_channel = EventMachine::Channel.new + + child_to_parent_channel.subscribe do |msg| + emit :connect if msg[:type] == :open + emit :message, msg[:data] if msg[:type] == :message + emit :error, msg[:message] if msg[:type] == :error + emit :close, msg[:code], msg[:reason] if msg[:type] == :close + end + end + end +end diff --git a/lib/nostr/client_message_type.rb b/lib/nostr/client_message_type.rb new file mode 100644 index 0000000..c8a3ae4 --- /dev/null +++ b/lib/nostr/client_message_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Nostr + # Clients can send 3 types of messages, which must be JSON arrays + module ClientMessageType + # @return [String] Used to publish events + EVENT = 'EVENT' + + # @return [String] Used to request events and subscribe to new updates + REQ = 'REQ' + + # @return [String] Used to stop previous subscriptions + CLOSE = 'CLOSE' + end +end diff --git a/lib/nostr/event.rb b/lib/nostr/event.rb new file mode 100644 index 0000000..eb44fd6 --- /dev/null +++ b/lib/nostr/event.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Nostr + # The only object type that exists in Nostr is an event. Events are immutable. + class Event < EventFragment + # 32-bytes sha256 of the the serialized event data. + # To obtain the event.id, we sha256 the serialized event. The serialization is done over the UTF-8 JSON-serialized + # string (with no white space or line breaks) + # + # @api public + # + # @example Getting the event id + # event.id # => 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' + # + # @return [String] + # + attr_reader :id + + # 64-bytes signature of the sha256 hash of the serialized event data, which is + # the same as the "id" field + # + # @api public + # + # @example Getting the event signature + # event.sig # => '' + # + # @return [String] + # + attr_reader :sig + + # Instantiates a new Event + # + # @api public + # + # @example Instantiating a new event + # Nostr::Event.new( + # id: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + # pubkey: '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e', + # created_at: 1230981305, + # kind: 1, + # tags: [], + # content: 'Your feedback is appreciated, now pay $8', + # sig: '123ac2923b792ce730b3da34f16155470ab13c8f97f9c53eaeb334f1fb3a5dc9a7f643 + # 937c6d6e9855477638f5655c5d89c9aa5501ea9b578a66aced4f1cd7b3' + # ) + # + # + # @param id [String] 32-bytes sha256 of the the serialized event data. + # @param sig [String] 64-bytes signature of the sha256 hash of the serialized event data, which is + # the same as the "id" field + # + def initialize(id:, sig:, **kwargs) + super(**kwargs) + + @id = id + @sig = sig + end + + # Converts the event to a hash + # + # @api public + # + # @example Converting the event to a hash + # event.to_h + # + # @return [Hash] The event as a hash. + # + def to_h + { + id:, + pubkey:, + created_at:, + kind:, + tags:, + content:, + sig: + } + end + + # Compares two events. Returns true if all attributes are equal and false otherwise + # + # @api public + # + # @example + # event1 == event2 # => true + # + # @return [Boolean] True if all attributes are equal and false otherwise + # + def ==(other) + to_h == other.to_h + end + end +end diff --git a/lib/nostr/event_fragment.rb b/lib/nostr/event_fragment.rb new file mode 100644 index 0000000..b979778 --- /dev/null +++ b/lib/nostr/event_fragment.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Nostr + # Part of an +Event+. A complete +Event+ must have an +id+ and a +sig+. + class EventFragment + # 32-bytes hex-encoded public key of the event creator + # + # @api public + # + # @example + # event.pubkey # => '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e' + # + # @return [String] + # + attr_reader :pubkey + + # Date of the creation of the vent. A UNIX timestamp, in seconds + # + # @api public + # + # @example + # event.created_at # => 1230981305 + # + # @return [Integer] + # + attr_reader :created_at + + # The kind of the event. An integer from 0 to 2 + # + # @api public + # + # @example + # event.kind # => 1 + # + # @return [Integer] + # + attr_reader :kind + + # An array of tags. Each tag is an array of strings + # + # @api public + # + # @example Tags referencing an event + # event.tags #=> [["e", "event_id", "relay URL"]] + # + # @example Tags referencing a key + # event.tags #=> [["p", "event_id", "relay URL"]] + # + # @return [Array] + # + attr_reader :tags + + # An arbitrary string + # + # @api public + # + # @example + # event.content # => 'Your feedback is appreciated, now pay $8' + # + # @return [String] + # + attr_reader :content + + # Instantiates a new EventFragment + # + # @api public + # + # @example + # Nostr::EventFragment.new( + # pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + # created_at: 1230981305, + # kind: 1, + # tags: [['e', '189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408']], + # content: 'Your feedback is appreciated, now pay $8' + # ) + # + # @param pubkey [String] 32-bytes hex-encoded public key of the event creator. + # @param created_at [Integer] Date of the creation of the vent. A UNIX timestamp, in seconds. + # @param kind [Integer] The kind of the event. An integer from 0 to 2. + # @param tags [Array] An array of tags. Each tag is an array of strings. + # @param content [String] Arbitrary string. + # + def initialize(pubkey:, kind:, content:, created_at: Time.now.to_i, tags: []) + @pubkey = pubkey + @created_at = created_at + @kind = kind + @tags = tags + @content = content + end + + # Serializes the event fragment, to obtain a SHA256 hash of it + # + # @api public + # + # @example Converting the event to a hash + # event_fragment.serialize + # + # @return [Array] The event fragment as an array. + # + def serialize + [ + 0, + pubkey, + created_at, + kind, + tags, + content + ] + end + end +end diff --git a/lib/nostr/event_kind.rb b/lib/nostr/event_kind.rb new file mode 100644 index 0000000..3e25861 --- /dev/null +++ b/lib/nostr/event_kind.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Nostr + # Defines the event kinds that can be emitted by clients. + module EventKind + # The content is set to a stringified JSON object +{name: , about: , + # picture: }+ describing the user who created the event. A relay may delete past set_metadata + # events once it gets a new one for the same pubkey. + # + # @return [Integer] + # + SET_METADATA = 0 + + # The content is set to the text content of a note (anything the user wants to say). + # Non-plaintext notes should instead use kind 1000-10000 as described in NIP-16. + # + # @return [Integer] + # + TEXT_NOTE = 1 + + # The content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to + # recommend to its followers. + # + # @return [Integer] + # + RECOMMEND_SERVER = 2 + end +end diff --git a/lib/nostr/filter.rb b/lib/nostr/filter.rb new file mode 100644 index 0000000..68189eb --- /dev/null +++ b/lib/nostr/filter.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module Nostr + # A filter determines what events will be sent in a subscription. + class Filter + # A list of event ids or prefixes + # + # @api public + # + # @example + # filter.ids # => ['c24881c305c5cfb7c1168be7e9b0e150', '35deb2612efdb9e13e8b0ca4fc162341'] + # + # @return [Array, nil] + # + attr_reader :ids + + # A list of pubkeys or prefixes, the pubkey of an event must be one of these + # + # @api public + # + # @example + # filter.authors # => ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + # + # @return [Array, nil] + # + attr_reader :authors + + # A list of a kind numbers + # + # @api public + # + # @example + # filter.kinds # => [0, 1, 2] + # + # @return [Array, nil] + # + attr_reader :kinds + + # A list of event ids that are referenced in an "e" tag + # + # @api public + # + # @example + # filter.e # => ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'] + # + # @return [Array, nil] + # + attr_reader :e + + # A list of pubkeys that are referenced in a "p" tag + # + # @api public + # + # @example + # filter.p # => ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + # + # @return [Array, nil] + # + attr_reader :p + + # A timestamp, events must be newer than this to pass + # + # @api public + # + # @example + # filter.since # => 1230981305 + # + # @return [Integer, nil] + # + attr_reader :since + + # A timestamp, events must be older than this to pass + # + # @api public + # + # @example + # filter.until # => 1292190341 + # + # @return [Integer, nil] + # + attr_reader :until + + # Maximum number of events to be returned in the initial query + # + # @api public + # + # @example + # filter.limit # => 420 + # + # @return [Integer, nil] + # + attr_reader :limit + + # Instantiates a new Filter + # + # @api public + # + # @example + # Nostr::Filter.new( + # ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + # authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + # kinds: [0, 1, 2], + # since: 1230981305, + # until: 1292190341, + # e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + # p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + # ) + # + # @param kwargs [Hash] + # @option kwargs [Array, nil] ids A list of event ids or prefixes + # @option kwargs [Array, nil] authors A list of pubkeys or prefixes, the pubkey of an event must be one + # of these + # @option kwargs [Array, nil] kinds A list of a kind numbers + # @option kwargs [Array, nil] e A list of event ids that are referenced in an "e" tag + # @option kwargs [Array] p A list of pubkeys that are referenced in a "p" tag + # @option kwargs [Integer, nil] since A timestamp, events must be newer than this to pass + # @option kwargs [Integer, nil] until A timestamp, events must be older than this to pass + # @option kwargs [Integer, nil] limit Maximum number of events to be returned in the initial query + # + def initialize(**kwargs) + @ids = kwargs[:ids] + @authors = kwargs[:authors] + @kinds = kwargs[:kinds] + @e = kwargs[:e] + @p = kwargs[:p] + @since = kwargs[:since] + @until = kwargs[:until] + @limit = kwargs[:limit] + end + + # Converts the filter to a hash, removing all empty attributes + # + # @api public + # + # @example + # filter.to_h # => {:ids=>["c24881c305c5cfb7c1168be7e9b0e150"], + # :authors=>["000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7"], + # :kinds=>[0, 1, 2], + # :"#e"=>["7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2"], + # :"#p"=>["000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7"], + # :since=>1230981305, + # :until=>1292190341} + # + # @return [Hash] The filter as a hash. + # + def to_h + { + ids:, + authors:, + kinds:, + '#e': e, + '#p': p, + since:, + until: self.until, + limit: + }.compact + end + + # Compares two filters. Returns true if all attributes are equal and false otherwise + # + # @api public + # + # @example + # filter == filter # => true + # + # @return [Boolean] True if all attributes are equal and false otherwise + # + def ==(other) + to_h == other.to_h + end + end +end diff --git a/lib/nostr/key_pair.rb b/lib/nostr/key_pair.rb new file mode 100644 index 0000000..169ea48 --- /dev/null +++ b/lib/nostr/key_pair.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Nostr + # A pair of public and private keys + class KeyPair + # 32-bytes hex-encoded private key + # + # @api public + # + # @example + # keypair.private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' + # + # @return [String] + # + attr_reader :private_key + + # 32-bytes hex-encoded public key + # + # @api public + # + # @example + # keypair.public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' + # + # @return [String] + # + attr_reader :public_key + + # Instantiates a key pair + # + # @api public + # + # @example + # keypair = Nostr::KeyPair.new( + # private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', + # public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + # ) + # + # @param private_key [String] 32-bytes hex-encoded private key. + # @param public_key [String] 32-bytes hex-encoded public key. + # + def initialize(private_key:, public_key:) + @private_key = private_key + @public_key = public_key + end + end +end diff --git a/lib/nostr/keygen.rb b/lib/nostr/keygen.rb new file mode 100644 index 0000000..ea5a3fc --- /dev/null +++ b/lib/nostr/keygen.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'ecdsa' +require 'securerandom' + +module Nostr + # Generates private keys, public keys and key pairs. + class Keygen + # Instantiates a new keygen + # + # @api public + # + # @example + # keygen = Nostr::Keygen.new + # + def initialize + @group = ECDSA::Group::Secp256k1 + end + + # Generates a pair of private and public keys + # + # @api public + # + # @example + # keypair = keygen.generate_keypair + # keypair # # + # + # @return [KeyPair] An object containing a private key and a public key. + # + def generate_key_pair + private_key = generate_private_key + public_key = extract_public_key(private_key) + + KeyPair.new(private_key:, public_key:) + end + + # Generates a private key + # + # @api public + # + # @example + # private_key = keygen.generate_private_key + # private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' + # + # @return [String] A 32-bytes hex-encoded private key. + # + def generate_private_key + (SecureRandom.random_number(group.order - 1) + 1).to_s(16) + end + + # Extracts a public key from a private key + # + # @api public + # + # @example + # private_key = keygen.generate_private_key + # public_key = keygen.extract_public_key(private_key) + # public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' + # + # @return [String] A 32-bytes hex-encoded public key. + # + def extract_public_key(private_key) + group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16) + end + + private + + # The elliptic curve group. Used to generate public and private keys + # + # @api private + # + # @return [ECDSA::Group] + # + attr_reader :group + end +end diff --git a/lib/nostr/relay.rb b/lib/nostr/relay.rb new file mode 100644 index 0000000..106c438 --- /dev/null +++ b/lib/nostr/relay.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Nostr + # Relays expose a websocket endpoint to which clients can connect. + class Relay + # The websocket URL of the relay + # + # @api public + # + # @example + # relay.url # => 'wss://relay.damus.io' + # + # @return [String] + # + attr_reader :url + + # The name of the relay + # + # @api public + # + # @example + # relay.name # => 'Damus' + # + # @return [String] + # + attr_reader :name + + # Instantiates a new Relay + # + # @api public + # + # @example + # relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') + # + # @return [String] The websocket URL of the relay + # @return [String] The name of the relay + # + def initialize(url:, name:) + @url = url + @name = name + end + end +end diff --git a/lib/nostr/subscription.rb b/lib/nostr/subscription.rb new file mode 100644 index 0000000..06b9091 --- /dev/null +++ b/lib/nostr/subscription.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Nostr + # A subscription the result of a request to receive events from a relay + class Subscription + # A random string that should be used to represent a subscription + # + # @api public + # + # @example + # subscription.id # => 'c24881c305c5cfb7c1168be7e9b0e150' + # + # @return [String] + # + attr_reader :id + + # An object that determines what events will be sent in the subscription + # + # @api public + # + # @example + # subscription.filter # => #, + # @id="0dd7f3fa06cd5f797438dd0b7477f3c7"> + # + # @return [Filter] + # + attr_reader :filter + + # Initializes a subscription + # + # @api public + # + # @example Creating a subscription with no id and no filters + # subscription = Nostr::Subscription.new + # + # @example Creating a subscription with an ID + # subscription = Nostr::Subscription.new(id: 'c24881c305c5cfb7c1168be7e9b0e150') + # + # @example Subscribing to all events created after a certain time + # subscription = Nostr::Subscription.new(filter: Nostr::Filter.new(since: 1230981305)) + # + # @param id [String] A random string that should be used to represent a subscription + # @param filter [Filter] An object that determines what events will be sent in that subscription + # + def initialize(filter:, id: SecureRandom.hex) + @id = id + @filter = filter + end + + # Compares two subscriptions. Returns true if all attributes are equal and false otherwise + # + # @api public + # + # @example + # subscription1 == subscription1 # => true + # + # @return [Boolean] True if all attributes are equal and false otherwise + # + def ==(other) + id == other.id && filter == other.filter + end + end +end diff --git a/lib/nostr/user.rb b/lib/nostr/user.rb new file mode 100644 index 0000000..a14d425 --- /dev/null +++ b/lib/nostr/user.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'schnorr' +require 'json' + +module Nostr + # Each user has a keypair. Signatures, public key, and encodings are done according to the + # Schnorr signatures standard for the curve secp256k1. + class User + # A pair of private and public keys + # + # @api public + # + # @example + # user.keypair # # + # + # @return [KeyPair] + # + attr_reader :keypair + + # Instantiates a user + # + # @api public + # + # @example Creating a user with no keypair + # user = Nostr::User.new + # + # @example Creating a user with a keypair + # user = Nostr::User.new(keypair: keypair) + # + # @param keypair [Keypair] A pair of private and public keys + # @param keygen [Keygen] A private key and public key generator + # + def initialize(keypair: nil, keygen: Keygen.new) + @keypair = keypair || keygen.generate_key_pair + end + + # Builds an Event + # + # @api public + # + # @example Creating a note event + # event = user.create_event( + # kind: Nostr::EventKind::TEXT_NOTE, + # content: 'Your feedback is appreciated, now pay $8' + # ) + # + # @param event_attributes [Hash] + # @option event_attributes [String] :pubkey 32-bytes hex-encoded public key of the event creator. + # @option event_attributes [Integer] :created_at Date of the creation of the vent. A UNIX timestamp, in seconds. + # @option event_attributes [Integer] :kind The kind of the event. An integer from 0 to 2. + # @option event_attributes [Array] :tags An array of tags. Each tag is an array of strings. + # @option event_attributes [String] :content Arbitrary string. + # + # @return [Event] + # + def create_event(event_attributes) + event_fragment = EventFragment.new(**event_attributes.merge(pubkey: keypair.public_key)) + event_sha256 = Digest::SHA256.hexdigest(JSON.dump(event_fragment.serialize)) + + signature = sign(event_sha256) + + Event.new( + id: event_sha256, + pubkey: event_fragment.pubkey, + created_at: event_fragment.created_at, + kind: event_fragment.kind, + tags: event_fragment.tags, + content: event_fragment.content, + sig: signature + ) + end + + private + + # Signs an event with the user's private key + # + # @api private + # + # @param event_sha256 [String] The SHA256 hash of the event. + # + # @return [String] The signature of the event. + # + def sign(event_sha256) + hex_private_key = Array(keypair.private_key).pack('H*') + hex_message = Array(event_sha256).pack('H*') + Schnorr.sign(hex_message, hex_private_key).encode.unpack1('H*') + end + end +end diff --git a/lib/nostr/version.rb b/lib/nostr/version.rb index 32cfe8d..70f6991 100644 --- a/lib/nostr/version.rb +++ b/lib/nostr/version.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true module Nostr + # The version of the gem VERSION = '0.1.0' end diff --git a/nostr.gemspec b/nostr.gemspec index 979fbdb..403f2fa 100644 --- a/nostr.gemspec +++ b/nostr.gemspec @@ -31,6 +31,13 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.add_dependency 'bech32', '~> 1.3' + spec.add_dependency 'bip-schnorr', '~> 0.4' + spec.add_dependency 'ecdsa', '~> 1.2' + spec.add_dependency 'event_emitter', '~> 0.2' + spec.add_dependency 'faye-websocket', '~> 0.11' + spec.add_dependency 'json', '~> 2.6' + spec.add_development_dependency 'bundler-audit', '~> 0.9' spec.add_development_dependency 'dotenv', '~> 2.8' spec.add_development_dependency 'guard', '~> 2.18' @@ -40,6 +47,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'guard-rubocop', '~> 1.5' spec.add_development_dependency 'overcommit', '~> 0.59' spec.add_development_dependency 'pry', '~> 0.14' + spec.add_development_dependency 'puma', '~> 5.6' + spec.add_development_dependency 'rack', '~> 3.0' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.12' spec.add_development_dependency 'rubocop', '~> 1.42' diff --git a/spec/nostr/client_message_type_spec.rb b/spec/nostr/client_message_type_spec.rb new file mode 100644 index 0000000..ed455a4 --- /dev/null +++ b/spec/nostr/client_message_type_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::ClientMessageType do + describe '::EVENT' do + it 'is a string' do + expect(described_class::EVENT).to eq('EVENT') + end + end + + describe '::REQ' do + it 'is a string' do + expect(described_class::REQ).to eq('REQ') + end + end + + describe '::CLOSE' do + it 'is a string' do + expect(described_class::CLOSE).to eq('CLOSE') + end + end +end diff --git a/spec/nostr/client_spec.rb b/spec/nostr/client_spec.rb new file mode 100644 index 0000000..7943597 --- /dev/null +++ b/spec/nostr/client_spec.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# This test suite does not use let blocks because they don't work with EventMachine. +# +# EventMachine is a pain to work with, hence the use of sleep statements and instance variables. +# I'll come back to fix this once I'm more familiar with it. + +RSpec.describe Nostr::Client do + def server(port) + @echo_server = EchoServer.new + @echo_server.listen(port) + end + + def stop + @echo_server.stop + end + + let(:client) { described_class.new } + let(:relay) { Nostr::Relay.new(url: plain_text_url, name: 'localhost') } + + let(:port) { 4180 } + let(:plain_text_url) { "ws://0.0.0.0:#{port}/" } + + before { server port } + after { stop } + + describe '.new' do + it 'creates an instance of a relay' do + client = described_class.new + + expect(client).to be_an_instance_of(described_class) + end + end + + describe '#connect' do + it 'connects to the relay' do + connected = false + + client.on :connect do + connected = true + end + + client.connect(relay) + sleep 0.02 + + expect(connected).to be(true) + end + end + + describe '#on' do + context 'when the connection is opened' do + it 'fires the :connect event' do + connect_event_fired = false + + client.on :connect do + connect_event_fired = true + end + + client.connect(relay) + sleep 0.02 + + expect(connect_event_fired).to be(true) + end + end + + context 'when the client receives a message' do + it 'fires the :message event' do + received_message = nil + + client.on :message do |_message| + received_message = 'hello' + end + + client.connect(relay) + + sleep 0.1 + + @echo_server.send('hello') + + sleep 0.1 + + expect(received_message).to eq('hello') + end + end + + context "when there's a connection error" do + it 'fires the :error event' do + connection_error_event = nil + + client.on :error do |event| + connection_error_event = event + end + + relay = Nostr::Relay.new(url: 'musk', name: 'localhost') + + client.connect(relay) + + sleep 0.1 + + expect(connection_error_event).to eq('Network error: musk: musk is not a valid WebSocket URL') + end + end + + context 'when the connection is closed' do + it 'fires the :close event' do + connection_closed_code = nil + connection_closed_reason = nil + + client.on :close do |code, reason| + connection_closed_code = code + connection_closed_reason = reason + end + + client.connect(relay) + + sleep 0.1 + + @echo_server.close(1000, 'We are done') + + sleep 0.1 + + aggregate_failures do + expect(connection_closed_code).to eq(1000) + expect(connection_closed_reason).to eq('We are done') + end + end + end + end + + describe '#subscribe' do + context 'when given a subscription id' do + it 'sends a REQ message to the relay, asking for all events and returns a subscription with the same id' do + id = '16605b59b539f6e86762f28fb57db2fd' + + client = described_class.new + + sent_message = nil + subscription = nil + + client.on :message do |message| + sent_message = message + end + + client.on :connect do + subscription = client.subscribe(subscription_id: id) + end + + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client.connect(relay) + + sleep 0.01 + + aggregate_failures do + expect(sent_message).to eq('["REQ","16605b59b539f6e86762f28fb57db2fd",{}]') + expect(subscription).to eq(Nostr::Subscription.new(id:, filter: nil)) + end + end + end + + context 'when given a filter' do + it 'sends a REQ message to the relay, asking for filtered events and returns a subscription with same filter' do + allow(SecureRandom).to receive(:hex).and_return('16605b59b539f6e86762f28fb57db2fd') + filter = Nostr::Filter.new(since: 1_230_981_305) + + client = described_class.new + + sent_message = nil + subscription = nil + + client.on :message do |message| + sent_message = message + end + + client.on :connect do + subscription = client.subscribe(filter:) + end + + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client.connect(relay) + + sleep 0.01 + + aggregate_failures do + expect(sent_message).to eq('["REQ","16605b59b539f6e86762f28fb57db2fd",{"since":1230981305}]') + expect(subscription).to eq(Nostr::Subscription.new(id: '16605b59b539f6e86762f28fb57db2fd', filter:)) + end + end + end + + context 'when given a subscription id and a filter' do + it 'sends a REQ message to the relay, asking for filtered events and returns a subscription with the same id' do + id = '16605b59b539f6e86762f28fb57db2fd' + filter = Nostr::Filter.new(since: 1_230_981_305) + + client = described_class.new + + sent_message = nil + subscription = nil + + client.on :message do |message| + sent_message = message + end + + client.on :connect do + subscription = client.subscribe(subscription_id: id, filter:) + end + + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client.connect(relay) + + sleep 0.01 + + aggregate_failures do + expect(sent_message).to eq('["REQ","16605b59b539f6e86762f28fb57db2fd",{"since":1230981305}]') + expect(subscription).to eq(Nostr::Subscription.new(id:, filter:)) + end + end + end + + context 'when not given a subscription id nor a filter' do + it 'sends a REQ message to the relay, asking for all events and returns a subscription with a random id' do + allow(SecureRandom).to receive(:hex).and_return('16605b59b539f6e86762f28fb57db2fd') + + client = described_class.new + + sent_message = nil + subscription = nil + + client.on :message do |message| + sent_message = message + end + + client.on :connect do + subscription = client.subscribe + end + + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client.connect(relay) + + sleep 0.01 + + aggregate_failures do + expect(sent_message).to eq('["REQ","16605b59b539f6e86762f28fb57db2fd",{}]') + expect(subscription).to eq(Nostr::Subscription.new(id: '16605b59b539f6e86762f28fb57db2fd', filter: nil)) + end + end + end + end + + describe '#unsubscribe' do + it 'sends a CLOSE message to the relay, asking it to stop a subscription' do + subscription_id = '16605b59b539f6e86762f28fb57db2fd' + + client = described_class.new + + sent_message = nil + + client.on :message do |message| + sent_message = message + end + + client.on :connect do + client.unsubscribe(subscription_id:) + end + + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client.connect(relay) + + sleep 0.01 + + expect(sent_message).to eq(['CLOSE', { subscription_id: }].to_json) + end + end + + describe '#publish' do + it 'sends a message to the relay' do + relay = Nostr::Relay.new(url: plain_text_url, name: 'localhost') + client = described_class.new + event = Nostr::Event.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + + received_message = nil + + client.on :message do |message| + received_message = message + end + + client.on :connect do + client.publish(event) + end + + client.connect(relay) + + sleep 0.01 + + expect(received_message).to eq(['EVENT', event.to_h].to_json) + end + end +end diff --git a/spec/nostr/event_fragment_spec.rb b/spec/nostr/event_fragment_spec.rb new file mode 100644 index 0000000..8f8b272 --- /dev/null +++ b/spec/nostr/event_fragment_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::EventFragment do + let(:event_fragment) do + described_class.new( + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + created_at: 1_230_981_305, + kind: 1, + tags: [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + content: 'Your feedback is appreciated, now pay $8' + ) + end + + describe '.new' do + it 'creates an instance of an event fragment' do + event_fragment = described_class.new( + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + created_at: 1_230_981_305, + kind: 1, + tags: [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + content: 'Your feedback is appreciated, now pay $8' + ) + + expect(event_fragment).to be_an_instance_of(described_class) + end + end + + describe '#content' do + it 'exposes the event fragment content' do + expect(event_fragment.content).to eq('Your feedback is appreciated, now pay $8') + end + end + + describe '#created_at' do + it 'exposes the event fragment creation date' do + expect(event_fragment.created_at).to eq(1_230_981_305) + end + end + + describe '#kind' do + it 'exposes the event fragment kind' do + expect(event_fragment.kind).to eq(1) + end + end + + describe '#pubkey' do + it 'exposes the event fragment pubkey' do + expect(event_fragment.pubkey).to eq('ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460') + end + end + + describe '#serialize' do + it 'serializes the event fragment according to NIP-01' do + serialized_event_fragment = event_fragment.serialize + + expect(serialized_event_fragment).to eq( + [ + 0, + 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + 1_230_981_305, + 1, + [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + 'Your feedback is appreciated, now pay $8' + ] + ) + end + end + + describe '#tags' do + it 'exposes the event fragment tags' do + expect(event_fragment.tags).to eq( + [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ] + ) + end + end +end diff --git a/spec/nostr/event_kind_spec.rb b/spec/nostr/event_kind_spec.rb new file mode 100644 index 0000000..d83f36c --- /dev/null +++ b/spec/nostr/event_kind_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::EventKind do + describe '::SET_METADATA' do + it 'is an integer' do + expect(described_class::SET_METADATA).to eq(0) + end + end + + describe '::TEXT_NOTE' do + it 'is an integer' do + expect(described_class::TEXT_NOTE).to eq(1) + end + end + + describe '::RECOMMEND_SERVER' do + it 'is an integer' do + expect(described_class::RECOMMEND_SERVER).to eq(2) + end + end +end diff --git a/spec/nostr/event_spec.rb b/spec/nostr/event_spec.rb new file mode 100644 index 0000000..62f0980 --- /dev/null +++ b/spec/nostr/event_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Event do + let(:event) do + described_class.new( + id: '20f31a9b2a0ced48a167add9732ccade1dca5e34b44316e37da4af33bc8946a9', + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + created_at: 1_230_981_305, + kind: 1, + tags: [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + content: 'Your feedback is appreciated, now pay $8', + sig: '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + ) + end + + describe '#==' do + context 'when both events have the same attributes' do + it 'returns true' do + event1 = described_class.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + + event2 = described_class.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + + expect(event1).to eq(event2) + end + end + + context 'when both events have at least one different attribute' do + it 'returns false' do + event1 = described_class.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + + event2 = described_class.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + ) + + expect(event1).not_to eq(event2) + end + end + end + + describe '.new' do + it 'creates an instance of an event' do + event = described_class.new( + id: '20f31a9b2a0ced48a167add9732ccade1dca5e34b44316e37da4af33bc8946a9', + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + created_at: 1_230_981_305, + kind: 1, + tags: [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + content: 'Your feedback is appreciated, now pay $8', + sig: '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + ) + + expect(event).to be_an_instance_of(described_class) + end + end + + describe '#content' do + it 'exposes the event content' do + expect(event.content).to eq('Your feedback is appreciated, now pay $8') + end + end + + describe '#created_at' do + it 'exposes the event creation date' do + expect(event.created_at).to eq(1_230_981_305) + end + end + + describe '#kind' do + it 'exposes the event kind' do + expect(event.kind).to eq(1) + end + end + + describe '#id' do + it 'exposes the event id' do + expect(event.id).to eq('20f31a9b2a0ced48a167add9732ccade1dca5e34b44316e37da4af33bc8946a9') + end + end + + describe '#pubkey' do + it 'exposes the event pubkey' do + expect(event.pubkey).to eq('ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460') + end + end + + describe '#serialize' do + it 'serializes the event according to NIP-01' do + serialized_event = event.serialize + + expect(serialized_event).to eq( + [ + 0, + 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + 1_230_981_305, + 1, + [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ], + 'Your feedback is appreciated, now pay $8' + ] + ) + end + end + + describe '#sig' do + it 'exposes the event signature' do + expect(event.sig).to eq( + '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + ) + end + end + + describe '#tags' do + it 'exposes the event tags' do + expect(event.tags).to eq( + [ + %w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e] + ] + ) + end + end + + describe '#to_h' do + it 'converts the event to a hash' do + expect(event.to_h).to eq( + id: '20f31a9b2a0ced48a167add9732ccade1dca5e34b44316e37da4af33bc8946a9', + pubkey: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408], + %w[p 472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e]], + content: 'Your feedback is appreciated, now pay $8', + sig: '058613f8d34c053294cc28b7f9e1f8f0e80fd1ac94fb20f2da6ca514e7360b39' \ + '63d0086171f842ffebf1f7790ce147b4811a15ef3f59c76ec1324b970cc57ffe' + ) + end + end +end diff --git a/spec/nostr/filter_spec.rb b/spec/nostr/filter_spec.rb new file mode 100644 index 0000000..8026d46 --- /dev/null +++ b/spec/nostr/filter_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Filter do + let(:filter) do + described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + end + + describe '#==' do + context 'when both filters have the same attributes' do + it 'returns true' do + filter1 = described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + + filter2 = described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + + expect(filter1).to eq(filter2) + end + end + + context 'when both filters have at least one different attribute' do + it 'returns false' do + filter1 = described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + + filter2 = described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [1], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + + expect(filter1).not_to eq(filter2) + end + end + end + + describe '.new' do + it 'creates an instance of a filter' do + filter = described_class.new( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + since: 1_230_981_305, + until: 1_292_190_341, + e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] + ) + + expect(filter).to be_an_instance_of(described_class) + end + end + + describe '#ids' do + it 'exposes the filter ids' do + expect(filter.ids).to eq(['c24881c305c5cfb7c1168be7e9b0e150']) + end + end + + describe '#authors' do + it 'exposes the filter authors' do + expect(filter.authors).to eq(['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7']) + end + end + + describe '#kinds' do + it 'exposes the filter kinds' do + expect(filter.kinds).to eq([0, 1, 2]) + end + end + + describe '#since' do + it 'exposes the filter since' do + expect(filter.since).to eq(1_230_981_305) + end + end + + describe '#until' do + it 'exposes the filter until' do + expect(filter.until).to eq(1_292_190_341) + end + end + + describe '#e' do + it 'exposes the filter e' do + expect(filter.e).to eq(['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2']) + end + end + + describe '#p' do + it 'exposes the filter p' do + expect(filter.p).to eq(['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7']) + end + end + + describe '#to_h' do + it 'converts the filter to a hash' do + expect(filter.to_h).to eq( + ids: ['c24881c305c5cfb7c1168be7e9b0e150'], + authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + kinds: [0, 1, 2], + '#e': ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], + '#p': ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], + since: 1_230_981_305, + until: 1_292_190_341 + ) + end + end +end diff --git a/spec/nostr/key_pair_spec.rb b/spec/nostr/key_pair_spec.rb new file mode 100644 index 0000000..3c4cd91 --- /dev/null +++ b/spec/nostr/key_pair_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::KeyPair do + let(:keypair) do + described_class.new( + private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', + public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' + ) + end + + describe '.new' do + it 'creates an instance of a key pair' do + keypair = described_class.new( + private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', + public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' + ) + + expect(keypair).to be_an_instance_of(described_class) + end + end + + describe '#private_key' do + it 'exposes the private key' do + expect(keypair.private_key).to eq('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900') + end + end + + describe '#public_key' do + it 'exposes the public key' do + expect(keypair.public_key).to eq('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') + end + end +end diff --git a/spec/nostr/keygen_spec.rb b/spec/nostr/keygen_spec.rb new file mode 100644 index 0000000..0bc01c8 --- /dev/null +++ b/spec/nostr/keygen_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Keygen do + let(:keygen) { described_class.new } + + describe '.new' do + it 'creates an instance of a keygen' do + keygen = described_class.new + + expect(keygen).to be_an_instance_of(described_class) + end + end + + describe '#generate_key_pair' do + it 'generates a private/public key pair' do + keypair = keygen.generate_key_pair + + aggregate_failures do + expect(keypair.private_key).to match(/[a-f0-9]{64}/) + expect(keypair.public_key).to match(/[a-f0-9]{64}/) + end + end + end + + describe '#generate_private_key' do + it 'generates a private key' do + private_key = keygen.generate_private_key + + expect(private_key).to match(/[a-f0-9]{64}/) + end + end + + describe '#extract_public_key' do + it 'extracts a public key from a private key' do + private_key = '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' + public_key = keygen.extract_public_key(private_key) + + expect(public_key).to eq('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') + end + end +end diff --git a/spec/nostr/relay_spec.rb b/spec/nostr/relay_spec.rb new file mode 100644 index 0000000..7992cb6 --- /dev/null +++ b/spec/nostr/relay_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Relay do + let(:relay) do + described_class.new(url: 'wss://relay.damus.io', name: 'Damus') + end + + describe '.new' do + it 'creates an instance of a relay' do + relay = described_class.new(url: 'wss://relay.damus.io', name: 'Damus') + + expect(relay).to be_an_instance_of(described_class) + end + end + + describe '#name' do + it 'exposes the relay name' do + expect(relay.name).to eq('Damus') + end + end + + describe '#public_key' do + it 'exposes the relay URL' do + expect(relay.url).to eq('wss://relay.damus.io') + end + end +end diff --git a/spec/nostr/subscription_spec.rb b/spec/nostr/subscription_spec.rb new file mode 100644 index 0000000..509d1b4 --- /dev/null +++ b/spec/nostr/subscription_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::Subscription do + let(:filter) do + Nostr::Filter.new(since: 1_230_981_305) + end + + let(:subscription) do + described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) + end + + describe '#==' do + context 'when both subscriptions have the same attributes' do + it 'returns true' do + subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) + subscription2 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) + + expect(subscription1).to eq(subscription2) + end + end + + context 'when both subscriptions have a different id' do + it 'returns false' do + subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) + subscription2 = described_class.new(id: '16605b59b539f6e86762f28fb57db2fd', filter:) + + expect(subscription1).not_to eq(subscription2) + end + end + + context 'when both subscriptions have a different filter' do + let(:other_filter) do + Nostr::Filter.new(since: 1_230_981_305, until: 1_292_190_341) + end + + it 'returns false' do + subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) + subscription2 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter: other_filter) + + expect(subscription1).not_to eq(subscription2) + end + end + end + + describe '.new' do + context 'when no id is provided' do + it 'creates an instance of a subscription using a randomly generated id' do + allow(SecureRandom).to receive(:hex).and_return('a_random_string') + + subscription = described_class.new(filter:) + + expect(subscription.id).to eq('a_random_string') + end + end + + context 'when an id is provided' do + it 'creates an instance of a subscription using that ID' do + subscription = described_class.new( + id: 'c24881c305c5cfb7c1168be7e9b0e150', + filter: + ) + + expect(subscription.id).to eq('c24881c305c5cfb7c1168be7e9b0e150') + end + end + end + + describe '#filter' do + it 'exposes the subscription filter' do + expect(subscription.filter).to eq(filter) + end + end + + describe '#id' do + it 'exposes the subscription id' do + expect(subscription.id).to eq('c24881c305c5cfb7c1168be7e9b0e150') + end + end +end diff --git a/spec/nostr/user_spec.rb b/spec/nostr/user_spec.rb new file mode 100644 index 0000000..0d8413f --- /dev/null +++ b/spec/nostr/user_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Nostr::User do + let(:keypair) do + Nostr::KeyPair.new( + private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', + public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' + ) + end + + let(:user) do + described_class.new(keypair:) + end + + describe '.new' do + context 'when no key pair is provided' do + let(:nostr_keygen) do + instance_double(Nostr::Keygen, generate_key_pair: keypair) + end + + it 'creates an instance of a user with a new key pair' do + allow(Nostr::Keygen).to receive(:new).and_return(nostr_keygen) + + user = described_class.new + + aggregate_failures do + expect(user).to be_an_instance_of(described_class) + expect(user.keypair).to eq(keypair) + end + end + end + + context 'when a key pair is provided' do + it 'creates an instance of a user using the provided key pair' do + user = described_class.new(keypair:) + + expect(user.keypair).to eq(keypair) + end + end + end + + describe '#keypair' do + it 'exposes the user key pair' do + expect(user.keypair).to eq(keypair) + end + end + + describe '#create_event' do + context 'when created_at is missing' do + let(:now) { instance_double(Time, to_i: 1_230_981_305) } + + before { allow(Time).to receive(:now).and_return(now) } + + it 'builds and signs an event using the user key pair and the current time' do + event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8' + ) + + expect(event).to eq( + Nostr::Event.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + ) + end + end + + context 'when tags is missing' do + it 'builds and signs an event using the user key pair and empty tags' do + event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + tags: [], + created_at: 1_230_981_305, + content: 'Your feedback is appreciated, now pay $8' + ) + + expect(event).to eq( + Nostr::Event.new( + id: 'ed35331e66dc166109d45daff39b8c1bf83d4c0c7a59a9a8a23b240dc126d526', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [], + content: 'Your feedback is appreciated, now pay $8', + sig: 'f5a2cdc29723c888df52afd6f8c6e260110f74ed23fee3edbf39fff4a9f1b9f1' \ + 'c93284b02d4eba0481325bb5555624ddf969d5905b63f17191f9132a0ddd97b0' + ) + ) + end + end + + context 'when all attributes are present' do + it 'builds and signs an event using the user key pair' do + event = user.create_event( + created_at: 1_230_981_305, + kind: Nostr::EventKind::TEXT_NOTE, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8' + ) + + expect(event).to eq( + Nostr::Event.new( + id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', + pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', + created_at: 1_230_981_305, + kind: 1, + tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], + content: 'Your feedback is appreciated, now pay $8', + sig: '970fea8d213da86c583804522c45d04e61c18c433704b62f793f187bca82091c' \ + '3884d6207c6511c0966ecf6230082179a49257b03e5a4d2d08da9124a190f1bb' + ) + ) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2c1fcfe..9f469ed 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,6 +20,8 @@ end require 'nostr' +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' diff --git a/spec/support/echo_server.rb b/spec/support/echo_server.rb new file mode 100644 index 0000000..c8224ca --- /dev/null +++ b/spec/support/echo_server.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'puma' +require 'puma/binder' +require 'puma/events' + +class EchoServer + def call(env) + @socket = Faye::WebSocket.new(env, ['echo']) + + @socket.onmessage = lambda do |event| + @socket.send(event.data) + end + + @socket.rack_response + end + + def send(message) + @socket.send(message) + end + + def close(code, reason) + @socket.close(code, reason) + end + + def log(*args); end + + def listen(port) + events = Puma::Events.new(StringIO.new, StringIO.new) + binder = Puma::Binder.new(events) + binder.parse(["tcp://0.0.0.0:#{port}"], self) + @server = Puma::Server.new(self, events) + @server.binder = binder + @server.run + end + + def stop + case @server + when Puma::Server then @server.stop(true) + else @server.stop + end + end +end