├── .adr-dir ├── docs ├── .tool-versions ├── bun.lockb ├── .gitignore ├── subscriptions │ ├── updating-a-subscription.md │ ├── deleting-a-subscription.md │ ├── creating-a-subscription.md │ └── filtering-subscription-events.md ├── relays │ ├── receiving-events.md │ ├── connecting-to-a-relay.md │ └── publishing-events.md ├── package.json ├── getting-started │ ├── installation.md │ └── overview.md ├── events │ ├── text-note.md │ ├── set-metadata.md │ ├── contact-list.md │ ├── recommend-server.md │ └── encrypted-direct-message.md ├── implemented-nips.md ├── README.md ├── events.md ├── api-examples.md ├── core │ ├── user.md │ ├── client.md │ └── keys.md ├── markdown-examples.md ├── index.md ├── common-use-cases │ ├── signing-and-verifying-events.md │ ├── signing-and-verifying-messages.md │ ├── logging-and-debugging.md │ └── bech32-encoding-and-decoding-(NIP-19).md └── .vitepress │ └── config.mjs ├── .tool-versions ├── .rspec ├── sig ├── nostr │ ├── public_key.rbs │ ├── private_key.rbs │ ├── errors │ │ ├── error.rbs │ │ ├── key_validation_error.rbs │ │ ├── signature_validation_error.rbs │ │ ├── invalid_key_type_error.rbs │ │ ├── invalid_hrb_error.rbs │ │ ├── invalid_key_format_error.rbs │ │ ├── invalid_key_length_error.rbs │ │ ├── invalid_signature_type_error.rbs │ │ ├── invalid_signature_format_error.rbs │ │ └── invalid_signature_length_error.rbs │ ├── client │ │ ├── color_logger.rbs │ │ ├── plain_logger.rbs │ │ └── logger.rbs │ ├── client_message_type.rbs │ ├── relay_message_type.rbs │ ├── relay.rbs │ ├── event_kind.rbs │ ├── subscription.rbs │ ├── signature.rbs │ ├── key.rbs │ ├── events │ │ └── encrypted_direct_message.rbs │ ├── key_pair.rbs │ ├── user.rbs │ ├── keygen.rbs │ ├── filter.rbs │ ├── crypto.rbs │ ├── bech32.rbs │ ├── client.rbs │ └── event.rbs ├── nostr.rbs └── vendor │ ├── ecsda │ └── group │ │ └── secp256k1.rbs │ ├── schnorr.rbs │ ├── schnorr │ └── signature.rbs │ ├── event_emitter.rbs │ ├── event_machine │ └── channel.rbs │ ├── bech32 │ ├── nostr │ │ ├── nip19.rbs │ │ └── entity.rbs │ └── segwit_addr.rbs │ ├── faye │ ├── websocket.rbs │ └── websocket │ │ ├── api.rbs │ │ └── client.rbs │ ├── bech32.rbs │ └── event_machine.rbs ├── lib ├── nostr │ ├── version.rb │ ├── errors │ │ ├── error.rb │ │ ├── key_validation_error.rb │ │ ├── signature_validation_error.rb │ │ ├── invalid_signature_type_error.rb │ │ ├── invalid_signature_format_error.rb │ │ ├── invalid_signature_length_error.rb │ │ ├── invalid_key_type_error.rb │ │ ├── invalid_key_format_error.rb │ │ ├── invalid_key_length_error.rb │ │ └── invalid_hrp_error.rb │ ├── client_message_type.rb │ ├── errors.rb │ ├── relay_message_type.rb │ ├── relay.rb │ ├── public_key.rb │ ├── private_key.rb │ ├── client │ │ ├── plain_logger.rb │ │ ├── color_logger.rb │ │ └── logger.rb │ ├── event_kind.rb │ ├── signature.rb │ ├── subscription.rb │ ├── user.rb │ ├── events │ │ └── encrypted_direct_message.rb │ ├── key.rb │ ├── key_pair.rb │ ├── keygen.rb │ └── filter.rb └── nostr.rb ├── Gemfile ├── spec ├── nostr_spec.rb ├── nostr │ ├── errors │ │ ├── invalid_key_type_error_spec.rb │ │ ├── invalid_signature_length_error_spec.rb │ │ ├── invalid_signature_format_error_spec.rb │ │ ├── invalid_signature_type_error_spec.rb │ │ ├── invalid_key_length_error_spec.rb │ │ ├── invalid_key_format_error_spec.rb │ │ └── invalid_hrp_error_spec.rb │ ├── client_message_type_spec.rb │ ├── relay_spec.rb │ ├── event_kind_spec.rb │ ├── client │ │ ├── logger_spec.rb │ │ ├── plain_logger_spec.rb │ │ └── color_logger_spec.rb │ ├── signature_spec.rb │ ├── key_spec.rb │ ├── keygen_spec.rb │ ├── subscription_spec.rb │ ├── key_pair_spec.rb │ ├── public_key_spec.rb │ ├── private_key_spec.rb │ ├── events │ │ └── encrypted_direct_message_spec.rb │ ├── user_spec.rb │ ├── bech32_spec.rb │ ├── filter_spec.rb │ └── crypto_spec.rb ├── spec_helper.rb └── support │ └── echo_server.rb ├── bin ├── console └── setup ├── .editorconfig ├── Steepfile ├── .gitignore ├── .yardstick.yml ├── adr ├── 0001-record-architecture-decisions.md ├── 0004-default-logging-activation.md ├── 0005-logger-types.md ├── 0002-introduction-of-signature-class.md └── 0003-logging-methods-vs-logger-class.md ├── Guardfile ├── .github └── workflows │ └── main.yml ├── .rubocop_todo.yml ├── LICENSE.txt ├── Rakefile ├── .overcommit.yml ├── .rubocop.yml ├── nostr.gemspec └── CODE_OF_CONDUCT.md /.adr-dir: -------------------------------------------------------------------------------- 1 | adr 2 | -------------------------------------------------------------------------------- /docs/.tool-versions: -------------------------------------------------------------------------------- 1 | bun 1.1.10 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.0 2 | bun 1.1.3 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /docs/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilsonsilva/nostr/HEAD/docs/bun.lockb -------------------------------------------------------------------------------- /sig/nostr/public_key.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class PublicKey < Key 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /sig/nostr/private_key.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class PrivateKey < Key 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .vitepress/dist 3 | .vitepress/temp 4 | .vitepress/cache 5 | -------------------------------------------------------------------------------- /sig/nostr/errors/error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Error < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /sig/nostr/errors/key_validation_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class KeyValidationError < Error 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /sig/nostr/errors/signature_validation_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class SignatureValidationError < Error 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /sig/nostr.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /sig/nostr/client/color_logger.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Client 3 | class ColorLogger < Logger 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /sig/nostr/client/plain_logger.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Client 3 | class PlainLogger < Logger 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/nostr/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # The version of the gem 5 | VERSION = '0.7.0' 6 | end 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in nostr.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/nostr/errors/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Base error class 5 | class Error < StandardError 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/nostr/client_message_type.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module ClientMessageType 3 | EVENT: String 4 | REQ: String 5 | CLOSE: String 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_key_type_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidKeyTypeError < KeyValidationError 3 | def initialize: (String) -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_hrb_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidHRPError < KeyValidationError 3 | def initialize: (String, String) -> void 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_key_format_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidKeyFormatError < KeyValidationError 3 | def initialize: (String) -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_key_length_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidKeyLengthError < KeyValidationError 3 | def initialize: (String) -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_signature_type_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidSignatureTypeError < SignatureValidationError 3 | def initialize: -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nostr/relay_message_type.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module RelayMessageType 3 | EOSE: String 4 | EVENT: String 5 | NOTICE: String 6 | OK: String 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_signature_format_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidSignatureFormatError < SignatureValidationError 3 | def initialize: -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/nostr/errors/invalid_signature_length_error.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class InvalidSignatureLengthError < SignatureValidationError 3 | def initialize: -> void 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/vendor/ecsda/group/secp256k1.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module ECDSA 3 | class Group 4 | Secp256k1: Group 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/nostr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Nostr do 4 | it 'has a version number' do 5 | expect(described_class::VERSION).not_to be_nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'dotenv/load' 6 | require 'nostr' 7 | require 'pry' 8 | 9 | Pry.start 10 | -------------------------------------------------------------------------------- /lib/nostr/errors/key_validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Base class for all key validation errors 5 | class KeyValidationError < Error; end 6 | end 7 | -------------------------------------------------------------------------------- /lib/nostr/errors/signature_validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Base class for all signature validation errors 5 | class SignatureValidationError < Error; end 6 | end 7 | -------------------------------------------------------------------------------- /sig/nostr/relay.rbs: -------------------------------------------------------------------------------- 1 | # Classes 2 | module Nostr 3 | class Relay 4 | attr_reader url: String 5 | attr_reader name: String 6 | 7 | def initialize: (url: String, name: String) -> void 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/nostr/event_kind.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module EventKind 3 | SET_METADATA: Integer 4 | TEXT_NOTE: Integer 5 | RECOMMEND_SERVER: Integer 6 | CONTACT_LIST: Integer 7 | ENCRYPTED_DIRECT_MESSAGE: Integer 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /docs/subscriptions/updating-a-subscription.md: -------------------------------------------------------------------------------- 1 | # Updating a subscription 2 | 3 | Updating a subscription is done by creating a new subscription with the same id as the previous one. See 4 | [creating a subscription](./creating-a-subscription.md) for more information. 5 | -------------------------------------------------------------------------------- /sig/nostr/subscription.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Subscription 3 | attr_reader id: String 4 | attr_reader filter: Filter 5 | 6 | def initialize: (filter: Filter, ?id: String) -> void 7 | def ==: (Subscription other) -> bool 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Unix-style newlines with a newline ending every file 2 | [*] 3 | end_of_line = lf 4 | insert_final_newline = true 5 | max_line_length = 120 6 | trim_trailing_whitespace = true 7 | 8 | # 2 space indentation 9 | [{*.rb, *.mjs}] 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | target :lib do 4 | signature 'sig' 5 | 6 | check 'lib' 7 | 8 | # Core libraries 9 | library 'base64' 10 | library 'digest' 11 | library 'openssl' 12 | library 'securerandom' 13 | 14 | # Gems 15 | library 'json' 16 | end 17 | -------------------------------------------------------------------------------- /sig/nostr/signature.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Signature < String 3 | FORMAT: Regexp 4 | LENGTH: int 5 | 6 | def initialize: (String) -> void 7 | 8 | private 9 | 10 | attr_reader hex_value: String 11 | 12 | def validate: (String) -> nil 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /docs/relays/receiving-events.md: -------------------------------------------------------------------------------- 1 | # Receiving events 2 | 3 | To receive events from Relays, you must create a subscription on the relay. A subscription is a filter that defines the 4 | events you want to receive. 5 | 6 | For more information, read the [Subscription](../subscriptions/creating-a-subscription.md) section. 7 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev", 4 | "docs:build": "vitepress build", 5 | "docs:preview": "vitepress preview" 6 | }, 7 | "devDependencies": { 8 | "mermaid": "^10.9.1", 9 | "vitepress": "^1.2.2", 10 | "vitepress-plugin-mermaid": "^2.0.16" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sig/nostr/key.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Key < String 3 | FORMAT: Regexp 4 | LENGTH: int 5 | 6 | def self.from_bech32: (String) -> Key 7 | def self.hrp: -> String 8 | 9 | def initialize: (String) -> void 10 | def to_bech32: -> String 11 | 12 | private 13 | 14 | def validate_hex_value: (String) -> nil 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /sig/vendor/schnorr.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Schnorr 3 | def self.sign: (String message, String private_key, ?String aux_rand) -> Signature 4 | def self.valid_sig?: (String message, String public_key, String signature) -> bool 5 | def self.check_sig!: (String message, String public_key, String signature) -> bool 6 | end 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | if ! which overcommit >/dev/null; then 9 | echo 'The gem overcommit is not installed. It is necessary to lint the git history through git hooks.' 10 | echo 'Please install overcommit and run this script again.' 11 | exit 1 12 | fi 13 | 14 | overcommit --install 15 | -------------------------------------------------------------------------------- /sig/nostr/events/encrypted_direct_message.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module Events 3 | class EncryptedDirectMessage < Event 4 | def initialize: ( 5 | plain_text: String, 6 | sender_private_key: PrivateKey, 7 | recipient_public_key: PublicKey, 8 | ?previous_direct_message: String|nil 9 | ) -> void 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /sig/nostr/key_pair.rbs: -------------------------------------------------------------------------------- 1 | # Classes 2 | module Nostr 3 | class KeyPair 4 | attr_reader private_key: PrivateKey 5 | attr_reader public_key: PublicKey 6 | 7 | def initialize: (private_key: PrivateKey, public_key: PublicKey) -> void 8 | def to_ary: -> [PrivateKey, PublicKey] 9 | 10 | private 11 | 12 | def validate_keys: (PrivateKey, PublicKey) -> void 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /sig/nostr/client/logger.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Client 3 | class Logger 4 | def attach_to: (Client client) -> void 5 | def on_connect: (Relay relay) -> void 6 | def on_message: (String message) -> void 7 | def on_send: (String message) -> void 8 | def on_error: (String message) -> void 9 | def on_close: (String code, String reason) -> void 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /.yardocck 5 | /coverage/ 6 | /doc/ 7 | /measurements/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | /Gemfile.lock 15 | 16 | # Local dotenv files 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env.local 21 | 22 | # A local copy of nostr nips to help Github Copilot suggestions 23 | /nips/ 24 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_key_type_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidKeyTypeError do 6 | describe '#initialize' do 7 | let(:key_kind) { 'private' } 8 | let(:error) { described_class.new(key_kind) } 9 | 10 | it 'builds a useful error message' do 11 | expect(error.message).to eq('Invalid private key type') 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_signature_length_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidSignatureLengthError do 6 | describe '#initialize' do 7 | let(:error) { described_class.new } 8 | 9 | it 'builds a useful error message' do 10 | expect(error.message).to eq('Invalid signature length. It should have 128 characters.') 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_signature_format_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidSignatureFormatError do 6 | describe '#initialize' do 7 | let(:error) { described_class.new } 8 | 9 | it 'builds a useful error message' do 10 | expect(error.message).to eq('Only lowercase hexadecimal characters are allowed in signatures.') 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /docs/subscriptions/deleting-a-subscription.md: -------------------------------------------------------------------------------- 1 | # Stop previous subscriptions 2 | 3 | You can stop receiving messages from a subscription by calling 4 | [`Nostr::Client#unsubscribe`](https://www.rubydoc.info/gems/nostr/Nostr/Client#unsubscribe-instance_method) with the 5 | ID of the subscription you want to stop receiving messages from: 6 | 7 | ```ruby 8 | client.unsubscribe('your-subscription-id') 9 | client.unsubscribe(subscription.id) 10 | ``` 11 | -------------------------------------------------------------------------------- /sig/nostr/user.rbs: -------------------------------------------------------------------------------- 1 | # Classes 2 | module Nostr 3 | class User 4 | attr_reader keypair: KeyPair 5 | 6 | def initialize: (?keypair: KeyPair | nil, ?keygen: Keygen) -> void 7 | def create_event: ( 8 | kind: Integer, 9 | content: String, 10 | ?created_at: Integer, 11 | ?tags: Array[Array[String]], 12 | ) -> Event 13 | 14 | private 15 | 16 | def sign: (String event_sha256) -> String 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_signature_type_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidSignatureTypeError do 6 | describe '#initialize' do 7 | let(:error) { described_class.new } 8 | 9 | it 'builds a useful error message' do 10 | expect(error.message).to eq('Invalid signature type. It must be a string with lowercase hexadecimal characters.') 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the gem and add to the application's Gemfile by executing: 4 | 5 | ```shell 6 | bundle add nostr 7 | ``` 8 | 9 | If bundler is not being used to manage dependencies, install the gem by executing: 10 | 11 | ```shell 12 | gem install nostr 13 | ``` 14 | 15 | ## Requiring the gem 16 | 17 | All examples in this guide assume that the gem has been required: 18 | 19 | ```ruby 20 | require 'nostr' 21 | ``` 22 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_key_length_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidKeyLengthError do 6 | describe '#initialize' do 7 | let(:key_kind) { 'private' } 8 | let(:error) { described_class.new(key_kind) } 9 | 10 | it 'builds a useful error message' do 11 | expect(error.message).to eq('Invalid private key length. It should have 64 characters.') 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/nostr/client_message_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Clients can send 3 types of messages, which must be JSON arrays 5 | module ClientMessageType 6 | # @return [String] Used to publish events 7 | EVENT = 'EVENT' 8 | 9 | # @return [String] Used to request events and subscribe to new updates 10 | REQ = 'REQ' 11 | 12 | # @return [String] Used to stop previous subscriptions 13 | CLOSE = 'CLOSE' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/vendor/schnorr/signature.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Schnorr 3 | class InvalidSignatureError < StandardError 4 | end 5 | 6 | class Signature 7 | attr_reader r: Integer 8 | attr_reader s: Integer 9 | 10 | def self.decode: (String string) -> Signature 11 | 12 | def initialize: (Integer r, Integer s) -> void 13 | def encode: -> String 14 | def ==: (untyped other) -> bool 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_key_format_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidKeyFormatError do 6 | describe '#initialize' do 7 | let(:key_kind) { 'private' } 8 | let(:error) { described_class.new(key_kind) } 9 | 10 | it 'builds a useful error message' do 11 | expect(error.message).to eq('Only lowercase hexadecimal characters are allowed in private keys.') 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /docs/events/text-note.md: -------------------------------------------------------------------------------- 1 | # Text Note 2 | 3 | In the `Text Note` event, the `content` is set to the plaintext content of a note (anything the user wants to say). 4 | Content that must be parsed, such as Markdown and HTML, should not be used. 5 | 6 | ## Sending a text note event 7 | 8 | ```ruby 9 | text_note_event = user.create_event( 10 | kind: Nostr::EventKind::TEXT_NOTE, 11 | content: 'Your feedback is appreciated, now pay $8' 12 | ) 13 | 14 | client.publish(text_note_event) 15 | ``` 16 | -------------------------------------------------------------------------------- /sig/nostr/keygen.rbs: -------------------------------------------------------------------------------- 1 | # Classes 2 | module Nostr 3 | class Keygen 4 | def initialize: -> void 5 | def generate_key_pair: -> KeyPair 6 | def generate_private_key: -> PrivateKey 7 | def extract_public_key: (PrivateKey private_key) -> PublicKey 8 | def get_key_pair_from_private_key: (PrivateKey private_key) -> KeyPair 9 | 10 | private 11 | 12 | attr_reader group: untyped 13 | 14 | def validate_private_key: (PrivateKey private_key) -> void 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_signature_type_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the signature is not a string 5 | # 6 | # @api public 7 | # 8 | class InvalidSignatureTypeError < SignatureValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidSignatureTypeError.new 13 | # 14 | def initialize = super('Invalid signature type. It must be a string with lowercase hexadecimal characters.') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.yardstick.yml: -------------------------------------------------------------------------------- 1 | threshold: 100 2 | rules: 3 | ApiTag::Presence: 4 | enabled: true 5 | ApiTag::Inclusion: 6 | enabled: true 7 | ApiTag::ProtectedMethod: 8 | enabled: true 9 | ApiTag::PrivateMethod: 10 | enabled: true 11 | ExampleTag: 12 | enabled: true 13 | ReturnTag: 14 | enabled: true 15 | Summary::Presence: 16 | enabled: true 17 | Summary::Length: 18 | enabled: false 19 | Summary::Delimiter: 20 | enabled: true 21 | Summary::SingleLine: 22 | enabled: false 23 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_signature_format_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the signature is in an invalid format 5 | # 6 | # @api public 7 | # 8 | class InvalidSignatureFormatError < SignatureValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidSignatureFormatError.new 13 | # 14 | def initialize 15 | super('Only lowercase hexadecimal characters are allowed in signatures.') 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_signature_length_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the signature's length is not 128 characters 5 | # 6 | # @api public 7 | # 8 | class InvalidSignatureLengthError < SignatureValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidSignatureLengthError.new 13 | # 14 | def initialize 15 | super('Invalid signature length. It should have 128 characters.') 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/nostr/errors/invalid_hrp_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::InvalidHRPError do 6 | describe '#initialize' do 7 | let(:given_hrp) { 'nwrong' } 8 | let(:allowed_hrp) { 'nsec' } 9 | let(:error) { described_class.new(given_hrp, allowed_hrp) } 10 | 11 | it 'builds a useful error message' do 12 | expect(error.message).to eq("Invalid hrp: nwrong. The allowed hrp value for this kind of entity is 'nsec'.") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_key_type_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the private key is not a string 5 | # 6 | # @api public 7 | # 8 | class InvalidKeyTypeError < KeyValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidKeyTypeError.new('private') 13 | # 14 | # @param [String] key_kind The kind of key that is invalid (public or private) 15 | # 16 | def initialize(key_kind) = super("Invalid #{key_kind} key type") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /sig/nostr/filter.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Filter 3 | attr_reader ids: Array[String] 4 | attr_reader authors: Array[String] 5 | attr_reader kinds: Array[Integer] 6 | attr_reader e: Array[String] 7 | attr_reader p: Array[String] 8 | attr_reader since: Integer 9 | attr_reader until: Integer 10 | attr_reader limit: Integer 11 | 12 | def initialize: (**untyped) -> void 13 | def to_h: -> Hash[::Symbol, (::Array[::String] | ::Array[::Integer] | ::Integer)] 14 | def ==: (Filter other) -> bool 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /sig/vendor/event_emitter.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module EventEmitter 3 | def self.included: (Module) -> void 4 | def self.apply: (untyped) -> void 5 | 6 | def __events: () -> Array[untyped] 7 | 8 | def add_listener: (Symbol | String type, ?Hash[untyped, untyped] params) { (*untyped) -> void } -> Integer 9 | alias on add_listener 10 | alias once add_listener 11 | 12 | def remove_listener: (Integer | Symbol | String id_or_type) -> void 13 | def emit: (Symbol | String type, *untyped data) -> void 14 | end 15 | -------------------------------------------------------------------------------- /spec/nostr/client_message_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::ClientMessageType do 6 | describe '::EVENT' do 7 | it 'is a string' do 8 | expect(described_class::EVENT).to eq('EVENT') 9 | end 10 | end 11 | 12 | describe '::REQ' do 13 | it 'is a string' do 14 | expect(described_class::REQ).to eq('REQ') 15 | end 16 | end 17 | 18 | describe '::CLOSE' do 19 | it 'is a string' do 20 | expect(described_class::CLOSE).to eq('CLOSE') 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/nostr/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'errors/error' 4 | require_relative 'errors/key_validation_error' 5 | require_relative 'errors/invalid_hrp_error' 6 | require_relative 'errors/invalid_key_type_error' 7 | require_relative 'errors/invalid_key_length_error' 8 | require_relative 'errors/invalid_key_format_error' 9 | require_relative 'errors/signature_validation_error' 10 | require_relative 'errors/invalid_signature_type_error' 11 | require_relative 'errors/invalid_signature_length_error' 12 | require_relative 'errors/invalid_signature_format_error' 13 | -------------------------------------------------------------------------------- /sig/vendor/event_machine/channel.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module EventMachine 3 | class Channel 4 | @subs: Hash[untyped, untyped] 5 | @uid: Integer 6 | 7 | def initialize: -> void 8 | def num_subscribers: -> Integer 9 | def subscribe: (*untyped a) ?{ (untyped) -> untyped } -> Integer 10 | def unsubscribe: (untyped name) -> untyped 11 | def push: (*untyped items) -> untyped 12 | alias << push 13 | def pop: (*untyped a) -> untyped 14 | 15 | private 16 | def gen_id: -> Integer 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /adr/0001-record-architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # 1. Record architecture decisions 2 | 3 | Date: 2024-03-13 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | We need to record the architectural decisions made on this project. 12 | 13 | ## Decision 14 | 15 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 16 | 17 | ## Consequences 18 | 19 | See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). 20 | -------------------------------------------------------------------------------- /docs/implemented-nips.md: -------------------------------------------------------------------------------- 1 | # Implemented NIPs 2 | 3 | NIPs stand for Nostr Implementation Possibilities. They exist to document what may be implemented by Nostr-compatible 4 | relay and client software. 5 | 6 | - [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) 7 | - [NIP-02: Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) 8 | - [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) 9 | - [NIP-19: Bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md) 10 | -------------------------------------------------------------------------------- /lib/nostr/relay_message_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Clients can send 4 types of messages, which must be JSON arrays 5 | module RelayMessageType 6 | # @return [String] Used to notify clients all stored events have been sent 7 | EOSE = 'EOSE' 8 | 9 | # @return [String] Used to send events requested to clients 10 | EVENT = 'EVENT' 11 | 12 | # @return [String] Used to send human-readable messages to clients 13 | NOTICE = 'NOTICE' 14 | 15 | # @return [String] Used to notify clients if an EVENT was successful 16 | OK = 'OK' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :bundler do 4 | watch('nostr.gemspec') 5 | end 6 | 7 | guard :bundler_audit, run_on_start: true do 8 | watch('Gemfile.lock') 9 | end 10 | 11 | group :tests do 12 | guard :rspec, all_on_start: true, cmd: 'COVERAGE=false bundle exec rspec --format progress' do 13 | watch(%r{^spec/.+_spec\.rb$}) 14 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 15 | watch('spec/spec_helper.rb') { 'spec' } 16 | end 17 | end 18 | 19 | guard :rubocop do 20 | watch(/.+\.rb$/) 21 | watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } 22 | end 23 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_key_format_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the private key is in an invalid format 5 | # 6 | # @api public 7 | # 8 | class InvalidKeyFormatError < KeyValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidKeyFormatError.new('private') 13 | # 14 | # @param [String] key_kind The kind of key that is invalid (public or private) 15 | # 16 | def initialize(key_kind) 17 | super("Only lowercase hexadecimal characters are allowed in #{key_kind} keys.") 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_key_length_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the private key's length is not 64 characters 5 | # 6 | # @api public 7 | # 8 | class InvalidKeyLengthError < KeyValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidKeyLengthError.new('private') 13 | # 14 | # @param [String] key_kind The kind of key that is invalid (public or private) 15 | # 16 | def initialize(key_kind) 17 | super("Invalid #{key_kind} key length. It should have 64 characters.") 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /sig/vendor/bech32/nostr/nip19.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Bech32 3 | module Nostr 4 | module NIP19 5 | HRP_PUBKEY: String 6 | HRP_PRIVATE_KEY: String 7 | HRP_NOTE_ID: String 8 | HRP_PROFILE: String 9 | HRP_EVENT: String 10 | HRP_RELAY: String 11 | HRP_EVENT_COORDINATE: String 12 | BARE_PREFIXES: [String, String, String] 13 | TLV_PREFIXES: [String, String, String, String] 14 | ALL_PREFIXES: Array[String] 15 | 16 | def decode: (untyped string) -> untyped 17 | def self.decode: (untyped string) -> untyped 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /sig/nostr/crypto.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Crypto 3 | BN_BASE: 0 | 2 | 10 | 16 4 | CIPHER_CURVE: String 5 | CIPHER_ALGORITHM: String 6 | 7 | def encrypt_text: (PrivateKey, PublicKey, String) -> String 8 | def decrypt_text: (PrivateKey, PublicKey, String) -> String 9 | def sign_event: (Event, PrivateKey) -> Event 10 | def sign_message: (String, PrivateKey) -> Signature 11 | def valid_sig?: (String, PublicKey, Signature) -> bool 12 | def check_sig!: (String, PublicKey, Signature) -> bool 13 | 14 | private 15 | 16 | def compute_shared_key: (PrivateKey, PublicKey) -> String 17 | def hash_event:(Event) -> String 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /docs/events/set-metadata.md: -------------------------------------------------------------------------------- 1 | # Set Metadata 2 | 3 | In the `Metadata` event, the `content` is set to a stringified JSON object 4 | `{name: , about: , picture: }` describing the [user](../core/user) who created the event. A relay may 5 | delete older events once it gets a new one for the same pubkey. 6 | 7 | ## Setting the user's metadata 8 | 9 | ```ruby 10 | metadata_event = user.create_event( 11 | kind: Nostr::EventKind::SET_METADATA, 12 | content: { 13 | name: 'Wilson Silva', 14 | about: 'Used to make hydrochloric acid bombs in high school.', 15 | picture: 'https://thispersondoesnotexist.com/' 16 | } 17 | ) 18 | 19 | client.publish(metadata_event) 20 | ``` 21 | -------------------------------------------------------------------------------- /sig/vendor/bech32/segwit_addr.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Bech32 3 | class SegwitAddr 4 | HRP_MAINNET: String 5 | HRP_TESTNET: String 6 | HRP_REGTEST: String 7 | 8 | attr_accessor hrp: String 9 | attr_accessor ver: (Float | Integer | String)? 10 | attr_accessor prog: Array[(Float | Integer | String)?] 11 | 12 | def initialize: (?nil addr) -> void 13 | def to_script_pubkey: -> ((Float | Integer | String)?) 14 | def script_pubkey=: (untyped script_pubkey) -> (Array[(Float | Integer | String)?]) 15 | def addr: -> untyped 16 | 17 | private 18 | 19 | def parse_addr: (untyped addr) -> nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/nostr/bech32.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | module Bech32 3 | # Perhaps a bug in RBS/Steep. +decode+ and +encode+ are not recognized as public class methods. 4 | def self?.decode: (String data) -> [String, String] 5 | def self?.encode: (hrp: String, data: String) -> String 6 | 7 | def naddr_encode: (pubkey: PublicKey, ?relays: Array[String], ?kind: Integer, ?identifier: String) -> String 8 | def nevent_encode: (id: PublicKey, ?relays: Array[String], ?kind: Integer) -> String 9 | def nprofile_encode: (pubkey: PublicKey, ?relays: Array[String]) -> String 10 | def npub_encode: (String npub) -> String 11 | def nrelay_encode: (String nrelay) -> String 12 | def nsec_encode: (String nsec) -> String 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/nostr/errors/invalid_hrp_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Raised when the human readable part of a Bech32 string is invalid 5 | # 6 | # @api public 7 | # 8 | class InvalidHRPError < KeyValidationError 9 | # Initializes the error 10 | # 11 | # @example 12 | # InvalidHRPError.new('example wrong hrp', 'nsec') 13 | # 14 | # @param given_hrp [String] The given human readable part of the Bech32 string 15 | # @param allowed_hrp [String] The allowed human readable part of the Bech32 string 16 | # 17 | def initialize(given_hrp, allowed_hrp) 18 | super("Invalid hrp: #{given_hrp}. The allowed hrp value for this kind of entity is '#{allowed_hrp}'.") 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.3.0' 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Test and report the code coverage 27 | uses: paambaati/codeclimate-action@v3.2.0 28 | env: 29 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 30 | with: 31 | coverageCommand: bundle exec rspec 32 | -------------------------------------------------------------------------------- /spec/nostr/relay_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Relay do 6 | let(:relay) do 7 | described_class.new(url: 'wss://relay.damus.io', name: 'Damus') 8 | end 9 | 10 | describe '.new' do 11 | it 'creates an instance of a relay' do 12 | relay = described_class.new(url: 'wss://relay.damus.io', name: 'Damus') 13 | 14 | expect(relay).to be_an_instance_of(described_class) 15 | end 16 | end 17 | 18 | describe '#name' do 19 | it 'exposes the relay name' do 20 | expect(relay.name).to eq('Damus') 21 | end 22 | end 23 | 24 | describe '#public_key' do 25 | it 'exposes the relay URL' do 26 | expect(relay.url).to eq('wss://relay.damus.io') 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sig/nostr/client.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Client 3 | include EventEmitter 4 | 5 | def initialize: -> void 6 | def connect: (Relay relay) -> Thread 7 | def subscribe: (?subscription_id: String, ?filter: Filter) -> Subscription 8 | def unsubscribe: (String subscription_id) -> void 9 | def publish: (Event event) -> untyped 10 | 11 | private 12 | 13 | attr_reader logger: Logger 14 | attr_reader subscriptions: Hash[String, Subscription] 15 | attr_reader parent_to_child_channel: EventMachine::Channel 16 | attr_reader child_to_parent_channel: EventMachine::Channel 17 | 18 | def execute_within_an_em_thread: { -> void } -> Thread 19 | def initialize_channels: -> void 20 | def build_websocket_client: (String relay_name) -> Faye::WebSocket::Client 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pry' 4 | require 'simplecov' 5 | require 'simplecov-console' 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 8 | [ 9 | SimpleCov::Formatter::HTMLFormatter, 10 | SimpleCov::Formatter::Console 11 | ] 12 | ) 13 | 14 | unless ENV['COVERAGE'] == 'false' 15 | SimpleCov.start do 16 | root 'lib' 17 | coverage_dir "#{Dir.pwd}/coverage" 18 | end 19 | end 20 | 21 | require 'nostr' 22 | 23 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 24 | 25 | RSpec.configure do |config| 26 | # Enable flags like --only-failures and --next-failure 27 | config.example_status_persistence_file_path = '.rspec_status' 28 | 29 | # Disable RSpec exposing methods globally on `Module` and `main` 30 | config.disable_monkey_patching! 31 | 32 | config.expect_with :rspec do |c| 33 | c.syntax = :expect 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2023-01-12 10:00:10 UTC using RuboCop version 1.42.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | # Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes. 11 | Metrics/AbcSize: 12 | Max: 24 13 | 14 | # Offense count: 2 15 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods. 16 | Metrics/MethodLength: 17 | Max: 16 18 | 19 | # Offense count: 4 20 | # Configuration parameters: AssignmentOnly. 21 | RSpec/InstanceVariable: 22 | Exclude: 23 | - 'spec/nostr/client_spec.rb' 24 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Nostr Docs 2 | 3 | VitePress-powered documentation for the Nostr Ruby gem. 4 | 5 | ## Live Demo 6 | 7 | https://nostr-ruby.com/ 8 | 9 | ## Development 10 | 11 | ### Requirements 12 | 13 | - [Bun](https://bun.sh/) 14 | 15 | ### Installation 16 | 17 | ```shell 18 | bun install 19 | ``` 20 | 21 | ### Tasks 22 | 23 | The `docs:dev` script will start a local dev server with instant hot updates. Run it with the following command: 24 | 25 | ```shell 26 | bun run docs:dev 27 | ``` 28 | 29 | Run this command to build the docs: 30 | 31 | ```shell 32 | bun run docs:build 33 | ``` 34 | 35 | Once built, preview it locally by running: 36 | 37 | ```shell 38 | bun run docs:preview 39 | ``` 40 | 41 | The preview command will boot up a local static web server that will serve the output directory .`vitepress/dist` at 42 | http://localhost:4173. You can use this to make sure everything looks good before pushing to production. 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/relays/connecting-to-a-relay.md: -------------------------------------------------------------------------------- 1 | # Connecting to a Relay 2 | 3 | You must connect your nostr [Client](../core/client) to a relay in order to send and receive [Events](../events). 4 | Instantiate a [`Nostr::Client`](https://www.rubydoc.info/gems/nostr/Nostr/Client) and a 5 | [`Nostr::Relay`](https://www.rubydoc.info/gems/nostr/Nostr/Relay) giving it the `url` of your relay. The `name` 6 | attribute is just descriptive. 7 | Calling [`Client#connect`](https://www.rubydoc.info/gems/nostr/Nostr/Client#connect-instance_method) attempts to 8 | establish a WebSocket connection between the Client and the Relay. 9 | 10 | ```ruby 11 | client = Nostr::Client.new 12 | relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') 13 | 14 | # Listen for the connect event 15 | client.on :connect do 16 | # When this block executes, you're connected to the relay 17 | end 18 | 19 | # Connect to a relay asynchronously 20 | client.connect(relay) 21 | ``` 22 | -------------------------------------------------------------------------------- /lib/nostr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'nostr/errors' 4 | require_relative 'nostr/bech32' 5 | require_relative 'nostr/crypto' 6 | require_relative 'nostr/version' 7 | require_relative 'nostr/keygen' 8 | require_relative 'nostr/client_message_type' 9 | require_relative 'nostr/filter' 10 | require_relative 'nostr/subscription' 11 | require_relative 'nostr/relay' 12 | require_relative 'nostr/relay_message_type' 13 | require_relative 'nostr/key_pair' 14 | require_relative 'nostr/event_kind' 15 | require_relative 'nostr/signature' 16 | require_relative 'nostr/event' 17 | require_relative 'nostr/events/encrypted_direct_message' 18 | require_relative 'nostr/client' 19 | require_relative 'nostr/client/logger' 20 | require_relative 'nostr/client/color_logger' 21 | require_relative 'nostr/user' 22 | require_relative 'nostr/key' 23 | require_relative 'nostr/private_key' 24 | require_relative 'nostr/public_key' 25 | 26 | # Encapsulates all the gem's logic 27 | module Nostr 28 | end 29 | -------------------------------------------------------------------------------- /docs/relays/publishing-events.md: -------------------------------------------------------------------------------- 1 | # Publishing events 2 | 3 | Create a [signed event](../core/keys) and call the method 4 | [`Nostr::Client#publish`](https://www.rubydoc.info/gems/nostr/Nostr/Client#publish-instance_method) to send the 5 | event to the relay. 6 | 7 | ```ruby{4-8,17} 8 | # Create a user with the keypair 9 | user = Nostr::User.new(keypair: keypair) 10 | 11 | # Create a signed event 12 | text_note_event = user.create_event( 13 | kind: Nostr::EventKind::TEXT_NOTE, 14 | content: 'Your feedback is appreciated, now pay $8' 15 | ) 16 | 17 | # Connect asynchronously to a relay 18 | relay = Nostr::Relay.new(url: 'wss://nostr.wine', name: 'Wine') 19 | client.connect(relay) 20 | 21 | # Listen asynchronously for the connect event 22 | client.on :connect do 23 | # Send the event to the relay 24 | client.publish(text_note_event) 25 | end 26 | ``` 27 | 28 | The relay will verify the signature of the event with the public key. If the signature is valid, the relay should 29 | broadcast the event to all subscribers. 30 | -------------------------------------------------------------------------------- /sig/vendor/faye/websocket.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Faye 3 | class WebSocket 4 | ADAPTERS: Hash[String, :Goliath | :Rainbows | :Thin] 5 | 6 | @url: String 7 | @driver_started: false 8 | @stream: Stream 9 | @driver: bot 10 | 11 | def self.determine_url: (untyped env, ?[String, String] schemes) -> String 12 | def self.ensure_reactor_running: -> nil 13 | def self.load_adapter: (untyped backend) -> bool? 14 | def self.secure_request?: (untyped env) -> bool 15 | def self.websocket?: (untyped env) -> untyped 16 | 17 | attr_reader env: untyped 18 | 19 | def initialize: (untyped env, ?nil protocols, ?Hash[untyped, untyped] options) -> void 20 | def start_driver: -> nil 21 | def rack_response: -> [Integer, Hash[untyped, untyped], Array[untyped]] 22 | 23 | class Stream 24 | @socket_object: bot 25 | 26 | def fail: -> untyped 27 | def receive: (untyped data) -> untyped 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/nostr/relay.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Relays expose a websocket endpoint to which clients can connect. 5 | class Relay 6 | # The websocket URL of the relay 7 | # 8 | # @api public 9 | # 10 | # @example 11 | # relay.url # => 'wss://relay.damus.io' 12 | # 13 | # @return [String] 14 | # 15 | attr_reader :url 16 | 17 | # The name of the relay 18 | # 19 | # @api public 20 | # 21 | # @example 22 | # relay.name # => 'Damus' 23 | # 24 | # @return [String] 25 | # 26 | attr_reader :name 27 | 28 | # Instantiates a new Relay 29 | # 30 | # @api public 31 | # 32 | # @example 33 | # relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') 34 | # 35 | # @return [String] The websocket URL of the relay 36 | # @return [String] The name of the relay 37 | # 38 | def initialize(url:, name:) 39 | @url = url 40 | @name = name 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | ## Event Kinds 4 | 5 | | kind | description | NIP | 6 | | ------------- |----------------------------------------------------------------| -------------------------------------------------------------- | 7 | | `0` | [Metadata](./events/set-metadata) | [1](https://github.com/nostr-protocol/nips/blob/master/01.md) | 8 | | `1` | [Short Text Note](./events/text-note) | [1](https://github.com/nostr-protocol/nips/blob/master/01.md) | 9 | | `2` | [Recommend Relay](./events/recommend-server) | | 10 | | `3` | [Contacts](./events/contact-list) | [2](https://github.com/nostr-protocol/nips/blob/master/02.md) | 11 | | `4` | [Encrypted Direct Messages](./events/encrypted-direct-message) | [4](https://github.com/nostr-protocol/nips/blob/master/04.md) | 12 | -------------------------------------------------------------------------------- /docs/events/contact-list.md: -------------------------------------------------------------------------------- 1 | # Contact List 2 | 3 | ## Creating/updating your contact list 4 | 5 | Every new contact list that gets published overwrites the past ones, so it should contain all entries. 6 | 7 | ```ruby 8 | # Creating a contact list event with 2 contacts 9 | update_contacts_event = user.create_event( 10 | kind: Nostr::EventKind::CONTACT_LIST, 11 | tags: [ 12 | [ 13 | "p", # mandatory 14 | "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", # public key of the user to add to the contacts 15 | "wss://alicerelay.com/", # can be an empty string or can be omitted 16 | "alice" # can be an empty string or can be omitted 17 | ], 18 | [ 19 | "p", # mandatory 20 | "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", # public key of the user to add to the contacts 21 | "wss://bobrelay.com/nostr", # can be an empty string or can be omitted 22 | "bob" # can be an empty string or can be omitted 23 | ], 24 | ], 25 | ) 26 | 27 | # Send it to the Relay 28 | client.publish(update_contacts_event) 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/api-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Runtime API Examples 6 | 7 | This page demonstrates usage of some of the runtime APIs provided by VitePress. 8 | 9 | The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: 10 | 11 | ```md 12 | 17 | 18 | ## Results 19 | 20 | ### Theme Data 21 |
{{ theme }}
22 | 23 | ### Page Data 24 |
{{ page }}
25 | 26 | ### Page Frontmatter 27 |
{{ frontmatter }}
28 | ``` 29 | 30 | 35 | 36 | ## Results 37 | 38 | ### Theme Data 39 |
{{ theme }}
40 | 41 | ### Page Data 42 |
{{ page }}
43 | 44 | ### Page Frontmatter 45 |
{{ frontmatter }}
46 | 47 | ## More 48 | 49 | Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata). 50 | -------------------------------------------------------------------------------- /spec/support/echo_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'puma' 4 | require 'logger' 5 | require 'puma/binder' 6 | require 'puma/events' 7 | 8 | class EchoServer 9 | def call(env) 10 | @socket = Faye::WebSocket.new(env, ['echo']) 11 | 12 | @socket.onmessage = lambda do |event| 13 | @socket.send(event.data) 14 | end 15 | 16 | @socket.rack_response 17 | end 18 | 19 | def send(message) 20 | @socket.send(message) 21 | end 22 | 23 | def close(code, reason) 24 | @socket.close(code, reason) 25 | end 26 | 27 | def log(*args); end 28 | 29 | def listen(port) 30 | # Instead of logging to the stdout/stderr, we'll log to a StringIO to prevent cluttering the test output 31 | silent_stream = StringIO.new 32 | logger = Puma::LogWriter.new(silent_stream, silent_stream) 33 | events = Puma::Events.new 34 | binder = Puma::Binder.new(logger) 35 | binder.parse(["tcp://0.0.0.0:#{port}"], logger) 36 | 37 | @server = Puma::Server.new(self, events) 38 | @server.binder = binder 39 | @server.run 40 | end 41 | 42 | def stop 43 | @server&.stop(true) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Wilson Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /sig/nostr/event.rbs: -------------------------------------------------------------------------------- 1 | module Nostr 2 | class Event 3 | attr_reader pubkey: PublicKey 4 | attr_reader created_at: Integer 5 | attr_reader kind: Integer 6 | attr_reader tags: Array[Array[String]] 7 | attr_reader content: String 8 | attr_accessor id: String? 9 | attr_accessor sig: Signature? 10 | 11 | def initialize: ( 12 | pubkey: PublicKey, 13 | kind: Integer, 14 | content: String, 15 | ?created_at: Integer, 16 | ?tags: Array[Array[String]], 17 | ?id: String?, 18 | ?sig: Signature? 19 | ) -> void 20 | 21 | def serialize: -> [Integer, String, Integer, Integer, Array[Array[String]], String] 22 | 23 | def to_h: -> { 24 | id: String?, 25 | pubkey: String, 26 | created_at: Integer, 27 | kind: Integer, 28 | tags: Array[Array[String]], 29 | content: String, 30 | sig: String? 31 | } 32 | def ==: (Event other) -> bool 33 | 34 | def sign:(PrivateKey) -> Event 35 | def verify_signature: -> bool 36 | 37 | def add_event_reference: (String) -> Array[Array[String]] 38 | def add_pubkey_reference: (PublicKey) -> Array[Array[String]] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/nostr/public_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # 32-bytes lowercase hex-encoded public key 5 | class PublicKey < Key 6 | # Human-readable part of the Bech32 encoded address 7 | # 8 | # @api private 9 | # 10 | # @return [String] The human-readable part of the Bech32 encoded address 11 | # 12 | def self.hrp 13 | 'npub' 14 | end 15 | 16 | private 17 | 18 | # Validates the hex value of the public key 19 | # 20 | # @api private 21 | # 22 | # @param [String] hex_value The public key in hex format 23 | # 24 | # @raise InvalidKeyTypeError when the public key is not a string 25 | # @raise InvalidKeyLengthError when the public key's length is not 64 characters 26 | # @raise InvalidKeyFormatError when the public key is in an invalid format 27 | # 28 | # @return [void] 29 | # 30 | def validate_hex_value(hex_value) 31 | raise InvalidKeyTypeError, 'public' unless hex_value.is_a?(String) 32 | raise InvalidKeyLengthError, 'public' unless hex_value.size == Key::LENGTH 33 | raise InvalidKeyFormatError, 'public' unless hex_value.match(Key::FORMAT) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/nostr/private_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # 32-bytes lowercase hex-encoded private key 5 | class PrivateKey < Key 6 | # Human-readable part of the Bech32 encoded address 7 | # 8 | # @api private 9 | # 10 | # @return [String] The human-readable part of the Bech32 encoded address 11 | # 12 | def self.hrp 13 | 'nsec' 14 | end 15 | 16 | private 17 | 18 | # Validates the hex value of the private key 19 | # 20 | # @api private 21 | # 22 | # @param [String] hex_value The private key in hex format 23 | # 24 | # @raise InvalidKeyTypeError when the private key is not a string 25 | # @raise InvalidKeyLengthError when the private key's length is not 64 characters 26 | # @raise InvalidKeyFormatError when the private key is in an invalid format 27 | # 28 | # @return [void] 29 | # 30 | def validate_hex_value(hex_value) 31 | raise InvalidKeyTypeError, 'private' unless hex_value.is_a?(String) 32 | raise InvalidKeyLengthError, 'private' unless hex_value.size == Key::LENGTH 33 | raise InvalidKeyFormatError, 'private' unless hex_value.match(Key::FORMAT) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/nostr/event_kind_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::EventKind do 6 | describe '::SET_METADATA' do 7 | it 'is an integer' do 8 | expect(described_class::SET_METADATA).to eq(0) 9 | end 10 | end 11 | 12 | describe '::TEXT_NOTE' do 13 | it 'is an integer' do 14 | expect(described_class::TEXT_NOTE).to eq(1) 15 | end 16 | end 17 | 18 | describe '::RECOMMEND_SERVER' do 19 | it 'is an integer' do 20 | expect(described_class::RECOMMEND_SERVER).to eq(2) 21 | end 22 | end 23 | 24 | describe '::CONTACT_LIST' do 25 | it 'is an integer' do 26 | expect(described_class::CONTACT_LIST).to eq(3) 27 | end 28 | end 29 | 30 | describe '::ENCRYPTED_DIRECT_MESSAGE' do 31 | it 'is an integer' do 32 | expect(described_class::ENCRYPTED_DIRECT_MESSAGE).to eq(4) 33 | end 34 | end 35 | 36 | describe '::ZAP_REQUEST' do 37 | it 'is an integer' do 38 | expect(described_class::ZAP_REQUEST).to eq(9734) 39 | end 40 | end 41 | 42 | describe '::ZAP_RECEIPT' do 43 | it 'is an integer' do 44 | expect(described_class::ZAP_RECEIPT).to eq(9735) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/audit/task' 4 | require 'bundler/gem_tasks' 5 | require 'rspec/core/rake_task' 6 | require 'rubocop/rake_task' 7 | require 'yaml' 8 | require 'yard/rake/yardoc_task' 9 | require 'yard-junk/rake' 10 | require 'yardstick/rake/measurement' 11 | require 'yardstick/rake/verify' 12 | 13 | yardstick_options = YAML.load_file('.yardstick.yml') 14 | 15 | Bundler::Audit::Task.new 16 | RSpec::Core::RakeTask.new(:spec) 17 | RuboCop::RakeTask.new 18 | YARD::Rake::YardocTask.new 19 | YardJunk::Rake.define_task 20 | Yardstick::Rake::Measurement.new(:yardstick_measure, yardstick_options) 21 | Yardstick::Rake::Verify.new 22 | 23 | task default: %i[spec rubocop] 24 | 25 | # Remove the report on rake clobber 26 | CLEAN.include('measurements', 'doc', '.yardoc', 'tmp') 27 | 28 | # Delete these files and folders when running rake clobber. 29 | CLOBBER.include('coverage', '.rspec_status') 30 | 31 | desc 'Run spec with coverage' 32 | task :coverage do 33 | ENV['COVERAGE'] = 'true' 34 | Rake::Task['spec'].execute 35 | `open coverage/index.html` 36 | end 37 | 38 | desc 'Test, lint and perform security and documentation audits' 39 | task qa: %w[spec rubocop yard:junk verify_measurements bundle:audit] 40 | -------------------------------------------------------------------------------- /sig/vendor/bech32.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Bech32 3 | SEPARATOR: String 4 | BECH32M_CONST: Integer 5 | 6 | def encode: (untyped hrp, untyped data, untyped spec) -> untyped 7 | def self.encode: (untyped hrp, untyped data, untyped spec) -> untyped 8 | def decode: (untyped bech, ?Integer max_length) -> [untyped, untyped, Integer]? 9 | def self.decode: (untyped bech, ?Integer max_length) -> [untyped, untyped, Integer]? 10 | def create_checksum: (untyped hrp, untyped data, untyped spec) -> Array[Integer] 11 | def self.create_checksum: (untyped hrp, untyped data, untyped spec) -> Array[Integer] 12 | def verify_checksum: (untyped hrp, untyped data) -> Integer? 13 | def self.verify_checksum: (untyped hrp, untyped data) -> Integer? 14 | def expand_hrp: (untyped hrp) -> untyped 15 | def self.expand_hrp: (untyped hrp) -> untyped 16 | def convert_bits: (untyped data, untyped from, untyped to, ?true padding) -> Array[Integer]? 17 | def self.convert_bits: (untyped data, untyped from, untyped to, ?true padding) -> Array[Integer]? 18 | def polymod: (untyped values) -> Integer 19 | def self.polymod: (untyped values) -> Integer 20 | 21 | module Encoding 22 | BECH32: Integer 23 | BECH32M: Integer 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/vendor/bech32/nostr/entity.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Bech32 3 | module Nostr 4 | class BareEntity 5 | attr_reader hrp: untyped 6 | attr_reader data: untyped 7 | 8 | def initialize: (untyped hrp, untyped data) -> void 9 | def encode: -> untyped 10 | end 11 | 12 | class TLVEntry 13 | attr_reader type: (Float | Integer | String)? 14 | attr_reader label: String? 15 | attr_reader value: (Float | Integer | String)? 16 | 17 | def initialize: ((Float | Integer | String)? `type`, (Float | Integer | String)? value, ?String? label) -> void 18 | def to_payload: -> String 19 | def to_s: -> String 20 | 21 | private 22 | 23 | def hex_string?: ((Float | Integer | String)? str) -> bool 24 | end 25 | 26 | class TLVEntity 27 | TYPE_SPECIAL: Integer 28 | TYPE_RELAY: Integer 29 | TYPE_AUTHOR: Integer 30 | TYPE_KIND: Integer 31 | TYPES: [Integer, Integer, Integer, Integer] 32 | 33 | attr_reader hrp: untyped 34 | attr_reader entries: Array[TLVEntry] 35 | 36 | def initialize: (untyped hrp, Array[TLVEntry] entries) -> void 37 | def self.parse: (untyped hrp, untyped data) -> TLVEntity 38 | def encode: -> untyped 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /docs/events/recommend-server.md: -------------------------------------------------------------------------------- 1 | # Recommend Server 2 | 3 | The `Recommend Server` event, has a set of tags with the following structure `['e', , , ]` 4 | 5 | Where: 6 | 7 | - `` is the id of the event being referenced. 8 | - `` is the URL of a recommended relay associated with the reference. Clients SHOULD add a valid `` 9 | field, but may instead leave it as `''`. 10 | - `` is optional and if present is one of `'reply'`, `'root'`, or `'mention'`. 11 | Those marked with `'reply'` denote the id of the reply event being responded to. Those marked with `'root'` denote the 12 | root id of the reply thread being responded to. For top level replies (those replying directly to the root event), 13 | only the `'root'` marker should be used. Those marked with `'mention'` denote a quoted or reposted event id. 14 | 15 | A direct reply to the root of a thread should have a single marked `'e'` tag of type `'root'`. 16 | 17 | ## Recommending a server 18 | 19 | ```ruby 20 | recommend_server_event = user.create_event( 21 | kind: Nostr::EventKind::RECOMMEND_SERVER, 22 | tags: [ 23 | [ 24 | 'e', 25 | '461544014d87c9eaf3e76e021240007dff2c7afb356319f99c741b45749bf82f', 26 | 'wss://relay.damus.io' 27 | ], 28 | ] 29 | ) 30 | 31 | client.publish(recommend_server_event) 32 | ``` 33 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Use this file to configure the Overcommit hooks you wish to use. This will 2 | # extend the default configuration defined in: 3 | # https://github.com/sds/overcommit/blob/master/config/default.yml 4 | # 5 | # At the topmost level of this YAML file is a key representing type of hook 6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 7 | # customize each hook, such as whether to only run it on certain files (via 8 | # `include`), whether to only display output if it fails (via `quiet`), etc. 9 | # 10 | # For a complete list of hooks, see: 11 | # https://github.com/sds/overcommit/tree/master/lib/overcommit/hook 12 | # 13 | # For a complete list of options that you can use to customize hooks, see: 14 | # https://github.com/sds/overcommit#configuration 15 | # 16 | # Uncomment the following lines to make the configuration take effect. 17 | 18 | PreCommit: 19 | RuboCop: 20 | enabled: true 21 | on_warn: fail # Treat all warnings as failures 22 | 23 | TrailingWhitespace: 24 | enabled: true 25 | exclude: 26 | - '**/db/structure.sql' # Ignore trailing whitespace in generated files 27 | 28 | #PostCheckout: 29 | # ALL: # Special hook name that customizes all hooks of this type 30 | # quiet: true # Change all post-checkout hooks to only display output on failure 31 | # 32 | # IndexTags: 33 | # enabled: true # Generate a tags file with `ctags` each time HEAD changes 34 | -------------------------------------------------------------------------------- /docs/core/user.md: -------------------------------------------------------------------------------- 1 | # User 2 | 3 | The class [`Nostr::User`](https://www.rubydoc.info/gems/nostr/Nostr/User) is an abstraction to facilitate the creation 4 | of signed events. It is not required to use it to create events, but it is recommended. 5 | 6 | Here's an example of how to create a signed event without the class `Nostr::User`: 7 | 8 | ```ruby 9 | event = Nostr::Event.new( 10 | pubkey: keypair.public_key, 11 | kind: Nostr::EventKind::TEXT_NOTE, 12 | tags: [], 13 | content: 'Your feedback is appreciated, now pay $8', 14 | ) 15 | event.sign(keypair.private_key) 16 | ``` 17 | 18 | ::: details Click me to view the event 19 | 20 | ```ruby 21 | # event.to_h 22 | { 23 | id: '5feb10973dbcf5f210cfc1f0aa338fee62bed6a29696a67957713599b9baf0eb', 24 | pubkey: 'b9b9821074d1b60b8fb4a3983632af3ef9669f55b20d515bf982cda5c439ad61', 25 | created_at: 1699847447, 26 | kind: 1, # Nostr::EventKind::TEXT_NOTE, 27 | tags: [], 28 | content: 'Your feedback is appreciated, now pay $8', 29 | sig: 'e30f2f08331f224e41a4099d16aefc780bf9f2d1191b71777e1e1789e6b51fdf7bb956f25d4ea9a152d1c66717a9d68c081ce6c89c298c3c5e794914013381ab' 30 | } 31 | ``` 32 | ::: 33 | 34 | And here's how to create it with the class `Nostr::User`: 35 | 36 | ```ruby 37 | user = Nostr::User.new(keypair: keypair) 38 | 39 | event = user.create_event( 40 | kind: Nostr::EventKind::TEXT_NOTE, 41 | content: 'Your feedback is appreciated, now pay $8' 42 | ) 43 | ``` 44 | -------------------------------------------------------------------------------- /sig/vendor/faye/websocket/api.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Faye 3 | class WebSocket 4 | module API 5 | CONNECTING: Integer 6 | OPEN: Integer 7 | CLOSING: Integer 8 | CLOSED: Integer 9 | CLOSE_TIMEOUT: Integer 10 | 11 | @driver: untyped 12 | @ping: nil 13 | @ping_id: Integer 14 | @stream: nil 15 | @proxy: nil 16 | @ping_timer: nil 17 | @close_timer: nil 18 | @close_params: [String, Integer]? 19 | @onerror: nil 20 | @onclose: nil 21 | @onmessage: nil 22 | @onopen: nil 23 | 24 | attr_reader url: untyped 25 | attr_reader ready_state: Integer 26 | attr_reader buffered_amount: Integer 27 | 28 | def initialize: (?Hash[untyped, untyped] options) -> void 29 | def write: (untyped data) -> untyped 30 | def send: (untyped message) -> false 31 | def ping: (?String message) -> false 32 | def close: (?nil code, ?nil reason) -> untyped 33 | def protocol: -> String 34 | 35 | private 36 | 37 | def open: -> nil 38 | def receive_message: (untyped data) -> nil 39 | def emit_error: (untyped message) -> nil 40 | def begin_close: (String reason, Integer code, ?Hash[untyped, untyped] options) -> nil 41 | def finalize_close: -> nil 42 | def parse: (untyped data) -> untyped 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /docs/events/encrypted-direct-message.md: -------------------------------------------------------------------------------- 1 | # Encrypted Direct Message 2 | 3 | ## Sending an encrypted direct message 4 | 5 | ```ruby 6 | sender_private_key = '3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757' 7 | 8 | encrypted_direct_message = Nostr::Events::EncryptedDirectMessage.new( 9 | sender_private_key: sender_private_key, 10 | recipient_public_key: '6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0', 11 | plain_text: 'Your feedback is appreciated, now pay $8', 12 | previous_direct_message: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' # optional 13 | ) 14 | 15 | encrypted_direct_message.sign(sender_private_key) 16 | 17 | # # 25 | 26 | # Send it to the Relay 27 | client.publish(encrypted_direct_message) 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/markdown-examples.md: -------------------------------------------------------------------------------- 1 | # Markdown Extension Examples 2 | 3 | This page demonstrates some of the built-in markdown extensions provided by VitePress. 4 | 5 | ## Syntax Highlighting 6 | 7 | VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: 8 | 9 | **Input** 10 | 11 | ```` 12 | ```js{4} 13 | export default { 14 | data () { 15 | return { 16 | msg: 'Highlighted!' 17 | } 18 | } 19 | } 20 | ``` 21 | ```` 22 | 23 | **Output** 24 | 25 | ```js{4} 26 | export default { 27 | data () { 28 | return { 29 | msg: 'Highlighted!' 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | ## Custom Containers 36 | 37 | **Input** 38 | 39 | ```md 40 | ::: info 41 | This is an info box. 42 | ::: 43 | 44 | ::: tip 45 | This is a tip. 46 | ::: 47 | 48 | ::: warning 49 | This is a warning. 50 | ::: 51 | 52 | ::: danger 53 | This is a dangerous warning. 54 | ::: 55 | 56 | ::: details 57 | This is a details block. 58 | ::: 59 | ``` 60 | 61 | **Output** 62 | 63 | ::: info 64 | This is an info box. 65 | ::: 66 | 67 | ::: tip 68 | This is a tip. 69 | ::: 70 | 71 | ::: warning 72 | This is a warning. 73 | ::: 74 | 75 | ::: danger 76 | This is a dangerous warning. 77 | ::: 78 | 79 | ::: details 80 | This is a details block. 81 | ::: 82 | 83 | ## More 84 | 85 | Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). 86 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-rake 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.3 9 | DisplayCopNames: true 10 | NewCops: enable 11 | Exclude: 12 | - docs/**/* 13 | 14 | # ----------------------- Gemspec ----------------------- 15 | 16 | Gemspec/DevelopmentDependencies: 17 | Enabled: false 18 | 19 | # ----------------------- Style ----------------------- 20 | 21 | Style/RaiseArgs: 22 | Exclude: 23 | - 'lib/nostr/key.rb' 24 | 25 | Style/StringLiterals: 26 | Enabled: true 27 | EnforcedStyle: single_quotes 28 | 29 | Style/StringLiteralsInInterpolation: 30 | Enabled: true 31 | EnforcedStyle: double_quotes 32 | 33 | # ----------------------- Layout ---------------------- 34 | 35 | Layout/LineLength: 36 | Max: 120 37 | 38 | # ---------------------- Metrics ---------------------- 39 | 40 | Metrics/BlockLength: 41 | Exclude: 42 | - '**/*_spec.rb' 43 | - nostr.gemspec 44 | 45 | Metrics/ParameterLists: 46 | CountKeywordArgs: false 47 | 48 | # ----------------------- RSpec ----------------------- 49 | 50 | RSpec/ExampleLength: 51 | Enabled: false 52 | 53 | RSpec/FilePath: 54 | Exclude: 55 | - spec/nostr/errors/invalid_* 56 | 57 | RSpec/SpecFilePathFormat: 58 | Exclude: 59 | - spec/nostr/errors/invalid_* 60 | 61 | # ----------------------- Naming ----------------------- 62 | 63 | Naming/MemoizedInstanceVariableName: 64 | Exclude: 65 | - 'spec/nostr/key.rb' 66 | -------------------------------------------------------------------------------- /sig/vendor/faye/websocket/client.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module Faye 3 | class WebSocket 4 | class Client 5 | DEFAULT_PORTS: Hash[String, Integer] 6 | SECURE_PROTOCOLS: [String, String] 7 | 8 | include EventEmitter 9 | include API 10 | 11 | @url: untyped 12 | @endpoint: untyped 13 | @origin_tls: Hash[untyped, untyped] 14 | @socket_tls: Hash[untyped, untyped] 15 | @driver: bot 16 | @proxy: nil 17 | @ssl_verifier: untyped 18 | @stream: untyped 19 | 20 | def initialize: (untyped url, ?Array[String] protocols, ?Hash[untyped, untyped] options) -> void 21 | 22 | private 23 | 24 | def configure_proxy: (Hash[untyped, untyped] proxy) -> nil 25 | def start_tls: (untyped uri, Hash[untyped, untyped] options) -> nil 26 | def on_connect: (untyped stream) -> untyped 27 | def on_network_error: (nil error) -> untyped 28 | def ssl_verify_peer: (untyped cert) -> untyped 29 | def ssl_handshake_completed: -> untyped 30 | 31 | module Connection 32 | attr_accessor parent: bot 33 | 34 | def connection_completed: -> untyped 35 | def ssl_verify_peer: (untyped cert) -> untyped 36 | def ssl_handshake_completed: -> untyped 37 | def receive_data: (untyped data) -> untyped 38 | def unbind: (?nil error) -> untyped 39 | def write: (untyped data) -> nil 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Nostr" 7 | text: "Ruby" 8 | tagline: "The Nostr protocol implemented in a Ruby gem." 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /getting-started/overview 13 | - theme: alt 14 | text: View on Github 15 | link: https://github.com/wilsonsilva/nostr 16 | - theme: alt 17 | text: View on RubyDoc 18 | link: https://www.rubydoc.info/gems/nostr 19 | - theme: alt 20 | text: View on RubyGems 21 | link: https://rubygems.org/gems/nostr 22 | 23 | features: 24 | - title: Asynchronous 25 | details: Non-blocking I/O for maximum performance. 26 | icon: ⚡ 27 | - title: Lightweight 28 | details: Minimal runtime dependencies. 29 | icon: 🪶 30 | - title: Intuitive 31 | details: The API mirrors the Nostr protocol specification domain language. 32 | icon: 💡 33 | - title: Fully documented 34 | details: All code is documented from both the maintainer's as well as the consumer's perspective. 35 | icon: 📚 36 | - title: Fully tested 37 | details: All code is tested with 100% coverage. 38 | icon: 🧪 39 | - title: Fully typed 40 | details: All code is typed with RBS with the help of TypeProf. Type correctness is enforced by Steep. 41 | icon: ✅ 42 | --- 43 | -------------------------------------------------------------------------------- /docs/subscriptions/creating-a-subscription.md: -------------------------------------------------------------------------------- 1 | # Creating a subscription 2 | 3 | A client can request events and subscribe to new updates __after__ it has established a connection with the Relay. 4 | 5 | You may use a [`Nostr::Filter`](https://www.rubydoc.info/gems/nostr/Nostr/Filter) instance with as many attributes as 6 | you wish: 7 | 8 | ```ruby 9 | client.on :connect do 10 | filter = Nostr::Filter.new( 11 | ids: ['8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8'], 12 | authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], 13 | kinds: [Nostr::EventKind::TEXT_NOTE], 14 | e: ["f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52"], 15 | p: ["f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8"], 16 | since: 1230981305, 17 | until: 1292190341, 18 | limit: 420, 19 | ) 20 | 21 | subscription = client.subscribe(subscription_id: 'an-id', filter: filter) 22 | end 23 | ``` 24 | 25 | With just a few: 26 | 27 | ```ruby 28 | client.on :connect do 29 | filter = Nostr::Filter.new(kinds: [Nostr::EventKind::TEXT_NOTE]) 30 | subscription = client.subscribe(subscription_id: 'an-id', filter: filter) 31 | end 32 | ``` 33 | 34 | Or omit the filter: 35 | 36 | ```ruby 37 | client.on :connect do 38 | subscription = client.subscribe(subscription_id: 'an-id') 39 | end 40 | ``` 41 | 42 | Or even omit the subscription id: 43 | 44 | ```ruby 45 | client.on :connect do 46 | subscription = client.subscribe(filter: filter) 47 | subscription.id # => "13736f08dee8d7b697222ba605c6fab2" (randomly generated) 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /spec/nostr/client/logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Client::Logger do 6 | let(:client) { instance_spy(Nostr::Client) } 7 | let(:relay) { Nostr::Relay.new(url: 'ws://0.0.0.0:4180/', name: 'localhost') } 8 | let(:logger) { described_class.new } 9 | 10 | describe '#attach_to' do 11 | it 'attaches event handlers to the client' do 12 | logger.attach_to(client) 13 | 14 | aggregate_failures do 15 | expect(client).to have_received(:on).with(:connect) 16 | expect(client).to have_received(:on).with(:message) 17 | expect(client).to have_received(:on).with(:send) 18 | expect(client).to have_received(:on).with(:error) 19 | expect(client).to have_received(:on).with(:close) 20 | end 21 | end 22 | end 23 | 24 | describe '#on_connect' do 25 | it 'returns nil' do 26 | expect(logger.on_connect(relay)).to be_nil 27 | end 28 | end 29 | 30 | describe '#on_message' do 31 | it 'returns nil' do 32 | message = 'Received message' 33 | expect(logger.on_message(message)).to be_nil 34 | end 35 | end 36 | 37 | describe '#on_send' do 38 | it 'returns nil' do 39 | message = 'Sent message' 40 | expect(logger.on_send(message)).to be_nil 41 | end 42 | end 43 | 44 | describe '#on_error' do 45 | it 'returns nil' do 46 | message = 'Error message' 47 | expect(logger.on_error(message)).to be_nil 48 | end 49 | end 50 | 51 | describe '#on_close' do 52 | it 'returns nil' do 53 | code = 1000 54 | reason = 'Normal closure' 55 | expect(logger.on_close(code, reason)).to be_nil 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /adr/0004-default-logging-activation.md: -------------------------------------------------------------------------------- 1 | # 3. Default Logging Activation 2 | 3 | Date: 2024-03-19 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | Logging provides visibility into the gem's behavior and helps to diagnose issues. The decision centered on whether 12 | to enable logging by default or require manual activation. 13 | 14 | ### Option 1: On-demand Logging 15 | 16 | ```ruby 17 | client = Nostr::Client.new 18 | logger = Nostr::ClientLogger.new 19 | logger.attach_to(client) 20 | ``` 21 | 22 | #### Advantages: 23 | 24 | - Offers users flexibility and control over logging. 25 | - Conserves resources by logging only when needed. 26 | 27 | #### Disadvantages: 28 | 29 | - Potential to miss critical logs if not enabled. 30 | - Requires users to read and understand documentation to enable logging. 31 | - Requires users to manually activate and configure logging. 32 | 33 | ### Option 2: Automatic Logging 34 | 35 | ```ruby 36 | class Client 37 | def initialize(logger: ClientLogger.new) 38 | @logger = logger 39 | logger&.attach_to(self) 40 | end 41 | end 42 | 43 | client = Nostr::Client.new 44 | ``` 45 | 46 | #### Advantages: 47 | 48 | - Ensures comprehensive logging without user intervention. 49 | - Simplifies debugging and monitoring. 50 | - Balances logging detail with performance impact. 51 | 52 | #### Disadvantages: 53 | 54 | - Needs additional steps to disable logging. 55 | 56 | ## Decision 57 | 58 | Logging will be enabled by default. Users can disable logging by passing a `nil` logger to the client: 59 | 60 | ```ruby 61 | client = Nostr::Client.new(logger: nil) 62 | ``` 63 | 64 | ## Consequences 65 | 66 | Enabling logging by default favors ease of use and simplifies the developer's experience. 67 | -------------------------------------------------------------------------------- /lib/nostr/client/plain_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | class Client 5 | # Logs connection events, messages sent and received, errors, and connection closures. 6 | class PlainLogger < Logger 7 | # Logs connection to a relay 8 | # 9 | # @api private 10 | # 11 | # @param [Nostr::Relay] relay The relay the client connected to. 12 | # 13 | # @return [void] 14 | # 15 | def on_connect(relay) 16 | puts "Connected to the relay #{relay.name} (#{relay.url})" 17 | end 18 | 19 | # Logs a message received from the relay 20 | # 21 | # @api private 22 | # 23 | # @param [String] message The message received. 24 | # 25 | # @return [void] 26 | # 27 | def on_message(message) 28 | puts "◄- #{message}" 29 | end 30 | 31 | # Logs a message sent to the relay 32 | # 33 | # @api private 34 | # 35 | # @param [String] message The message sent. 36 | # 37 | # @return [void] 38 | # 39 | def on_send(message) 40 | puts "-► #{message}" 41 | end 42 | 43 | # Logs an error message 44 | # 45 | # @api private 46 | # 47 | # @param [String] message The error message. 48 | # 49 | # @return [void] 50 | # 51 | def on_error(message) 52 | puts "Error: #{message}" 53 | end 54 | 55 | # Logs a closure of connection with a relay 56 | # 57 | # @api private 58 | # 59 | # @param [String] code The closure code. 60 | # @param [String] reason The reason for the closure. 61 | # 62 | # @return [void] 63 | # 64 | def on_close(code, reason) 65 | puts "Connection closed: #{reason} (##{code})" 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /docs/common-use-cases/signing-and-verifying-events.md: -------------------------------------------------------------------------------- 1 | # Signing and verifying events 2 | 3 | Signing an event in Nostr proves it was sent by the owner of a specific private key. 4 | 5 | ## Signing an event 6 | 7 | To sign an event, use the private key associated with the event's creator. Here's how to sign a message using a 8 | predefined keypair: 9 | 10 | ```ruby{14} 11 | require 'nostr' 12 | 13 | private_key = Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'), 14 | public_key = Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 15 | 16 | event = Nostr::Event.new( 17 | pubkey: public_key.to_s, 18 | kind: Nostr::EventKind::TEXT_NOTE, 19 | content: 'We did it with security, now we’re going to do it with the economy.', 20 | created_at: Time.now.to_i, 21 | ) 22 | 23 | # Sign the event with the private key 24 | event.sign(private_key) 25 | 26 | puts "Event ID: #{event.id}" 27 | puts "Event Signature: #{event.sig}" 28 | ``` 29 | 30 | ## Verifying an event's signature 31 | 32 | To verify an event, you must ensure the event's signature is valid. This indicates the event was created by the owner 33 | of the corresponding public key. 34 | 35 | When the event was signed with the private key corresponding to the public key, the `verify_signature` method will 36 | return `true`. 37 | 38 | ```ruby 39 | event.verify_signature # => true 40 | ``` 41 | 42 | And when the event was not signed with the private key corresponding to the public key, the `verify_signature` method 43 | will return `false`. 44 | 45 | An event without an `id`, `pubkey`, `sig` is considered invalid and will return `false` when calling `verify_signature`. 46 | 47 | ```ruby 48 | other_public_key = Nostr::PublicKey.new('10be96d345ed58d923a734560680f1adfd2b1006c28ac93b8e1b032a9a32c6e9') 49 | event.verify_signature # => false 50 | ``` 51 | -------------------------------------------------------------------------------- /adr/0005-logger-types.md: -------------------------------------------------------------------------------- 1 | # 3. Logger Types 2 | 3 | Date: 2024-04-01 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | In developing the `Nostr::Client` logging mechanism, I identified a need to cater to multiple development environments 12 | and developer preferences. The consideration was whether to implement a singular logger type or to introduce 13 | multiple, specialized loggers. The alternatives were: 14 | 15 | - A single `ClientLogger` providing a one-size-fits-all solution. 16 | - Multiple logger types: 17 | - `Nostr::Client::Logger`: The base class for logging, providing core functionalities. 18 | - `Nostr::Client::ColorLogger`: An extension of the base logger, introducing color-coded outputs for enhanced readability in environments that support ANSI colors. 19 | - `Nostr::Client::PlainLogger`: A variation that produces logs without color coding, suitable for environments lacking color support or for users preferring plain text. 20 | 21 | ## Decision 22 | 23 | I decided to implement the latter option: three specific kinds of loggers (`Nostr::Client::Logger`, 24 | `Nostr::Client::ColorLogger`, and `Nostr::Client::PlainLogger`). This approach is intended to offer flexibility and 25 | cater to the varied preferences and requirements of our users, recognizing the diverse environments in which our 26 | library might be used. 27 | 28 | ## Consequences 29 | 30 | - `Developer Choice`: Developers gain the ability to select the one that best matches their needs and environmental constraints, thereby enhancing the library's usability. 31 | - `Code Complexity`: While introducing multiple logger types increases the library's code complexity, this is offset by the significant gain in flexibility and user satisfaction. 32 | - `Broad Compatibility`: This decision ensures that the logging mechanism is adaptable to a wide range of operational environments, enhancing the library's overall robustness and accessibility. 33 | -------------------------------------------------------------------------------- /adr/0002-introduction-of-signature-class.md: -------------------------------------------------------------------------------- 1 | # 2. introduction-of-signature-class 2 | 3 | Date: 2024-03-14 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | I noticed significant overuse of primitive strings for signatures, which led to widespread and repetitive validation logic, increasing the potential for errors and making the system harder to manage and maintain. 12 | 13 | ## Decision 14 | 15 | I introduced the Nostr::Signature class, choosing to subclass String to leverage string-like behavior while embedding specific validation rules for signatures. This move was aimed at streamlining validation, ensuring consistency, and maintaining the usability of strings. 16 | 17 | ## Consequences 18 | 19 | ### Positive 20 | 21 | - This design choice has made the codebase cleaner and more robust, reducing the chances of errors related to signature handling. It ensures that all signature instances are valid at creation, leveraging the familiarity and flexibility of string operations without sacrificing the integrity of the data. Moreover, it sets a strong foundation for extending signature-related functionality in the future. 22 | 23 | ### Negative 24 | 25 | - __Performance Concerns:__ Subclassing String might introduce slight performance overheads due to the additional validation logic executed upon instantiation of a Signature object. 26 | - __Integration Challenges:__ Integrating this class into existing systems where strings were used indiscriminately for signatures requires careful refactoring to ensure compatibility. There's also the potential for issues when passing Nostr::Signature objects to libraries or APIs expecting plain strings without the additional constraints. 27 | - __Learning Curve:__ For new team members or contributors, understanding the necessity and functionality of the Nostr::Signature class adds to the learning curve, potentially slowing down initial development efforts as they familiarize themselves with the custom implementation. 28 | -------------------------------------------------------------------------------- /docs/common-use-cases/signing-and-verifying-messages.md: -------------------------------------------------------------------------------- 1 | # Signing and verifying messages 2 | 3 | Signing a message in Nostr proves it was sent by the owner of a specific private key. 4 | 5 | ## Signing a message 6 | 7 | To sign a message, you'll need a private key. Here's how to sign a message using a predefined keypair: 8 | 9 | ```ruby{9} 10 | require 'nostr' 11 | 12 | private_key = Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'), 13 | public_key = Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 14 | 15 | message = 'We did it with security, now we’re going to do it with the economy.' # The message you want to sign 16 | 17 | crypto = Nostr::Crypto.new 18 | signature = crypto.sign_message(message, private_key) 19 | signature # => "d7a0aac1fadcddf1aa2949bedfcdf25ce0c1604e648e55d31431fdacbff8e8256f7c2166d98292f80bc5f79105a0b6e8a89236a47d97cf5d0e7cc1ebf34dea5c" 20 | ``` 21 | 22 | ## Verifying a signature 23 | 24 | To verify a signature, you need the original message, the public key of the signer, and the signature. 25 | 26 | ```ruby 27 | crypto.valid_sig?(message, public_key, signature) # => true 28 | crypto.check_sig!(message, public_key, signature) # => true 29 | ``` 30 | 31 | When the message was not signed with the private key corresponding to the public key, the `valid_sig?` method will return `false`. 32 | 33 | ```ruby 34 | other_public_key = Nostr::PublicKey.new('10be96d345ed58d923a734560680f1adfd2b1006c28ac93b8e1b032a9a32c6e9') 35 | crypto.valid_sig?(message, public_key, signature) # => false 36 | ``` 37 | 38 | And when the message was not signed with the private key corresponding to the public key, the `check_sig!` method will raise an error. 39 | 40 | ```ruby 41 | other_public_key = Nostr::PublicKey.new('10be96d345ed58d923a734560680f1adfd2b1006c28ac93b8e1b032a9a32c6e9') 42 | crypto.check_sig!(message, other_public_key, signature) # => Schnorr::InvalidSignatureError: signature verification failed 43 | ``` 44 | -------------------------------------------------------------------------------- /lib/nostr/client/color_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | class Client 5 | # Logs connection events, messages sent and received, errors, and connection closures in color. 6 | class ColorLogger < Logger 7 | # Logs connection to a relay 8 | # 9 | # @api private 10 | # 11 | # @param [Nostr::Relay] relay The relay the client connected to. 12 | # 13 | # @return [void] 14 | # 15 | def on_connect(relay) 16 | puts "\u001b[32m\u001b[1mConnected to the relay\u001b[22m #{relay.name} (#{relay.url})\u001b[0m" 17 | end 18 | 19 | # Logs a message received from the relay 20 | # 21 | # @api private 22 | # 23 | # @param [String] message The message received. 24 | # 25 | # @return [void] 26 | # 27 | def on_message(message) 28 | puts "\u001b[32m\u001b[1m◄-\u001b[0m #{message}" 29 | end 30 | 31 | # Logs a message sent to the relay 32 | # 33 | # @api private 34 | # 35 | # @param [String] message The message sent. 36 | # 37 | # @return [void] 38 | # 39 | def on_send(message) 40 | puts "\u001b[32m\u001b[1m-►\u001b[0m #{message}" 41 | end 42 | 43 | # Logs an error message 44 | # 45 | # @api private 46 | # 47 | # @param [String] message The error message. 48 | # 49 | # @return [void] 50 | # 51 | def on_error(message) 52 | puts "\u001b[31m\u001b[1mError: \u001b[22m#{message}\u001b[0m" 53 | end 54 | 55 | # Logs a closure of connection with a relay 56 | # 57 | # @api private 58 | # 59 | # @param [String] code The closure code. 60 | # @param [String] reason The reason for the closure. 61 | # 62 | # @return [void] 63 | # 64 | def on_close(code, reason) 65 | puts "\u001b[31m\u001b[1mConnection closed: \u001b[22m#{reason} (##{code})\u001b[0m" 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/nostr/signature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Signature do 6 | let(:valid_hex_signature) do 7 | 'f418c97b50cc68227e82f4f3a79d79eb2b7a0fa517859c86e1a8fa91e3741b7f' \ 8 | '06e070c44129227b83fcbe93cecb02a346804a4080ce47685ecad60ab4f5f128' 9 | end 10 | 11 | let(:signature) { described_class.new(valid_hex_signature) } 12 | 13 | describe '.new' do 14 | context 'when the signature is not a string' do 15 | it 'raises an InvalidSignatureTypeError' do 16 | expect { described_class.new(1234) }.to raise_error( 17 | Nostr::InvalidSignatureTypeError, 18 | 'Invalid signature type. It must be a string with lowercase hexadecimal characters.' 19 | ) 20 | end 21 | end 22 | 23 | context "when the signature's length is not 128 characters" do 24 | it 'raises an InvalidSignatureLengthError' do 25 | expect { described_class.new('a' * 129) }.to raise_error( 26 | Nostr::InvalidSignatureLengthError, 27 | 'Invalid signature length. It should have 128 characters.' 28 | ) 29 | end 30 | end 31 | 32 | context 'when the signature contains non-hexadecimal characters' do 33 | it 'raises an InvalidKeyFormatError' do 34 | expect { described_class.new('g' * 128) }.to raise_error( 35 | Nostr::InvalidSignatureFormatError, 36 | 'Only lowercase hexadecimal characters are allowed in signatures.' 37 | ) 38 | end 39 | end 40 | 41 | context 'when the signature contains uppercase characters' do 42 | it 'raises an InvalidKeyFormatError' do 43 | expect { described_class.new('A' * 128) }.to raise_error( 44 | Nostr::InvalidSignatureFormatError, 45 | 'Only lowercase hexadecimal characters are allowed in signatures.' 46 | ) 47 | end 48 | end 49 | 50 | context 'when the signature is valid' do 51 | it 'does not raise any error' do 52 | expect { described_class.new('a' * 128) }.not_to raise_error 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/nostr/event_kind.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Defines the event kinds that can be emitted by clients. 5 | module EventKind 6 | # The content is set to a stringified JSON object +{name: , about: , 7 | # picture: }+ describing the user who created the event. A relay may delete past set_metadata 8 | # events once it gets a new one for the same pubkey. 9 | # 10 | # @return [Integer] 11 | # 12 | SET_METADATA = 0 13 | 14 | # The content is set to the text content of a note (anything the user wants to say). 15 | # Non-plaintext notes should instead use kind 1000-10000 as described in NIP-16. 16 | # 17 | # @return [Integer] 18 | # 19 | TEXT_NOTE = 1 20 | 21 | # The content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to 22 | # recommend to its followers. 23 | # 24 | # @deprecated This event kind was removed in https://github.com/nostr-protocol/nips/pull/703/files#diff-39307f1617417657ee9874be314f13aabdc74401b124d0afe8217f2919c9c7d8L105 25 | # @return [Integer] 26 | # 27 | RECOMMEND_SERVER = 2 28 | 29 | # A special event with kind 3, meaning "contact list" is defined as having a list of p tags, one for each of 30 | # the followed/known profiles one is following. 31 | # 32 | # @return [Integer] 33 | # 34 | CONTACT_LIST = 3 35 | 36 | # A special event with kind 4, meaning "encrypted direct message". An event of this kind has its +content+ 37 | # equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a 38 | # shared cipher generated by combining the recipient's public-key with the sender's private-key. 39 | # 40 | # @return [Integer] 41 | # 42 | ENCRYPTED_DIRECT_MESSAGE = 4 43 | 44 | # A special event with kind 9374. See NIP-57. 45 | # 46 | # @return [Integer] 47 | # 48 | ZAP_REQUEST = 9734 49 | 50 | # A special event with kind 9375. See NIP-57. 51 | # 52 | # @return [Integer] 53 | # 54 | ZAP_RECEIPT = 9735 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/nostr/client/plain_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'nostr/client/plain_logger' 5 | 6 | RSpec.describe Nostr::Client::PlainLogger do 7 | let(:client) { instance_spy(Nostr::Client) } 8 | let(:relay) { Nostr::Relay.new(url: 'ws://0.0.0.0:4180/', name: 'localhost') } 9 | let(:logger) { described_class.new } 10 | 11 | describe '#attach_to' do 12 | it 'attaches event handlers to the client' do 13 | logger.attach_to(client) 14 | 15 | aggregate_failures do 16 | expect(client).to have_received(:on).with(:connect) 17 | expect(client).to have_received(:on).with(:message) 18 | expect(client).to have_received(:on).with(:send) 19 | expect(client).to have_received(:on).with(:error) 20 | expect(client).to have_received(:on).with(:close) 21 | end 22 | end 23 | end 24 | 25 | describe '#on_connect' do 26 | it 'logs connection to a relay' do 27 | expect do 28 | logger.on_connect(relay) 29 | end.to output("Connected to the relay localhost (ws://0.0.0.0:4180/)\n").to_stdout 30 | end 31 | end 32 | 33 | describe '#on_message' do 34 | it 'logs a message received from the relay' do 35 | message = 'Received message' 36 | expect { logger.on_message(message) }.to output("◄- #{message}\n").to_stdout 37 | end 38 | end 39 | 40 | describe '#on_send' do 41 | it 'logs a message sent to the relay' do 42 | message = 'Sent message' 43 | expect { logger.on_send(message) }.to output("-► #{message}\n").to_stdout 44 | end 45 | end 46 | 47 | describe '#on_error' do 48 | it 'logs an error message' do 49 | message = 'Error message' 50 | expect { logger.on_error(message) }.to output("Error: #{message}\n").to_stdout 51 | end 52 | end 53 | 54 | describe '#on_close' do 55 | it 'logs a closure of connection with a relay' do 56 | code = '1000' 57 | reason = 'Connection closed' 58 | expect do 59 | logger.on_close(code, reason) 60 | end.to output("Connection closed: #{reason} (##{code})\n").to_stdout 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/nostr/client/color_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Client::ColorLogger do 6 | let(:client) { instance_spy(Nostr::Client) } 7 | let(:relay) { Nostr::Relay.new(url: 'ws://0.0.0.0:4180/', name: 'localhost') } 8 | let(:logger) { described_class.new } 9 | 10 | describe '#attach_to' do 11 | it 'attaches event handlers to the client' do 12 | logger.attach_to(client) 13 | 14 | aggregate_failures do 15 | expect(client).to have_received(:on).with(:connect) 16 | expect(client).to have_received(:on).with(:message) 17 | expect(client).to have_received(:on).with(:send) 18 | expect(client).to have_received(:on).with(:error) 19 | expect(client).to have_received(:on).with(:close) 20 | end 21 | end 22 | end 23 | 24 | describe '#on_connect' do 25 | it 'logs connection to a relay' do 26 | expect do 27 | logger.on_connect(relay) 28 | end.to output("\e[32m\e[1mConnected to the relay\e[22m localhost (ws://0.0.0.0:4180/)\e[0m\n").to_stdout 29 | end 30 | end 31 | 32 | describe '#on_message' do 33 | it 'logs a message received from the relay' do 34 | message = 'Received message' 35 | expect { logger.on_message(message) }.to output("\e[32m\e[1m◄-\e[0m #{message}\n").to_stdout 36 | end 37 | end 38 | 39 | describe '#on_send' do 40 | it 'logs a message sent to the relay' do 41 | message = 'Sent message' 42 | expect { logger.on_send(message) }.to output("\e[32m\e[1m-►\e[0m #{message}\n").to_stdout 43 | end 44 | end 45 | 46 | describe '#on_error' do 47 | it 'logs an error message' do 48 | message = 'Error message' 49 | expect { logger.on_error(message) }.to output("\e[31m\e[1mError: \e[22m#{message}\e[0m\n").to_stdout 50 | end 51 | end 52 | 53 | describe '#on_close' do 54 | it 'logs a closure of connection with a relay' do 55 | code = '1000' 56 | reason = 'Connection closed' 57 | expect do 58 | logger.on_close(code, reason) 59 | end.to output("\e[31m\e[1mConnection closed: \e[22m#{reason} (##{code})\e[0m\n").to_stdout 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/nostr/signature.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # 64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, 5 | # which is the same as the "id" field 6 | class Signature < String 7 | # The regular expression for hexadecimal lowercase characters 8 | # 9 | # @return [Regexp] The regular expression for hexadecimal lowercase characters 10 | # 11 | FORMAT = /^[a-f0-9]+$/ 12 | 13 | # The length of the signature in hex format 14 | # 15 | # @return [Integer] The length of the signature in hex format 16 | # 17 | LENGTH = 128 18 | 19 | # Instantiates a new Signature 20 | # 21 | # @api public 22 | # 23 | # @example Instantiating a new signature 24 | # Nostr::Signature.new( 25 | # 'f418c97b50cc68227e82f4f3a79d79eb2b7a0fa517859c86e1a8fa91e3741b7f' \ 26 | # '06e070c44129227b83fcbe93cecb02a346804a4080ce47685ecad60ab4f5f128' 27 | # ) 28 | # 29 | # @param [String] hex_value Hex-encoded value of the signature 30 | # 31 | # @raise [SignatureValidationError] 32 | # 33 | def initialize(hex_value) 34 | validate(hex_value) 35 | 36 | super(hex_value) 37 | end 38 | 39 | private 40 | 41 | # Hex-encoded value of the signature 42 | # 43 | # @api private 44 | # 45 | # @return [String] hex_value Hex-encoded value of the signature 46 | # 47 | attr_reader :hex_value 48 | 49 | # Validates the hex value of the signature 50 | # 51 | # @api private 52 | # 53 | # @param [String] hex_value The signature in hex format 54 | # 55 | # @raise InvalidSignatureTypeError when the signature is not a string 56 | # @raise InvalidSignatureLengthError when the signature's length is not 128 characters 57 | # @raise InvalidSignatureFormatError when the signature is in an invalid format 58 | # 59 | # @return [void] 60 | # 61 | def validate(hex_value) 62 | raise InvalidSignatureTypeError unless hex_value.is_a?(String) 63 | raise InvalidSignatureLengthError unless hex_value.size == LENGTH 64 | raise InvalidSignatureFormatError unless hex_value.match(FORMAT) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/nostr/subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module Nostr 6 | # A subscription the result of a request to receive events from a relay 7 | class Subscription 8 | # An arbitrary, non-empty string of max length 64 chars used to represent a subscription 9 | # 10 | # @api public 11 | # 12 | # @example 13 | # subscription.id # => 'c24881c305c5cfb7c1168be7e9b0e150' 14 | # 15 | # @return [String] 16 | # 17 | attr_reader :id 18 | 19 | # An object that determines what events will be sent in the subscription 20 | # 21 | # @api public 22 | # 23 | # @example 24 | # subscription.filter # => #, 25 | # @id="0dd7f3fa06cd5f797438dd0b7477f3c7"> 26 | # 27 | # @return [Filter] 28 | # 29 | attr_reader :filter 30 | 31 | # Initializes a subscription 32 | # 33 | # @api public 34 | # 35 | # @example Creating a subscription with no id and no filters 36 | # subscription = Nostr::Subscription.new 37 | # 38 | # @example Creating a subscription with an ID 39 | # subscription = Nostr::Subscription.new(id: 'c24881c305c5cfb7c1168be7e9b0e150') 40 | # 41 | # @example Subscribing to all events created after a certain time 42 | # subscription = Nostr::Subscription.new(filter: Nostr::Filter.new(since: 1230981305)) 43 | # 44 | # @param id [String] An arbitrary, non-empty string of max length 64 chars used to represent a subscription 45 | # @param filter [Filter] An object that determines what events will be sent in that subscription 46 | # 47 | def initialize(filter:, id: SecureRandom.hex) 48 | @id = id 49 | @filter = filter 50 | end 51 | 52 | # Compares two subscriptions. Returns true if all attributes are equal and false otherwise 53 | # 54 | # @api public 55 | # 56 | # @example 57 | # subscription1 == subscription1 # => true 58 | # 59 | # @return [Boolean] True if all attributes are equal and false otherwise 60 | # 61 | def ==(other) 62 | id == other.id && filter == other.filter 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/nostr/key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Key do 6 | let(:subclass) do 7 | Class.new(Nostr::Key) do 8 | def self.hrp 9 | 'npub' 10 | end 11 | 12 | protected 13 | 14 | def validate_hex_value(_hex_value) = nil 15 | end 16 | end 17 | 18 | let(:valid_hex) { 'a' * 64 } 19 | 20 | describe '.new' do 21 | it 'raises an error because this is an abstract class' do 22 | expect { described_class.new(valid_hex) }.to raise_error(/Subclasses must implement this method/) 23 | end 24 | end 25 | 26 | describe '.from_bech32' do 27 | context 'when given a valid Bech32 value' do 28 | let(:valid_bech32) { 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg' } 29 | 30 | it 'creates a new key' do 31 | expect { subclass.from_bech32(valid_bech32) }.not_to raise_error 32 | end 33 | end 34 | 35 | context 'when given an invalid Bech32 value' do 36 | let(:invalid_bech32) { 'this is obviously not valid' } 37 | 38 | it 'raises an error' do 39 | expect { subclass.from_bech32(invalid_bech32) }.to raise_error(ArgumentError, /Invalid nip19 string\./) 40 | end 41 | end 42 | end 43 | 44 | describe '.hrp' do 45 | context 'when called on the abstract class' do 46 | it 'raises an error because this is an abstract method' do 47 | expect { described_class.hrp }.to raise_error(/Subclasses must implement this method/) 48 | end 49 | end 50 | 51 | context 'when called on a subclass' do 52 | it 'returns the human readable part of a Bech32 string' do 53 | expect(subclass.hrp).to eq('npub') 54 | end 55 | end 56 | end 57 | 58 | describe '#to_bech32' do 59 | let(:key) { subclass.new(valid_hex) } 60 | 61 | it 'returns a bech32 string representation of the key' do 62 | expect(key.to_bech32).to match(/^npub[0-9a-z]+$/) 63 | end 64 | end 65 | 66 | describe '#validate_hex_value' do 67 | let(:invalid_hex) { 'g' * 64 } 68 | 69 | it 'raises an error because this is an abstract method' do 70 | expect { described_class.new(invalid_hex) }.to raise_error(/Subclasses must implement this method/) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/nostr/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schnorr' 4 | require 'json' 5 | 6 | module Nostr 7 | # Each user has a keypair. Signatures, public key, and encodings are done according to the 8 | # Schnorr signatures standard for the curve secp256k1. 9 | class User 10 | # A pair of private and public keys 11 | # 12 | # @api public 13 | # 14 | # @example 15 | # user.keypair # # 18 | # 19 | # @return [KeyPair] 20 | # 21 | attr_reader :keypair 22 | 23 | # Instantiates a user 24 | # 25 | # @api public 26 | # 27 | # @example Creating a user with no keypair 28 | # user = Nostr::User.new 29 | # 30 | # @example Creating a user with a keypair 31 | # user = Nostr::User.new(keypair: keypair) 32 | # 33 | # @param keypair [Keypair] A pair of private and public keys 34 | # @param keygen [Keygen] A private key and public key generator 35 | # 36 | def initialize(keypair: nil, keygen: Keygen.new) 37 | @keypair = keypair || keygen.generate_key_pair 38 | end 39 | 40 | # Builds an Event 41 | # 42 | # @api public 43 | # 44 | # @example Creating a note event 45 | # event = user.create_event( 46 | # kind: Nostr::EventKind::TEXT_NOTE, 47 | # content: 'Your feedback is appreciated, now pay $8' 48 | # ) 49 | # 50 | # @param created_at [Integer] Date of the creation of the vent. A UNIX timestamp, in seconds. 51 | # @param kind [Integer] The kind of the event. An integer from 0 to 3. 52 | # @param tags [Array] An array of tags. Each tag is an array of strings. 53 | # @param content [String] Arbitrary string. 54 | # 55 | # @return [Event] 56 | # 57 | def create_event( 58 | kind:, 59 | content:, 60 | created_at: Time.now.to_i, 61 | tags: [] 62 | ) 63 | event = Event.new( 64 | pubkey: keypair.public_key, 65 | kind:, 66 | content:, 67 | created_at:, 68 | tags: 69 | ) 70 | event.sign(keypair.private_key) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/nostr/client/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | class Client 5 | # Logs connection events, messages sent and received, errors, and connection closures. 6 | class Logger 7 | # Attaches event handlers to the specified Nostr client for logging purposes 8 | # 9 | # @api public 10 | # 11 | # @example Attaching the logger to a client 12 | # client = Nostr::Client.new 13 | # logger = Nostr::Client::Logger.new 14 | # logger.attach_to(client) 15 | # 16 | # # Now, actions like connecting, sending messages, receiving messages, 17 | # # errors, and closing the connection will be logged to the console. 18 | # 19 | # @param [Nostr::Client] client The client to attach logging functionality to. 20 | # 21 | # @return [void] 22 | # 23 | def attach_to(client) 24 | logger_instance = self 25 | 26 | client.on(:connect) { |relay| logger_instance.on_connect(relay) } 27 | client.on(:message) { |message| logger_instance.on_message(message) } 28 | client.on(:send) { |message| logger_instance.on_send(message) } 29 | client.on(:error) { |message| logger_instance.on_error(message) } 30 | client.on(:close) { |code, reason| logger_instance.on_close(code, reason) } 31 | end 32 | 33 | # Logs connection to a relay 34 | # 35 | # @api private 36 | # 37 | # @param [Nostr::Relay] relay The relay the client connected to. 38 | # 39 | # @return [void] 40 | # 41 | def on_connect(relay); end 42 | 43 | # Logs a message received from the relay 44 | # 45 | # @api private 46 | # 47 | # @param [String] message The message received. 48 | # 49 | # @return [void] 50 | # 51 | def on_message(message); end 52 | 53 | # Logs a message sent to the relay 54 | # 55 | # @api private 56 | # 57 | # @param [String] message The message sent. 58 | # 59 | # @return [void] 60 | # 61 | def on_send(message); end 62 | 63 | # Logs an error message 64 | # 65 | # @api private 66 | # 67 | # @param [String] message The error message. 68 | # 69 | # @return [void] 70 | # 71 | def on_error(message); end 72 | 73 | # Logs a closure of connection with a relay 74 | # 75 | # @api private 76 | # 77 | # @param [String] code The closure code. 78 | # @param [String] reason The reason for the closure. 79 | # 80 | # @return [void] 81 | # 82 | def on_close(code, reason); end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/nostr/events/encrypted_direct_message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Classes of event kinds. 5 | module Events 6 | # An event whose +content+ is encrypted. It can only be decrypted by the owner of the private key that pairs 7 | # the event's +pubkey+. 8 | class EncryptedDirectMessage < Event 9 | # Instantiates a new encrypted direct message 10 | # 11 | # @api public 12 | # 13 | # @example Instantiating a new encrypted direct message 14 | # Nostr::Events::EncryptedDirectMessage.new( 15 | # sender_private_key: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', 16 | # recipient_public_key: '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e', 17 | # plain_text: 'Your feedback is appreciated, now pay $8', 18 | # ) 19 | # 20 | # @example Instantiating a new encrypted direct message that references a previous direct message 21 | # Nostr::Events::EncryptedDirectMessage.new( 22 | # sender_private_key: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460', 23 | # recipient_public_key: '48df4af6e240ac5f7c5de89bf5941b249880be0e87d03685b178ccb1a315f52e', 24 | # plain_text: 'Your feedback is appreciated, now pay $8', 25 | # previous_direct_message: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' 26 | # ) 27 | # 28 | # @param plain_text [String] The +content+ of the encrypted message. 29 | # @param sender_private_key [PrivateKey] 32-bytes hex-encoded private key of the message's author. 30 | # @param recipient_public_key [PublicKey] 32-bytes hex-encoded public key of the recipient of the encrypted 31 | # message. 32 | # @param previous_direct_message [String] 32-bytes hex-encoded id identifying the previous message in a 33 | # conversation or a message we are explicitly replying to (such that contextual, more organized conversations 34 | # may happen 35 | # 36 | def initialize(plain_text:, sender_private_key:, recipient_public_key:, previous_direct_message: nil) 37 | crypto = Crypto.new 38 | keygen = Keygen.new 39 | 40 | encrypted_content = crypto.encrypt_text(sender_private_key, recipient_public_key, plain_text) 41 | sender_public_key = keygen.extract_public_key(sender_private_key) 42 | 43 | super( 44 | pubkey: sender_public_key, 45 | kind: Nostr::EventKind::ENCRYPTED_DIRECT_MESSAGE, 46 | content: encrypted_content, 47 | ) 48 | 49 | add_pubkey_reference(recipient_public_key) 50 | add_event_reference(previous_direct_message) if previous_direct_message 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/nostr/keygen_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Keygen do 6 | let(:keygen) { described_class.new } 7 | 8 | describe '.new' do 9 | it 'creates an instance of a keygen' do 10 | keygen = described_class.new 11 | 12 | expect(keygen).to be_an_instance_of(described_class) 13 | end 14 | end 15 | 16 | describe '.get_key_pair_from_private_key' do 17 | context 'when given a private key' do 18 | let(:private_key) { Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900') } 19 | 20 | it 'generates a key pair' do 21 | keypair = keygen.get_key_pair_from_private_key(private_key) 22 | 23 | aggregate_failures do 24 | expect(keypair.private_key).to be_an_instance_of(Nostr::PrivateKey) 25 | expect(keypair.public_key).to be_an_instance_of(Nostr::PublicKey) 26 | end 27 | end 28 | end 29 | 30 | context 'when given another kind of value' do 31 | let(:not_a_private_key) { 'something else' } 32 | 33 | it 'raises an error' do 34 | expect { keygen.get_key_pair_from_private_key(not_a_private_key) }.to raise_error( 35 | ArgumentError, 'private_key is not an instance of PrivateKey' 36 | ) 37 | end 38 | end 39 | end 40 | 41 | describe '#generate_key_pair' do 42 | it 'generates a private/public key pair' do 43 | keypair = keygen.generate_key_pair 44 | 45 | aggregate_failures do 46 | expect(keypair.private_key).to match(/[a-f0-9]{64}/) 47 | expect(keypair.public_key).to match(/[a-f0-9]{64}/) 48 | end 49 | end 50 | end 51 | 52 | describe '#generate_private_key' do 53 | it 'generates a private key' do 54 | private_key = keygen.generate_private_key 55 | 56 | expect(private_key).to match(/[a-f0-9]{64}/) 57 | end 58 | end 59 | 60 | describe '#extract_public_key' do 61 | context 'when the given value is not a private key' do 62 | let(:not_a_private_key) { 'something else' } 63 | 64 | it 'raises an error' do 65 | expect { keygen.extract_public_key(not_a_private_key) }.to raise_error( 66 | ArgumentError, 'private_key is not an instance of PrivateKey' 67 | ) 68 | end 69 | end 70 | 71 | context 'when the given value is a private key' do 72 | let(:private_key) { Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900') } 73 | 74 | it 'extracts a public key from a private key' do 75 | public_key = keygen.extract_public_key(private_key) 76 | 77 | expect(public_key).to eq('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/nostr/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Subscription do 6 | let(:filter) do 7 | Nostr::Filter.new(since: 1_230_981_305) 8 | end 9 | 10 | let(:subscription) do 11 | described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) 12 | end 13 | 14 | describe '#==' do 15 | context 'when both subscriptions have the same attributes' do 16 | it 'returns true' do 17 | subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) 18 | subscription2 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) 19 | 20 | expect(subscription1).to eq(subscription2) 21 | end 22 | end 23 | 24 | context 'when both subscriptions have a different id' do 25 | it 'returns false' do 26 | subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) 27 | subscription2 = described_class.new(id: '16605b59b539f6e86762f28fb57db2fd', filter:) 28 | 29 | expect(subscription1).not_to eq(subscription2) 30 | end 31 | end 32 | 33 | context 'when both subscriptions have a different filter' do 34 | let(:other_filter) do 35 | Nostr::Filter.new(since: 1_230_981_305, until: 1_292_190_341) 36 | end 37 | 38 | it 'returns false' do 39 | subscription1 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter:) 40 | subscription2 = described_class.new(id: 'c24881c305c5cfb7c1168be7e9b0e150', filter: other_filter) 41 | 42 | expect(subscription1).not_to eq(subscription2) 43 | end 44 | end 45 | end 46 | 47 | describe '.new' do 48 | context 'when no id is provided' do 49 | it 'creates an instance of a subscription using a randomly generated id' do 50 | allow(SecureRandom).to receive(:hex).and_return('a_random_string') 51 | 52 | subscription = described_class.new(filter:) 53 | 54 | expect(subscription.id).to eq('a_random_string') 55 | end 56 | end 57 | 58 | context 'when an id is provided' do 59 | it 'creates an instance of a subscription using that ID' do 60 | subscription = described_class.new( 61 | id: 'c24881c305c5cfb7c1168be7e9b0e150', 62 | filter: 63 | ) 64 | 65 | expect(subscription.id).to eq('c24881c305c5cfb7c1168be7e9b0e150') 66 | end 67 | end 68 | end 69 | 70 | describe '#filter' do 71 | it 'exposes the subscription filter' do 72 | expect(subscription.filter).to eq(filter) 73 | end 74 | end 75 | 76 | describe '#id' do 77 | it 'exposes the subscription id' do 78 | expect(subscription.id).to eq('c24881c305c5cfb7c1168be7e9b0e150') 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/nostr/key_pair_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::KeyPair do 6 | let(:keypair) do 7 | described_class.new( 8 | private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 9 | public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 10 | ) 11 | end 12 | 13 | describe '.new' do 14 | context 'when private_key is not an instance of PrivateKey' do 15 | it 'raises an error' do 16 | expect do 17 | described_class.new( 18 | private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', 19 | public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 20 | ) 21 | end.to raise_error(ArgumentError, 'private_key is not an instance of PrivateKey') 22 | end 23 | end 24 | 25 | context 'when public_key is not an instance of PublicKey' do 26 | it 'raises an error' do 27 | expect do 28 | described_class.new( 29 | private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 30 | public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' 31 | ) 32 | end.to raise_error(ArgumentError, 'public_key is not an instance of PublicKey') 33 | end 34 | end 35 | 36 | context 'when private_key is an instance of PrivateKey and public_key is an instance of PublicKey' do 37 | it 'creates an instance of a key pair' do 38 | keypair = described_class.new( 39 | private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 40 | public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 41 | ) 42 | 43 | expect(keypair).to be_an_instance_of(described_class) 44 | end 45 | end 46 | end 47 | 48 | describe '#private_key' do 49 | it 'exposes the private key' do 50 | expect(keypair.private_key).to eq('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900') 51 | end 52 | end 53 | 54 | describe '#public_key' do 55 | it 'exposes the public key' do 56 | expect(keypair.public_key).to eq('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 57 | end 58 | end 59 | 60 | describe '#to_ary' do 61 | it 'converts the key pair to an array' do 62 | expect(keypair.to_ary).to eq( 63 | [ 64 | Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 65 | Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 66 | ] 67 | ) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /docs/common-use-cases/logging-and-debugging.md: -------------------------------------------------------------------------------- 1 | # Logging and debugging 2 | 3 | The `Nostr::Client` class provides built-in logging functionality to help you debug and monitor client interactions with 4 | relays. By default, the client uses the `ColorLogger`, which logs events in color. However, you can customize the 5 | logging behavior or disable it entirely. 6 | 7 | ## Disabling logging 8 | 9 | To instantiate a client without any logging, simply pass `logger: nil` when creating the client instance: 10 | 11 | ```ruby 12 | client = Nostr::Client.new(logger: nil) 13 | ``` 14 | 15 | This will disable all logging for the client. 16 | 17 | ## Formatting the logging 18 | 19 | The `Nostr::Client::Logger` class is the base class for logging functionality. It defines the following methods for 20 | logging different events: 21 | 22 | - `on_connect(relay)`: Logs when the client connects to a relay. 23 | - `on_message(message)`: Logs a message received from the relay. 24 | - `on_send(message)`: Logs a message sent to the relay. 25 | - `on_error(message)`: Logs an error message. 26 | - `on_close(code, reason)`: Logs when the connection with a relay is closed. 27 | 28 | You can create your own logger by subclassing `Nostr::Client::Logger` and overriding these methods to customize the 29 | logging format. 30 | 31 | The `Nostr::Client::ColorLogger` is a built-in logger that logs events in color. It uses ANSI escape codes to add color 32 | to the log output. Here's an example of how the ColorLogger formats the log messages: 33 | 34 | - Connection: `"\u001b[32m\u001b[1mConnected to the relay\u001b[22m #{relay.name} (#{relay.url})\u001b[0m"` 35 | - Message received: `"\u001b[32m\u001b[1m◄-\u001b[0m #{message}"` 36 | - Message sent: `"\u001b[32m\u001b[1m-►\u001b[0m #{message}"` 37 | - Error: `"\u001b[31m\u001b[1mError: \u001b[22m#{message}\u001b[0m"` 38 | - Connection closed: `"\u001b[31m\u001b[1mConnection closed: \u001b[22m#{reason} (##{code})\u001b[0m"` 39 | 40 | ## Plain text logging 41 | 42 | If you prefer plain text logging without colors, you can use the `Nostr::Client::PlainLogger`. This logger formats the 43 | log messages in a simple, readable format without any ANSI escape codes. 44 | 45 | To use the `PlainLogger`, pass it as the `logger` option when creating the client instance: 46 | 47 | ```ruby 48 | require 'nostr/client/plain_logger' 49 | 50 | client = Nostr::Client.new(logger: Nostr::Client::PlainLogger.new) 51 | ``` 52 | 53 | The `PlainLogger` formats the log messages as follows: 54 | 55 | - Connection: `"Connected to the relay #{relay.name} (#{relay.url})"` 56 | - Message received: `"◄- #{message}"` 57 | - Message sent: `"-► #{message}"` 58 | - Error: `"Error: #{message}"` 59 | - Connection closed: `"Connection closed: #{reason} (##{code})"` 60 | 61 | By using the appropriate logger or creating your own custom logger, you can effectively debug and monitor your Nostr 62 | client's interactions with relays. 63 | -------------------------------------------------------------------------------- /spec/nostr/public_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::PublicKey do 6 | let(:valid_hex) { '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' } 7 | let(:public_key) { described_class.new(valid_hex) } 8 | 9 | describe '.new' do 10 | context 'when the public key is not a string' do 11 | it 'raises an InvalidKeyTypeError' do 12 | expect { described_class.new(1234) }.to raise_error( 13 | Nostr::InvalidKeyTypeError, 14 | 'Invalid public key type' 15 | ) 16 | end 17 | end 18 | 19 | context "when the public key's length is not 64 characters" do 20 | it 'raises an InvalidKeyLengthError' do 21 | expect { described_class.new('a' * 65) }.to raise_error( 22 | Nostr::InvalidKeyLengthError, 23 | 'Invalid public key length. It should have 64 characters.' 24 | ) 25 | end 26 | end 27 | 28 | context 'when the public key contains non-hexadecimal characters' do 29 | it 'raises an InvalidKeyFormatError' do 30 | expect { described_class.new('g' * 64) }.to raise_error( 31 | Nostr::InvalidKeyFormatError, 32 | 'Only lowercase hexadecimal characters are allowed in public keys.' 33 | ) 34 | end 35 | end 36 | 37 | context 'when the public key contains uppercase characters' do 38 | it 'raises an InvalidKeyFormatError' do 39 | expect { described_class.new('A' * 64) }.to raise_error( 40 | Nostr::InvalidKeyFormatError, 41 | 'Only lowercase hexadecimal characters are allowed in public keys.' 42 | ) 43 | end 44 | end 45 | 46 | context 'when the public key is valid' do 47 | it 'does not raise any error' do 48 | expect { described_class.new('a' * 64) }.not_to raise_error 49 | end 50 | end 51 | end 52 | 53 | describe '.from_bech32' do 54 | context 'when given a valid Bech32 value' do 55 | let(:valid_bech32) { 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg' } 56 | 57 | it 'instantiates a public key from a Bech32 encoded string' do 58 | expect(described_class.from_bech32(valid_bech32)).to eq(valid_hex) 59 | end 60 | end 61 | 62 | context 'when given an invalid Bech32 value' do 63 | let(:invalid_bech32) { 'this is obviously not valid' } 64 | 65 | it 'raises an error' do 66 | expect { described_class.from_bech32(invalid_bech32) }.to raise_error(ArgumentError, /Invalid nip19 string\./) 67 | end 68 | end 69 | end 70 | 71 | describe '.hrp' do 72 | it 'returns the human readable part of a Bech32 string' do 73 | expect(described_class.hrp).to eq('npub') 74 | end 75 | end 76 | 77 | describe '#to_bech32' do 78 | it 'converts the hex key to bech32' do 79 | expect(public_key.to_bech32).to eq('npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg') 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/nostr/private_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::PrivateKey do 6 | let(:valid_hex) { '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' } 7 | let(:private_key) { described_class.new(valid_hex) } 8 | 9 | describe '.new' do 10 | context 'when the private key is not a string' do 11 | it 'raises an InvalidKeyTypeError' do 12 | expect { described_class.new(1234) }.to raise_error( 13 | Nostr::InvalidKeyTypeError, 14 | 'Invalid private key type' 15 | ) 16 | end 17 | end 18 | 19 | context "when the private key's length is not 64 characters" do 20 | it 'raises an InvalidKeyLengthError' do 21 | expect { described_class.new('a' * 65) }.to raise_error( 22 | Nostr::InvalidKeyLengthError, 23 | 'Invalid private key length. It should have 64 characters.' 24 | ) 25 | end 26 | end 27 | 28 | context 'when the private key contains non-hexadecimal characters' do 29 | it 'raises an InvalidKeyFormatError' do 30 | expect { described_class.new('g' * 64) }.to raise_error( 31 | Nostr::InvalidKeyFormatError, 32 | 'Only lowercase hexadecimal characters are allowed in private keys.' 33 | ) 34 | end 35 | end 36 | 37 | context 'when the private key contains uppercase characters' do 38 | it 'raises an InvalidKeyFormatError' do 39 | expect { described_class.new('A' * 64) }.to raise_error( 40 | Nostr::InvalidKeyFormatError, 41 | 'Only lowercase hexadecimal characters are allowed in private keys.' 42 | ) 43 | end 44 | end 45 | 46 | context 'when the private key is valid' do 47 | it 'does not raise any error' do 48 | expect { described_class.new('a' * 64) }.not_to raise_error 49 | end 50 | end 51 | end 52 | 53 | describe '.from_bech32' do 54 | context 'when given a valid Bech32 value' do 55 | let(:valid_bech32) { 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5' } 56 | 57 | it 'instantiates a private key from a Bech32 encoded string' do 58 | expect(described_class.from_bech32(valid_bech32)).to eq(valid_hex) 59 | end 60 | end 61 | 62 | context 'when given an invalid Bech32 value' do 63 | let(:invalid_bech32) { 'this is obviously not valid' } 64 | 65 | it 'raises an error' do 66 | expect { described_class.from_bech32(invalid_bech32) }.to raise_error(ArgumentError, /Invalid nip19 string\./) 67 | end 68 | end 69 | end 70 | 71 | describe '.hrp' do 72 | it 'returns the human readable part of a Bech32 string' do 73 | expect(described_class.hrp).to eq('nsec') 74 | end 75 | end 76 | 77 | describe '#to_bech32' do 78 | it 'converts the hex key to bech32' do 79 | expect(private_key.to_bech32).to eq('nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5') 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /nostr.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/nostr/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'nostr' 7 | spec.version = Nostr::VERSION 8 | spec.authors = ['Wilson Silva'] 9 | spec.email = ['wilson.dsigns@gmail.com'] 10 | 11 | spec.summary = 'Client and relay implementation of the Nostr protocol.' 12 | spec.description = 'Client and relay implementation of the Nostr protocol.' 13 | spec.homepage = 'https://nostr-ruby.com/' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 3.3.0' 16 | spec.metadata['rubygems_mfa_required'] = 'true' 17 | 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = 'https://github.com/wilsonsilva/nostr' 20 | spec.metadata['changelog_uri'] = 'https://github.com/wilsonsilva/nostr/blob/main/CHANGELOG.md' 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = Dir.chdir(__dir__) do 25 | `git ls-files -z`.split("\x0").reject do |f| 26 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)}) 27 | end 28 | end 29 | 30 | spec.bindir = 'exe' 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ['lib'] 33 | 34 | spec.add_dependency 'bech32', '~> 1.4' 35 | spec.add_dependency 'bip-schnorr', '~> 0.7' 36 | spec.add_dependency 'ecdsa', '~> 1.2' 37 | spec.add_dependency 'event_emitter', '~> 0.2' 38 | spec.add_dependency 'faye-websocket', '~> 0.11' 39 | spec.add_dependency 'json', '~> 2.7' 40 | 41 | spec.add_development_dependency 'bundler-audit', '~> 0.9' 42 | spec.add_development_dependency 'dotenv', '~> 3.1' 43 | spec.add_development_dependency 'guard', '~> 2.18' 44 | spec.add_development_dependency 'guard-bundler', '~> 3.0' 45 | spec.add_development_dependency 'guard-bundler-audit', '~> 0.1' 46 | spec.add_development_dependency 'guard-rspec', '~> 4.7' 47 | spec.add_development_dependency 'guard-rubocop', '~> 1.5' 48 | spec.add_development_dependency 'overcommit', '~> 0.63' 49 | spec.add_development_dependency 'pry', '~> 0.14' 50 | spec.add_development_dependency 'puma', '~> 6.4' 51 | spec.add_development_dependency 'rack', '~> 3.0' 52 | spec.add_development_dependency 'rake', '~> 13.1' 53 | spec.add_development_dependency 'rbs', '~> 3.4' 54 | spec.add_development_dependency 'rspec', '~> 3.13' 55 | spec.add_development_dependency 'rubocop', '~> 1.62' 56 | spec.add_development_dependency 'rubocop-rake', '~> 0.6' 57 | spec.add_development_dependency 'rubocop-rspec', '2.29' 58 | spec.add_development_dependency 'simplecov', '= 0.17' 59 | spec.add_development_dependency 'simplecov-console', '~> 0.9' 60 | spec.add_development_dependency 'steep', '~> 1.7.dev3' 61 | spec.add_development_dependency 'typeprof', '~> 0.21' 62 | spec.add_development_dependency 'yard', '~> 0.9' 63 | spec.add_development_dependency 'yard-junk', '~> 0.0.9' 64 | spec.add_development_dependency 'yardstick', '~> 0.9' 65 | end 66 | -------------------------------------------------------------------------------- /lib/nostr/key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # Abstract class for all keys 5 | # 6 | # @api private 7 | # 8 | class Key < String 9 | # The regular expression for hexadecimal lowercase characters 10 | # 11 | # @return [Regexp] The regular expression for hexadecimal lowercase characters 12 | # 13 | FORMAT = /^[a-f0-9]+$/ 14 | 15 | # The length of the key in hex format 16 | # 17 | # @return [Integer] The length of the key in hex format 18 | # 19 | LENGTH = 64 20 | 21 | # Instantiates a new key. Can't be used directly because this is an abstract class. Raises a +KeyValidationError+ 22 | # 23 | # @see Nostr::PrivateKey 24 | # @see Nostr::PublicKey 25 | # 26 | # @param [String] hex_value Hex-encoded value of the key 27 | # 28 | # @raise [KeyValidationError] 29 | # 30 | def initialize(hex_value) 31 | validate_hex_value(hex_value) 32 | 33 | super(hex_value) 34 | end 35 | 36 | # Instantiates a key from a bech32 string 37 | # 38 | # @api public 39 | # 40 | # @example 41 | # bech32_key = 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5' 42 | # bech32_key.to_key # => # 43 | # 44 | # @raise [Nostr::InvalidHRPError] if the bech32 string is invalid. 45 | # 46 | # @param [String] bech32_value The bech32 string representation of the key. 47 | # 48 | # @return [Key] the key. 49 | # 50 | def self.from_bech32(bech32_value) 51 | type, data = Bech32.decode(bech32_value) 52 | 53 | raise InvalidHRPError.new(type, hrp) unless type == hrp 54 | 55 | new(data) 56 | end 57 | 58 | # Abstract method to be implemented by subclasses to provide the HRP (npub, nsec) 59 | # 60 | # @api private 61 | # 62 | # @return [String] The HRP 63 | # 64 | def self.hrp 65 | raise 'Subclasses must implement this method' 66 | end 67 | 68 | # Converts the key to a bech32 string representation 69 | # 70 | # @api public 71 | # 72 | # @example Converting a private key to a bech32 string 73 | # public_key = Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa') 74 | # public_key.to_bech32 # => 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5' 75 | # 76 | # @example Converting a public key to a bech32 string 77 | # public_key = Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e') 78 | # public_key.to_bech32 # => 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg' 79 | # 80 | # @return [String] The bech32 string representation of the key 81 | # 82 | def to_bech32 = Bech32.encode(hrp: self.class.hrp, data: self) 83 | 84 | protected 85 | 86 | # Validates the hex value during initialization 87 | # 88 | # @api private 89 | # 90 | # @param [String] _hex_value The hex value of the key 91 | # 92 | # @raise [KeyValidationError] When the hex value is invalid 93 | # 94 | # @return [void] 95 | # 96 | def validate_hex_value(_hex_value) 97 | raise 'Subclasses must implement this method' 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/nostr/events/encrypted_direct_message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Events::EncryptedDirectMessage do 6 | describe '.new' do 7 | let(:sender_keypair) do 8 | Nostr::KeyPair.new( 9 | public_key: Nostr::PublicKey.new('8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca'), 10 | private_key: Nostr::PrivateKey.new('3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757') 11 | ) 12 | end 13 | 14 | let(:recipient_keypair) do 15 | Nostr::KeyPair.new( 16 | public_key: Nostr::PublicKey.new('6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0'), 17 | private_key: Nostr::PrivateKey.new('22cea01c33eccf30fdd54cb6728f814f6de00c778aafd721e017f4582545f9cf') 18 | ) 19 | end 20 | 21 | let(:crypto) { Nostr::Crypto.new } 22 | 23 | it 'creates an instance of an encrypted private message event that can be decrypted by its recipient' do 24 | encrypted_direct_message = described_class.new( 25 | sender_private_key: sender_keypair.private_key, 26 | recipient_public_key: recipient_keypair.public_key, 27 | plain_text: 'Your feedback is appreciated, now pay $8' 28 | ) 29 | 30 | plain_text = crypto.decrypt_text( 31 | recipient_keypair.private_key, 32 | sender_keypair.public_key, 33 | encrypted_direct_message.content 34 | ) 35 | 36 | aggregate_failures do 37 | expect(encrypted_direct_message.content).not_to eq(plain_text) 38 | expect(plain_text).to eq('Your feedback is appreciated, now pay $8') 39 | end 40 | end 41 | 42 | it 'adds a reference to the recipient in the tags' do 43 | encrypted_direct_message = described_class.new( 44 | sender_private_key: sender_keypair.private_key, 45 | recipient_public_key: recipient_keypair.public_key, 46 | plain_text: 'Your feedback is appreciated, now pay $8' 47 | ) 48 | 49 | expect(encrypted_direct_message.tags).to eq([['p', recipient_keypair.public_key]]) 50 | end 51 | 52 | context 'when previous_direct_message is omitted' do 53 | it 'does not add a reference to a previous message' do 54 | encrypted_direct_message = described_class.new( 55 | sender_private_key: sender_keypair.private_key, 56 | recipient_public_key: recipient_keypair.public_key, 57 | plain_text: 'Your feedback is appreciated, now pay $8' 58 | ) 59 | 60 | expect(encrypted_direct_message.tags).to eq([['p', recipient_keypair.public_key]]) 61 | end 62 | end 63 | 64 | context 'when previous_direct_message is given' do 65 | let(:previous_direct_message_id) { 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' } 66 | 67 | it 'adds a reference to a previous message' do 68 | encrypted_direct_message = described_class.new( 69 | sender_private_key: sender_keypair.private_key, 70 | recipient_public_key: recipient_keypair.public_key, 71 | plain_text: 'Your feedback is appreciated, now pay $8', 72 | previous_direct_message: previous_direct_message_id 73 | ) 74 | 75 | expect(encrypted_direct_message.tags).to eq( 76 | [ 77 | ['p', recipient_keypair.public_key], 78 | ['e', previous_direct_message_id] 79 | ] 80 | ) 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /docs/subscriptions/filtering-subscription-events.md: -------------------------------------------------------------------------------- 1 | # Filtering events 2 | 3 | ## Filtering by id 4 | 5 | You can filter events by their ids: 6 | 7 | ```ruby 8 | filter = Nostr::Filter.new( 9 | ids: [ 10 | # matches events with these exact IDs 11 | '8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8', 12 | '461544014d87c9eaf3e76e021240007dff2c7afb356319f99c741b45749bf82f', 13 | ] 14 | ) 15 | subscription = client.subscribe(filter: filter) 16 | ``` 17 | 18 | ## Filtering by author 19 | 20 | You can filter events by their author's pubkey: 21 | 22 | ```ruby 23 | filter = Nostr::Filter.new( 24 | authors: [ 25 | # matches events whose (authors) pubkey match these exact IDs 26 | 'b698043170d580f8ae5bad4ac80b1fdb508e957f0bbffe97f2a8915fa8b34070', 27 | '51f853ff4894b062950e46ebed8c1c7015160f8173994414a96dd286f65f0f49', 28 | ] 29 | ) 30 | subscription = client.subscribe(filter: filter) 31 | ``` 32 | 33 | ## Filtering by kind 34 | 35 | You can filter events by their kind: 36 | 37 | ```ruby 38 | filter = Nostr::Filter.new( 39 | kinds: [ 40 | # matches events whose kind is TEXT_NOTE 41 | Nostr::EventKind::TEXT_NOTE, 42 | # and matches events whose kind is CONTACT_LIST 43 | Nostr::EventKind::CONTACT_LIST, 44 | ] 45 | ) 46 | subscription = client.subscribe(filter: filter) 47 | ``` 48 | 49 | ## Filtering by referenced event 50 | 51 | You can filter events by the events they reference (in their `e` tag): 52 | 53 | ```ruby 54 | filter = Nostr::Filter.new( 55 | e: [ 56 | # matches events that reference other events whose ids match these exact IDs 57 | 'f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52', 58 | 'f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8', 59 | ] 60 | ) 61 | subscription = client.subscribe(filter: filter) 62 | ``` 63 | 64 | ## Filtering by referenced pubkey 65 | 66 | You can filter events by the pubkeys they reference (in their `p` tag): 67 | 68 | ```ruby 69 | filter = Nostr::Filter.new( 70 | p: [ 71 | # matches events that reference other pubkeys that match these exact IDs 72 | 'b698043170d580f8ae5bad4ac80b1fdb508e957f0bbffe97f2a8915fa8b34070', 73 | '51f853ff4894b062950e46ebed8c1c7015160f8173994414a96dd286f65f0f49', 74 | ] 75 | ) 76 | subscription = client.subscribe(filter: filter) 77 | ``` 78 | 79 | ## Filtering by timestamp 80 | 81 | You can filter events by their timestamp: 82 | 83 | ```ruby 84 | filter = Nostr::Filter.new( 85 | since: 1230981305, # matches events that are newer than this timestamp 86 | until: 1292190341, # matches events that are older than this timestamp 87 | ) 88 | subscription = client.subscribe(filter: filter) 89 | ``` 90 | 91 | ## Limiting the number of events 92 | 93 | You can limit the number of events received: 94 | 95 | ```ruby 96 | filter = Nostr::Filter.new( 97 | limit: 420, # matches at most 420 events 98 | ) 99 | subscription = client.subscribe(filter: filter) 100 | ``` 101 | 102 | ## Combining filters 103 | 104 | You can combine filters. For example, to match `5` text note events that are newer than `1230981305` from the author 105 | `ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577`: 106 | 107 | ```ruby 108 | filter = Nostr::Filter.new( 109 | authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], 110 | kinds: [Nostr::EventKind::TEXT_NOTE], 111 | since: 1230981305, 112 | limit: 5, 113 | ) 114 | subscription = client.subscribe(filter: filter) 115 | ``` 116 | -------------------------------------------------------------------------------- /lib/nostr/key_pair.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # A pair of public and private keys 5 | class KeyPair 6 | # 32-bytes hex-encoded private key 7 | # 8 | # @api public 9 | # 10 | # @example 11 | # keypair.private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' 12 | # 13 | # @return [PrivateKey] 14 | # 15 | attr_reader :private_key 16 | 17 | # 32-bytes hex-encoded public key 18 | # 19 | # @api public 20 | # 21 | # @example 22 | # keypair.public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' 23 | # 24 | # @return [PublicKey] 25 | # 26 | attr_reader :public_key 27 | 28 | # Instantiates a key pair 29 | # 30 | # @api public 31 | # 32 | # @example 33 | # keypair = Nostr::KeyPair.new( 34 | # private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 35 | # public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558'), 36 | # ) 37 | # 38 | # @param private_key [PrivateKey] 32-bytes hex-encoded private key. 39 | # @param public_key [PublicKey] 32-bytes hex-encoded public key. 40 | # 41 | # @raise ArgumentError when the private key is not a {PrivateKey} 42 | # @raise ArgumentError when the public key is not a {PublicKey} 43 | # 44 | def initialize(private_key:, public_key:) 45 | validate_keys(private_key, public_key) 46 | 47 | @private_key = private_key 48 | @public_key = public_key 49 | end 50 | 51 | # Allows array destructuring of the KeyPair, enabling the extraction of +PrivateKey+ and +PublicKey+ separately 52 | # 53 | # @api public 54 | # 55 | # @example Implicit usage of `to_ary` for destructuring 56 | # keypair = Nostr::KeyPair.new( 57 | # private_key: Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d'), 58 | # public_key: Nostr::PublicKey.new('15678d8fbc126fa326fac536acd5a6dcb5ef64b3d939abe31d6830cba6cd26d6'), 59 | # ) 60 | # # The `to_ary` method can be implicitly used for array destructuring: 61 | # private_key, public_key = keypair 62 | # # Now `private_key` and `public_key` hold the respective values. 63 | # 64 | # @example Explicit usage of `to_ary` 65 | # array_representation = keypair.to_ary 66 | # # array_representation is now an array: [PrivateKey, PublicKey] 67 | # # where PrivateKey and PublicKey are the respective objects. 68 | # 69 | # @return [Array] An array containing the {PrivateKey} and {PublicKey} in that order 70 | # 71 | def to_ary 72 | [private_key, public_key] 73 | end 74 | 75 | private 76 | 77 | # Validates the keys 78 | # 79 | # @api private 80 | # 81 | # @param private_key [PrivateKey] 32-bytes hex-encoded private key. 82 | # @param public_key [PublicKey] 32-bytes hex-encoded public key. 83 | # 84 | # @raise ArgumentError when the private key is not a +PrivateKey+ 85 | # @raise ArgumentError when the public key is not a +PublicKey+ 86 | # 87 | # @return [void] 88 | # 89 | def validate_keys(private_key, public_key) 90 | raise ArgumentError, 'private_key is not an instance of PrivateKey' unless private_key.is_a?(Nostr::PrivateKey) 91 | raise ArgumentError, 'public_key is not an instance of PublicKey' unless public_key.is_a?(Nostr::PublicKey) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /docs/core/client.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | Clients establish a WebSocket connection to [relays](../relays/connecting-to-a-relay). Through this connection, clients 4 | communicate and subscribe to a range of [Nostr events](../events) based on specified subscription filters. These filters 5 | define the Nostr events a client wishes to receive updates about. 6 | 7 | ::: info 8 | Clients do not need to sign up or create an account to use Nostr. Upon connecting to a relay, a client provides 9 | its subscription filters. The relay then streams events that match these filters to the client for the duration of the 10 | connection. 11 | ::: 12 | 13 | ## WebSocket events 14 | 15 | Communication between clients and relays happen via WebSockets. The client will emit WebSocket events when the 16 | connection is __opened__, __closed__, when a __message__ is received or when there's an __error__. 17 | 18 | ::: info 19 | WebSocket events are not [Nostr events](https://nostr.com/the-protocol/events). They are events emitted by the 20 | WebSocket connection. The WebSocket `:message` event, however, contains a Nostr event in its payload. 21 | ::: 22 | 23 | ### connect 24 | 25 | The `:connect` event is fired when a connection with a WebSocket is opened. You must call `Nostr::Client#connect` first. 26 | 27 | ```ruby 28 | client = Nostr::Client.new 29 | relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') 30 | 31 | client.on :connect do |relay| 32 | # When this block executes, you're connected to the relay 33 | end 34 | 35 | # Connect to a relay asynchronously 36 | client.connect(relay) 37 | ``` 38 | 39 | Once the connection is open, you can send events to the relay, manage subscriptions, etc. 40 | 41 | ::: tip 42 | Define the connection event handler before calling 43 | [`Nostr::Client#connect`](https://www.rubydoc.info/gems/nostr/Nostr/Client#connect-instance_method). Otherwise, 44 | you may miss the event. 45 | ::: 46 | 47 | ### error 48 | 49 | The `:error` event is fired when a connection with a WebSocket has been closed because of an error. 50 | 51 | ```ruby 52 | client.on :error do |error_message| 53 | puts error_message 54 | end 55 | 56 | # > Network error: wss://rsslay.fiatjaf.com: Unable to verify the 57 | # server certificate for 'rsslay.fiatjaf.com' 58 | ``` 59 | 60 | ### message 61 | 62 | The `:message` event is fired when data is received through a WebSocket. 63 | 64 | ```ruby 65 | client.on :message do |message| 66 | puts message 67 | end 68 | ``` 69 | 70 | The message will be one of these 4 types, which must also be JSON arrays, according to the following patterns: 71 | - `["EVENT", , ]` 72 | - `["OK", , , ]` 73 | - `["EOSE", ]` 74 | - `["NOTICE", ]` 75 | 76 | ::: details Click me to see how a WebSocket message looks like 77 | ```ruby 78 | [ 79 | "EVENT", 80 | "d34107357089bfc9882146d3bfab0386", 81 | { 82 | "content": "", 83 | "created_at": 1676456512, 84 | "id": "18f63550da74454c5df7caa2a349edc5b2a6175ea4c5367fa4b4212781e5b310", 85 | "kind": 3, 86 | "pubkey": "117a121fa41dc2caa0b3d6c5b9f42f90d114f1301d39f9ee96b646ebfee75e36", 87 | "sig": "d171420bd62cf981e8f86f2dd8f8f86737ea2bbe2d98da88db092991d125535860d982139a3c4be39886188613a9912ef380be017686a0a8b74231dc6e0b03cb", 88 | "tags":[ 89 | ["p", "1cc821cc2d47191b15fcfc0f73afed39a86ac6fb34fbfa7993ee3e0f0186ef7c"] 90 | ] 91 | } 92 | ] 93 | ``` 94 | ::: 95 | 96 | ### close 97 | 98 | The `:close` event is fired when a connection with a WebSocket is closed. 99 | 100 | ```ruby 101 | client.on :close do |code, reason| 102 | puts "Error: #{code} - #{reason}" 103 | end 104 | ``` 105 | 106 | ::: tip 107 | This handler is useful to attempt to reconnect to the relay. 108 | ::: 109 | -------------------------------------------------------------------------------- /sig/vendor/event_machine.rbs: -------------------------------------------------------------------------------- 1 | # Added only to satisfy the Steep requirements. Not 100% reliable. 2 | module EventMachine 3 | ERRNOS: Hash[untyped, untyped] 4 | P: untyped 5 | self.@next_tick_mutex: Thread::Mutex 6 | self.@reactor_running: bool 7 | self.@next_tick_queue: Array[^-> untyped] 8 | self.@tails: Array[nil] 9 | self.@resultqueue: (Array[untyped] | Thread::Queue)? 10 | self.@threadqueue: Thread::Queue? 11 | self.@threadpool: Array[untyped]? 12 | self.@all_threads_spawned: bool 13 | self.@reactor_pid: Integer 14 | self.@conns: Hash[untyped, untyped] 15 | self.@acceptors: Hash[untyped, Array[(Array[untyped] | Integer)?]] 16 | self.@timers: Hash[untyped, Integer | ^-> untyped | false] 17 | self.@wrapped_exception: Exception? 18 | self.@reactor_thread: Thread? 19 | self.@threadpool_size: bot 20 | self.@error_handler: bot 21 | 22 | def self.run: (?untyped blk, ?nil tail) ?{ -> untyped } -> nil 23 | def self.run_block: -> nil 24 | def self.reactor_thread?: -> bool 25 | def self.schedule: (*untyped a) -> nil 26 | def self.fork_reactor: -> Integer? 27 | def self.cleanup_machine: -> Array[untyped] 28 | def self.add_shutdown_hook: -> Array[nil] 29 | def self.add_timer: (*Integer | ^-> untyped args) ?{ -> untyped } -> nil 30 | def self.add_periodic_timer: (*untyped args) -> untyped 31 | def self.cancel_timer: (untyped timer_or_sig) -> false? 32 | def self.stop_event_loop: -> untyped 33 | def self.start_server: (untyped server, ?nil port, ?nil handler, *untyped args) -> untyped 34 | def self.attach_server: (untyped sock, ?nil handler, *untyped args) -> untyped 35 | def self.stop_server: (untyped signature) -> untyped 36 | def self.start_unix_domain_server: (untyped filename, *untyped args) -> untyped 37 | def self.connect: (untyped server, ?nil port, ?nil handler, *untyped args) -> untyped 38 | def self.bind_connect: (nil bind_addr, nil bind_port, untyped server, ?nil port, ?nil handler, *untyped args) -> untyped 39 | def self.watch: (untyped io, ?nil handler, *untyped args) -> untyped 40 | def self.attach: (untyped io, ?nil handler, *untyped args) -> untyped 41 | def self.attach_io: (untyped io, bool watch_mode, ?nil handler, *untyped args) -> untyped 42 | def self.reconnect: (untyped server, untyped port, untyped handler) -> untyped 43 | def self.connect_unix_domain: (untyped socketname, *untyped args) -> untyped 44 | def self.open_datagram_socket: (untyped address, untyped port, ?nil handler, *untyped args) -> untyped 45 | def self.set_quantum: (untyped mills) -> untyped 46 | def self.set_max_timers: (untyped ct) -> untyped 47 | def self.get_max_timers: -> untyped 48 | def self.connection_count: -> untyped 49 | def self.run_deferred_callbacks: -> Integer 50 | def self.defer: (?nil op, ?nil callback, ?nil errback) -> untyped 51 | def self.spawn_threadpool: -> true 52 | def self.defers_finished?: -> bool 53 | def self.next_tick: (?nil pr) { -> nil } -> nil 54 | def self.set_effective_user: (untyped username) -> untyped 55 | def self.set_descriptor_table_size: (?nil n_descriptors) -> untyped 56 | def self.popen: (untyped cmd, ?nil handler, *untyped args) -> untyped 57 | def self.reactor_running?: -> bool 58 | def self.open_keyboard: (?nil handler, *untyped args) -> untyped 59 | def self.watch_file: (untyped filename, ?nil handler, *untyped args) -> untyped 60 | def self.watch_process: (untyped pid, ?nil handler, *untyped args) -> untyped 61 | def self.error_handler: (?nil cb) -> nil 62 | def self.enable_proxy: (untyped from, untyped to, ?Integer bufsize, ?Integer length) -> untyped 63 | def self.disable_proxy: (untyped from) -> untyped 64 | def self.heartbeat_interval: -> untyped 65 | def self.heartbeat_interval=: (untyped time) -> untyped 66 | def self.event_callback: (untyped conn_binding, untyped opcode, untyped data) -> Integer? 67 | def self._open_file_for_writing: (untyped filename, ?nil handler) -> untyped 68 | def self.klass_from_handler: (?untyped klass, ?Integer? handler, *nil args) -> Integer 69 | end 70 | -------------------------------------------------------------------------------- /lib/nostr/keygen.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ecdsa' 4 | require 'securerandom' 5 | 6 | module Nostr 7 | # Generates private keys, public keys and key pairs. 8 | class Keygen 9 | # Instantiates a new keygen 10 | # 11 | # @api public 12 | # 13 | # @example 14 | # keygen = Nostr::Keygen.new 15 | # 16 | def initialize 17 | @group = ECDSA::Group::Secp256k1 18 | end 19 | 20 | # Generates a pair of private and public keys 21 | # 22 | # @api public 23 | # 24 | # @example 25 | # keypair = keygen.generate_key_pair 26 | # keypair # # 29 | # 30 | # @return [KeyPair] An object containing a private key and a public key. 31 | # 32 | def generate_key_pair 33 | private_key = generate_private_key 34 | public_key = extract_public_key(private_key) 35 | 36 | KeyPair.new(private_key:, public_key:) 37 | end 38 | 39 | # Generates a private key 40 | # 41 | # @api public 42 | # 43 | # @example 44 | # private_key = keygen.generate_private_key 45 | # private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' 46 | # 47 | # @return [PrivateKey] A 32-bytes hex-encoded private key. 48 | # 49 | def generate_private_key 50 | hex_value = (SecureRandom.random_number(group.order - 1) + 1).to_s(16).rjust(64, '0') 51 | PrivateKey.new(hex_value) 52 | end 53 | 54 | # Extracts a public key from a private key 55 | # 56 | # @api public 57 | # 58 | # @example 59 | # private_key = keygen.generate_private_key 60 | # public_key = keygen.extract_public_key(private_key) 61 | # public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' 62 | # 63 | # @param [PrivateKey] private_key A 32-bytes hex-encoded private key. 64 | # 65 | # @raise [ArgumentError] if the private key is not an instance of +PrivateKey+ 66 | # 67 | # @return [PublicKey] A 32-bytes hex-encoded public key. 68 | # 69 | def extract_public_key(private_key) 70 | validate_private_key(private_key) 71 | hex_value = group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0') 72 | PublicKey.new(hex_value) 73 | end 74 | 75 | # Builds a key pair from an existing private key 76 | # 77 | # @api public 78 | # 79 | # @example 80 | # private_key = Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900') 81 | # keygen.get_key_pair_from_private_key(private_key) 82 | # 83 | # @param private_key [PrivateKey] 32-bytes hex-encoded private key. 84 | # 85 | # @raise [ArgumentError] if the private key is not an instance of +PrivateKey+ 86 | # 87 | # @return [Nostr::KeyPair] 88 | # 89 | def get_key_pair_from_private_key(private_key) 90 | validate_private_key(private_key) 91 | public_key = extract_public_key(private_key) 92 | KeyPair.new(private_key:, public_key:) 93 | end 94 | 95 | private 96 | 97 | # The elliptic curve group. Used to generate public and private keys 98 | # 99 | # @api private 100 | # 101 | # @return [ECDSA::Group] 102 | # 103 | attr_reader :group 104 | 105 | # Validates that the private key is an instance of +PrivateKey+ 106 | # 107 | # @api private 108 | # 109 | # @raise [ArgumentError] if the private key is not an instance of +PrivateKey+ 110 | # 111 | # @return [void] 112 | # 113 | def validate_private_key(private_key) 114 | raise ArgumentError, 'private_key is not an instance of PrivateKey' unless private_key.is_a?(Nostr::PrivateKey) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /adr/0003-logging-methods-vs-logger-class.md: -------------------------------------------------------------------------------- 1 | # 3. Logging methods vs logger class 2 | 3 | Date: 2024-03-19 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | I'm deciding between integrating logging directly into the main class or creating a dedicated logger class. 12 | 13 | ### Option 1: Logging methods 14 | 15 | The first approach weaves logging actions into the operational code, resulting in a tight coupling of functionality and 16 | logging. Classes should be open for extension but closed for modification, and this strategy violates that principle. 17 | 18 | ```ruby 19 | class Client 20 | def connect(relay) 21 | execute_within_an_em_thread do 22 | client = build_websocket_client(relay.url) 23 | parent_to_child_channel.subscribe do |msg| 24 | client.send(msg) 25 | emit(:send, msg) 26 | log_send(msg) # <------ new code 27 | end 28 | 29 | client.on :open do 30 | child_to_parent_channel.push(type: :open, relay:) 31 | log_connection_opened(relay) # <------ new code 32 | end 33 | 34 | client.on :message do |event| 35 | child_to_parent_channel.push(type: :message, data: event.data) 36 | log_message_received(event.data) # <------ new code 37 | end 38 | 39 | client.on :error do |event| 40 | child_to_parent_channel.push(type: :error, message: event.message) 41 | log_error(event.message) # <------ new code 42 | end 43 | 44 | client.on :close do |event| 45 | child_to_parent_channel.push(type: :close, code: event.code, reason: event.reason) 46 | log_connection_closed(event.code, event.reason) # <------ new code 47 | end 48 | end 49 | 50 | # ------ new code below ------ 51 | 52 | def log_send(msg) 53 | logger.info("Message sent: #{msg}") 54 | end 55 | 56 | def log_connection_opened(relay) 57 | logger.info("Connection opened to #{relay.url}") 58 | end 59 | 60 | def log_message_received(data) 61 | logger.info("Message received: #{data}") 62 | end 63 | 64 | def log_error(message) 65 | logger.error("Error: #{message}") 66 | end 67 | 68 | def log_connection_closed(code, reason) 69 | logger.info("Connection closed with code: #{code}, reason: #{reason}") 70 | end 71 | end 72 | end 73 | ``` 74 | 75 | ### Option 2: Logger class 76 | 77 | The second strategy separates logging into its own class, promoting cleaner code and adherence to the Single 78 | Responsibility Principle. Client already exposes events that can be tapped into, so the logger class can listen to these 79 | events and log accordingly. 80 | 81 | ```ruby 82 | class ClientLogger 83 | def attach_to(client) 84 | logger_instance = self 85 | 86 | client.on(:connect) { |relay| logger_instance.on_connect(relay) } 87 | client.on(:message) { |message| logger_instance.on_message(message) } 88 | client.on(:send) { |message| logger_instance.on_send(message) } 89 | client.on(:error) { |message| logger_instance.on_error(message) } 90 | client.on(:close) { |code, reason| logger_instance.on_close(code, reason) } 91 | end 92 | 93 | def on_connect(relay); end 94 | def on_message(message); end 95 | def on_send(message); end 96 | def on_error(message); end 97 | def on_close(code, reason); end 98 | end 99 | 100 | client = Nostr::Client.new 101 | logger = Nostr::ClientLogger.new 102 | logger.attach_to(client) 103 | ``` 104 | 105 | This approach decouples logging from the main class, making it easier to maintain and extend the logging system without 106 | affecting the core logic. 107 | 108 | ## Decision 109 | 110 | I've chosen the dedicated logger class route. This choice is driven by a desire for extensibility in the logging system. 111 | With a separate logger, I can easily modify logging behavior—like changing formats, adjusting verbosity levels, 112 | switching colors, or altering output destinations (files, networks, etc.) — without needing to rewrite any of the main 113 | operational code. 114 | 115 | ## Consequences 116 | 117 | Adopting a dedicated logger class offers greater flexibility and simplifies maintenance, making it straightforward to 118 | adjust how and what I log independently of the core logic. This separation of concerns means that any future changes 119 | to logging preferences or requirements can be implemented quickly and without risk to the main class's functionality. 120 | However, it's important to manage the integration carefully to avoid introducing complexity, such as handling 121 | dependencies and ensuring seamless communication between the main operations and the logging system. 122 | 123 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | import { withMermaid } from "vitepress-plugin-mermaid"; 3 | 4 | // https://vitepress.dev/reference/site-config 5 | // https://www.npmjs.com/package/vitepress-plugin-mermaid 6 | export default defineConfig(withMermaid({ 7 | title: "Nostr", 8 | description: "Documentation of the Nostr Ruby gem", 9 | // https://vitepress.dev/reference/site-config#head 10 | head: [ 11 | ['link', { rel: 'icon', href: '/favicon.ico' }], 12 | [ 13 | 'script', 14 | { 15 | defer: true, 16 | 'data-domain': 'nostr-ruby.com', 17 | src: 'https://plausible.io/js/script.js' 18 | } 19 | ] 20 | ], 21 | themeConfig: { 22 | // https://vitepress.dev/reference/default-theme-last-updated 23 | lastUpdated: true, 24 | 25 | // https://vitepress.dev/reference/default-theme-config 26 | nav: [ 27 | { text: 'Home', link: '/' }, 28 | { text: 'Guide', link: '/getting-started/overview' } 29 | ], 30 | 31 | // https://vitepress.dev/reference/default-theme-search 32 | search: { 33 | provider: 'local' 34 | }, 35 | 36 | // https://vitepress.dev/reference/default-theme-sidebar 37 | sidebar: [ 38 | { 39 | text: 'Getting started', 40 | collapsed: false, 41 | items: [ 42 | { text: 'Overview', link: '/getting-started/overview' }, 43 | { text: 'Installation', link: '/getting-started/installation' }, 44 | ] 45 | }, 46 | { 47 | text: 'Core', 48 | collapsed: false, 49 | items: [ 50 | { text: 'Client', link: '/core/client' }, 51 | { text: 'Keys', link: '/core/keys' }, 52 | { text: 'User', link: '/core/user' }, 53 | ] 54 | }, 55 | { 56 | text: 'Relays', 57 | items: [ 58 | { text: 'Connecting to a relay', link: '/relays/connecting-to-a-relay' }, 59 | { text: 'Publishing events', link: '/relays/publishing-events' }, 60 | { text: 'Receiving events', link: '/relays/receiving-events' }, 61 | ] 62 | }, 63 | { 64 | text: 'Subscriptions', 65 | collapsed: false, 66 | items: [ 67 | { text: 'Creating a subscription', link: '/subscriptions/creating-a-subscription' }, 68 | { text: 'Filtering subscription events', link: '/subscriptions/filtering-subscription-events' }, 69 | { text: 'Updating a subscription', link: '/subscriptions/updating-a-subscription' }, 70 | { text: 'Deleting a subscription', link: '/subscriptions/deleting-a-subscription' }, 71 | ] 72 | }, 73 | { 74 | text: 'Events', 75 | link: '/events', 76 | collapsed: false, 77 | items: [ 78 | { text: 'Set Metadata', link: '/events/set-metadata' }, 79 | { text: 'Text Note', link: '/events/text-note' }, 80 | { text: 'Recommend Server', link: '/events/recommend-server' }, 81 | { text: 'Contact List', link: '/events/contact-list' }, 82 | { text: 'Encrypted Direct Message', link: '/events/encrypted-direct-message' }, 83 | ] 84 | }, 85 | { 86 | text: 'Common use cases', 87 | collapsed: false, 88 | items: [ 89 | { text: 'Logging and debugging', link: '/common-use-cases/logging-and-debugging' }, 90 | { text: 'Bech32 enc/decoding (NIP-19)', link: '/common-use-cases/bech32-encoding-and-decoding-(NIP-19)' }, 91 | { text: 'Signing/verifying messages', link: '/common-use-cases/signing-and-verifying-messages' }, 92 | { text: 'Signing/verifying events', link: '/common-use-cases/signing-and-verifying-events' }, 93 | ] 94 | }, 95 | { 96 | text: 'Implemented NIPs', 97 | link: '/implemented-nips', 98 | }, 99 | ], 100 | 101 | // https://vitepress.dev/reference/default-theme-config#sociallinks 102 | socialLinks: [ 103 | { icon: 'github', link: 'https://github.com/wilsonsilva/nostr' } 104 | ], 105 | 106 | // https://vitepress.dev/reference/default-theme-edit-link 107 | editLink: { 108 | pattern: 'https://github.com/wilsonsilva/nostr/edit/main/docs/:path', 109 | text: 'Edit this page on GitHub' 110 | }, 111 | 112 | // https://vitepress.dev/reference/default-theme-footer 113 | footer: { 114 | message: 'Released under the MIT License.', 115 | copyright: 'Copyright © 2023-present Wilson Silva' 116 | } 117 | }, 118 | 119 | // https://vitepress.dev/reference/site-config#ignoredeadlinks 120 | ignoreDeadLinks: [ 121 | /^https?:\/\/localhost/ 122 | ], 123 | })) 124 | -------------------------------------------------------------------------------- /spec/nostr/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::User do 6 | let(:keypair) do 7 | Nostr::KeyPair.new( 8 | private_key: Nostr::PrivateKey.new('893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900'), 9 | public_key: Nostr::PublicKey.new('2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558') 10 | ) 11 | end 12 | 13 | let(:user) do 14 | described_class.new(keypair:) 15 | end 16 | 17 | describe '.new' do 18 | context 'when no key pair is provided' do 19 | let(:nostr_keygen) do 20 | instance_double(Nostr::Keygen, generate_key_pair: keypair) 21 | end 22 | 23 | it 'creates an instance of a user with a new key pair' do 24 | allow(Nostr::Keygen).to receive(:new).and_return(nostr_keygen) 25 | 26 | user = described_class.new 27 | 28 | aggregate_failures do 29 | expect(user).to be_an_instance_of(described_class) 30 | expect(user.keypair).to eq(keypair) 31 | end 32 | end 33 | end 34 | 35 | context 'when a key pair is provided' do 36 | it 'creates an instance of a user using the provided key pair' do 37 | user = described_class.new(keypair:) 38 | 39 | expect(user.keypair).to eq(keypair) 40 | end 41 | end 42 | end 43 | 44 | describe '#keypair' do 45 | it 'exposes the user key pair' do 46 | expect(user.keypair).to eq(keypair) 47 | end 48 | end 49 | 50 | describe '#create_event' do 51 | context 'when created_at is missing' do 52 | let(:now) { instance_double(Time, to_i: 1_230_981_305) } 53 | 54 | before { allow(Time).to receive(:now).and_return(now) } 55 | 56 | it 'builds and signs an event using the user key pair and the current time' do 57 | event = user.create_event( 58 | kind: Nostr::EventKind::TEXT_NOTE, 59 | tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], 60 | content: 'Your feedback is appreciated, now pay $8' 61 | ) 62 | 63 | expect(event).to eq( 64 | Nostr::Event.new( 65 | id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', 66 | pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', 67 | created_at: 1_230_981_305, 68 | kind: 1, 69 | tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], 70 | content: 'Your feedback is appreciated, now pay $8', 71 | sig: '6e852020d5c70527674a21ab7d47db3c355cdbac443a80f5fe2b956f536b75b1' \ 72 | '3fdfb28a2ffc09cb2438a61b020aaa62e8df7bb08471ccf7839a48350e485937' 73 | ) 74 | ) 75 | end 76 | end 77 | 78 | context 'when tags is missing' do 79 | it 'builds and signs an event using the user key pair and empty tags' do 80 | event = user.create_event( 81 | kind: Nostr::EventKind::TEXT_NOTE, 82 | tags: [], 83 | created_at: 1_230_981_305, 84 | content: 'Your feedback is appreciated, now pay $8' 85 | ) 86 | 87 | expect(event).to eq( 88 | Nostr::Event.new( 89 | id: 'ed35331e66dc166109d45daff39b8c1bf83d4c0c7a59a9a8a23b240dc126d526', 90 | pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', 91 | created_at: 1_230_981_305, 92 | kind: 1, 93 | tags: [], 94 | content: 'Your feedback is appreciated, now pay $8', 95 | sig: '48256fea31f2cabf0e92b5f67c2f654c9647be15b8bc6f381673af3748e15c76' \ 96 | 'b8d505019fc4e75d79be668c18f57b69b76d95b639cca8ae9a5817d569a8d12b' 97 | ) 98 | ) 99 | end 100 | end 101 | 102 | context 'when all attributes are present' do 103 | it 'builds and signs an event using the user key pair' do 104 | event = user.create_event( 105 | created_at: 1_230_981_305, 106 | kind: Nostr::EventKind::TEXT_NOTE, 107 | tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], 108 | content: 'Your feedback is appreciated, now pay $8' 109 | ) 110 | 111 | expect(event).to eq( 112 | Nostr::Event.new( 113 | id: '2a3184512d34077601e992ba3c3215354b21a8c76f85c2c7f66093481854e811', 114 | pubkey: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', 115 | created_at: 1_230_981_305, 116 | kind: 1, 117 | tags: [%w[e 189df012cfff8a075785b884bd702025f4a7a37710f581c4ac9d33e24b585408]], 118 | content: 'Your feedback is appreciated, now pay $8', 119 | sig: '6e852020d5c70527674a21ab7d47db3c355cdbac443a80f5fe2b956f536b75b1' \ 120 | '3fdfb28a2ffc09cb2438a61b020aaa62e8df7bb08471ccf7839a48350e485937' 121 | ) 122 | ) 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/nostr/bech32_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Bech32 do 6 | let(:keypair) { Nostr::Keygen.new.generate_key_pair } 7 | let(:private_key) { keypair.private_key } 8 | let(:public_key) { keypair.public_key } 9 | 10 | describe '.encode' do 11 | it 'encodes data into the bech32 format' do 12 | npub = described_class.encode(hrp: 'npub', data: public_key) 13 | expect(npub).to match(/npub1\w+/) 14 | end 15 | end 16 | 17 | describe '.decode' do 18 | it 'decodes data from the bech32 format' do 19 | npub = described_class.encode(hrp: 'npub', data: public_key) 20 | type, decoded = described_class.decode(npub) 21 | 22 | aggregate_failures do 23 | expect(type).to eq('npub') 24 | expect(decoded).to eq(public_key) 25 | end 26 | end 27 | end 28 | 29 | describe '.nsec_encode' do 30 | it 'encodes and decodes hexadecimal private keys' do 31 | nsec = described_class.nsec_encode(private_key) 32 | type, data = described_class.decode(nsec) 33 | 34 | aggregate_failures do 35 | expect(nsec).to match(/nsec1\w+/) 36 | expect(type).to eq('nsec') 37 | expect(data).to eq(private_key) 38 | end 39 | end 40 | end 41 | 42 | describe '.npub_encode' do 43 | it 'encodes and decodes hexadecimal public keys' do 44 | npub = described_class.npub_encode(public_key) 45 | type, data = described_class.decode(npub) 46 | 47 | aggregate_failures do 48 | expect(npub).to match(/npub1\w+/) 49 | expect(type).to eq('npub') 50 | expect(data).to eq(public_key) 51 | end 52 | end 53 | end 54 | 55 | describe '.nprofile_encode' do 56 | it 'encodes and decodes nprofiles with relays' do 57 | relay_urls = %w[wss://relay.damus.io wss://nos.lol] 58 | nprofile = described_class.nprofile_encode(pubkey: public_key, relays: relay_urls) 59 | type, profile = described_class.decode(nprofile) 60 | 61 | aggregate_failures do 62 | expect(nprofile).to match(/nprofile1\w+/) 63 | expect(type).to eq('nprofile') 64 | expect(profile.entries[0].value).to eq(public_key) 65 | expect(profile.entries[1].value).to eq(relay_urls[0]) 66 | expect(profile.entries[2].value).to eq(relay_urls[1]) 67 | end 68 | end 69 | 70 | it 'encodes and decodes nprofiles without relays' do 71 | nprofile = described_class.nprofile_encode(pubkey: public_key) 72 | type, profile = described_class.decode(nprofile) 73 | 74 | aggregate_failures do 75 | expect(nprofile).to match(/nprofile1\w+/) 76 | expect(type).to eq('nprofile') 77 | expect(profile.entries[0].value).to eq(public_key) 78 | end 79 | end 80 | end 81 | 82 | describe '.naddr_encode' do 83 | it 'encodes and decodes naddr' do 84 | relay_urls = %w[wss://relay.damus.io wss://nos.lol] 85 | naddr = described_class.naddr_encode( 86 | pubkey: public_key, 87 | relays: relay_urls, 88 | kind: 1984, 89 | identifier: 'damus' 90 | ) 91 | type, addr = described_class.decode(naddr) 92 | 93 | aggregate_failures do 94 | expect(naddr).to match(/naddr1\w+/) 95 | expect(type).to eq('naddr') 96 | expect(addr.entries[0].value).to eq(public_key) 97 | expect(addr.entries[1].value).to eq(relay_urls[0]) 98 | expect(addr.entries[2].value).to eq(relay_urls[1]) 99 | expect(addr.entries[3].value).to eq(1984) 100 | expect(addr.entries[4].value).to eq('damus') 101 | end 102 | end 103 | end 104 | 105 | describe '.nevent_encode' do 106 | it 'encodes and decodes nevent' do 107 | relay_urls = %w[wss://relay.damus.io wss://nos.lol] 108 | nevent = described_class.nevent_encode( 109 | id: '0fdb90f8e234d3400edafdd26d493f12efc0d7de2c6f9f21f997847d33ad2ea3', 110 | relays: relay_urls, 111 | kind: Nostr::EventKind::TEXT_NOTE 112 | ) 113 | type, event = described_class.decode(nevent) 114 | 115 | aggregate_failures do 116 | expect(nevent).to match(/nevent1\w+/) 117 | expect(type).to eq('nevent') 118 | expect(event.entries[0].value).to eq('0fdb90f8e234d3400edafdd26d493f12efc0d7de2c6f9f21f997847d33ad2ea3') 119 | expect(event.entries[1].value).to eq(relay_urls[0]) 120 | expect(event.entries[2].value).to eq(relay_urls[1]) 121 | expect(event.entries[3].value).to eq(Nostr::EventKind::TEXT_NOTE) 122 | end 123 | end 124 | end 125 | 126 | describe '.nrelay_encode' do 127 | it 'encodes and decodes nrelay' do 128 | relay_url = 'wss://relay.damus.io' 129 | nrelay = described_class.nrelay_encode(relay_url) 130 | type, data = described_class.decode(nrelay) 131 | 132 | aggregate_failures do 133 | expect(nrelay).to match(/nrelay1\w+/) 134 | expect(type).to eq('nrelay') 135 | expect(data.entries[0].value).to eq(relay_url) 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /docs/getting-started/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | editLink: true 3 | --- 4 | 5 | # Getting started 6 | 7 | This gem abstracts the complexity that you would face when trying to connect to relays web sockets, send and receive 8 | events, handle events callbacks and much more. 9 | 10 | ## Visual overview 11 | 12 | Begin your journey with an overview of the essential functions. A visual representation below maps out the key 13 | components we'll delve into in this section. 14 | 15 | ```mermaid 16 | classDiagram 17 | class Client { 18 | connect(relay) 19 | publish(event) 20 | subscribe(subscription_id, filter) 21 | unsubscribe(subscription_id) 22 | } 23 | class Relay { 24 | url 25 | name 26 | } 27 | class Event { 28 | pubkey 29 | created_at 30 | kind 31 | tags 32 | content 33 | add_event_reference(event_id) 34 | add_pubkey_reference(pubkey) 35 | serialize() 36 | to_h() 37 | sign(private_key) 38 | verify_signature() 39 | } 40 | class Subscription { 41 | id 42 | filter 43 | } 44 | class Filter { 45 | ids 46 | authors 47 | kinds 48 | since 49 | until 50 | limit 51 | to_h() 52 | } 53 | class EventKind { 54 | <> 55 | SET_METADATA 56 | TEXT_NOTE 57 | RECOMMEND_SERVER 58 | CONTACT_LIST 59 | ENCRYPTED_DIRECT_MESSAGE 60 | } 61 | class KeyPair { 62 | private_key 63 | public_key 64 | } 65 | class Keygen { 66 | generate_key_pair() 67 | generate_private_key() 68 | extract_public_key(private_key) 69 | } 70 | class User { 71 | keypair 72 | create_event(event_attributes) 73 | } 74 | 75 | Client --> Relay : connects via
WebSockets to 76 | Client --> Event : uses WebSockets to
publish and receive 77 | Client --> Subscription : receives Events via 78 | Subscription --> Filter : uses 79 | Event --> EventKind : is of kind 80 | User --> KeyPair : has 81 | User --> Event : creates and signs 82 | User --> Keygen : uses to generate keys 83 | Keygen --> KeyPair : generates 84 | ``` 85 | 86 | ## Code overview 87 | 88 | Explore the provided code snippet to learn about initializing the Nostr [client](../core/client.md), generating 89 | a [keypair](../core/keys), [publishing](../relays/publishing-events) an event, and 90 | efficiently [managing event subscriptions](../subscriptions/creating-a-subscription) (including event reception, 91 | filtering, and WebSocket event handling). 92 | 93 | ```ruby 94 | # Require the gem 95 | require 'nostr' 96 | 97 | # Instantiate a client 98 | client = Nostr::Client.new 99 | 100 | # a) Use an existing keypair 101 | keypair = Nostr::KeyPair.new( 102 | private_key: Nostr::PrivateKey.new('your-hex-private-key'), 103 | public_key: Nostr::PublicKey.new('your-hex-public-key'), 104 | ) 105 | 106 | # b) Or build a keypair from a private key 107 | keygen = Nostr::Keygen.new 108 | keypair = keygen.get_key_pair_from_private_key( 109 | Nostr::PrivateKey.new('your-hex-private-key') 110 | ) 111 | 112 | # c) Or create a new keypair 113 | keygen = Nostr::Keygen.new 114 | keypair = keygen.generate_key_pair 115 | 116 | # Create a user with the keypair 117 | user = Nostr::User.new(keypair: keypair) 118 | 119 | # Create a signed event 120 | text_note_event = user.create_event( 121 | kind: Nostr::EventKind::TEXT_NOTE, 122 | content: 'Your feedback is appreciated, now pay $8' 123 | ) 124 | 125 | # Connect asynchronously to a relay 126 | relay = Nostr::Relay.new(url: 'wss://nostr.wine', name: 'Wine') 127 | client.connect(relay) 128 | 129 | # Listen asynchronously for the connect event 130 | client.on :connect do 131 | # Send the event to the Relay 132 | client.publish(text_note_event) 133 | 134 | # Create a filter to receive the first 20 text notes 135 | # and encrypted direct messages from the relay that 136 | # were created in the previous hour 137 | filter = Nostr::Filter.new( 138 | kinds: [ 139 | Nostr::EventKind::TEXT_NOTE, 140 | Nostr::EventKind::ENCRYPTED_DIRECT_MESSAGE 141 | ], 142 | since: Time.now.to_i - 3600, # 1 hour ago 143 | until: Time.now.to_i, 144 | limit: 20, 145 | ) 146 | 147 | # Subscribe to events matching conditions of a filter 148 | subscription = client.subscribe(filter: filter) 149 | 150 | # Unsubscribe from events matching the filter above 151 | client.unsubscribe(subscription.id) 152 | end 153 | 154 | # Listen for incoming messages and print them 155 | client.on :message do |message| 156 | puts message 157 | end 158 | 159 | # Listen for error messages 160 | client.on :error do |error_message| 161 | # Handle the error 162 | end 163 | 164 | # Listen for the close event 165 | client.on :close do |code, reason| 166 | # You may attempt to reconnect to the relay here 167 | end 168 | 169 | # This line keeps the background client from exiting immediately. 170 | gets 171 | ``` 172 | 173 | Beyond what's covered here, the Nostr protocol and this gem boast a wealth of additional functionalities. For an 174 | in-depth exploration of these capabilities, proceed to the next page. 175 | -------------------------------------------------------------------------------- /spec/nostr/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Filter do 6 | let(:filter) do 7 | described_class.new( 8 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 9 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 10 | kinds: [0, 1, 2], 11 | since: 1_230_981_305, 12 | until: 1_292_190_341, 13 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 14 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 15 | ) 16 | end 17 | 18 | describe '#==' do 19 | context 'when both filters have the same attributes' do 20 | it 'returns true' do 21 | filter1 = described_class.new( 22 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 23 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 24 | kinds: [0, 1, 2], 25 | since: 1_230_981_305, 26 | until: 1_292_190_341, 27 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 28 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 29 | ) 30 | 31 | filter2 = described_class.new( 32 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 33 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 34 | kinds: [0, 1, 2], 35 | since: 1_230_981_305, 36 | until: 1_292_190_341, 37 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 38 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 39 | ) 40 | 41 | expect(filter1).to eq(filter2) 42 | end 43 | end 44 | 45 | context 'when both filters have at least one different attribute' do 46 | it 'returns false' do 47 | filter1 = described_class.new( 48 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 49 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 50 | kinds: [0, 1, 2], 51 | since: 1_230_981_305, 52 | until: 1_292_190_341, 53 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 54 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 55 | ) 56 | 57 | filter2 = described_class.new( 58 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 59 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 60 | kinds: [1], 61 | since: 1_230_981_305, 62 | until: 1_292_190_341, 63 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 64 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 65 | ) 66 | 67 | expect(filter1).not_to eq(filter2) 68 | end 69 | end 70 | end 71 | 72 | describe '.new' do 73 | it 'creates an instance of a filter' do 74 | filter = described_class.new( 75 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 76 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 77 | kinds: [0, 1, 2], 78 | since: 1_230_981_305, 79 | until: 1_292_190_341, 80 | e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 81 | p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 82 | ) 83 | 84 | expect(filter).to be_an_instance_of(described_class) 85 | end 86 | end 87 | 88 | describe '#ids' do 89 | it 'exposes the filter ids' do 90 | expect(filter.ids).to eq(['c24881c305c5cfb7c1168be7e9b0e150']) 91 | end 92 | end 93 | 94 | describe '#authors' do 95 | it 'exposes the filter authors' do 96 | expect(filter.authors).to eq(['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7']) 97 | end 98 | end 99 | 100 | describe '#kinds' do 101 | it 'exposes the filter kinds' do 102 | expect(filter.kinds).to eq([0, 1, 2]) 103 | end 104 | end 105 | 106 | describe '#since' do 107 | it 'exposes the filter since' do 108 | expect(filter.since).to eq(1_230_981_305) 109 | end 110 | end 111 | 112 | describe '#until' do 113 | it 'exposes the filter until' do 114 | expect(filter.until).to eq(1_292_190_341) 115 | end 116 | end 117 | 118 | describe '#e' do 119 | it 'exposes the filter e' do 120 | expect(filter.e).to eq(['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2']) 121 | end 122 | end 123 | 124 | describe '#p' do 125 | it 'exposes the filter p' do 126 | expect(filter.p).to eq(['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7']) 127 | end 128 | end 129 | 130 | describe '#to_h' do 131 | it 'converts the filter to a hash' do 132 | expect(filter.to_h).to eq( 133 | ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 134 | authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 135 | kinds: [0, 1, 2], 136 | '#e': ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 137 | '#p': ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 138 | since: 1_230_981_305, 139 | until: 1_292_190_341 140 | ) 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at wilson.dsigns@gmail.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /lib/nostr/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Nostr 4 | # A filter determines what events will be sent in a subscription. 5 | class Filter 6 | # A list of event ids 7 | # 8 | # @api public 9 | # 10 | # @example 11 | # filter.ids # => ['c24881c305c5cfb7c1168be7e9b0e150', '35deb2612efdb9e13e8b0ca4fc162341'] 12 | # 13 | # @return [Array, nil] 14 | # 15 | attr_reader :ids 16 | 17 | # A list of pubkeys, the pubkey of an event must be one of these 18 | # 19 | # @api public 20 | # 21 | # @example 22 | # filter.authors # => ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 23 | # 24 | # @return [Array, nil] 25 | # 26 | attr_reader :authors 27 | 28 | # A list of a kind numbers 29 | # 30 | # @api public 31 | # 32 | # @example 33 | # filter.kinds # => [0, 1, 2] 34 | # 35 | # @return [Array, nil] 36 | # 37 | attr_reader :kinds 38 | 39 | # A list of event ids that are referenced in an "e" tag 40 | # 41 | # @api public 42 | # 43 | # @example 44 | # filter.e # => ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'] 45 | # 46 | # @return [Array, nil] 47 | # 48 | attr_reader :e 49 | 50 | # A list of pubkeys that are referenced in a "p" tag 51 | # 52 | # @api public 53 | # 54 | # @example 55 | # filter.p # => ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 56 | # 57 | # @return [Array, nil] 58 | # 59 | attr_reader :p 60 | 61 | # A timestamp, events must be newer than this to pass 62 | # 63 | # @api public 64 | # 65 | # @example 66 | # filter.since # => 1230981305 67 | # 68 | # @return [Integer, nil] 69 | # 70 | attr_reader :since 71 | 72 | # A timestamp, events must be older than this to pass 73 | # 74 | # @api public 75 | # 76 | # @example 77 | # filter.until # => 1292190341 78 | # 79 | # @return [Integer, nil] 80 | # 81 | attr_reader :until 82 | 83 | # Maximum number of events to be returned in the initial query 84 | # 85 | # @api public 86 | # 87 | # @example 88 | # filter.limit # => 420 89 | # 90 | # @return [Integer, nil] 91 | # 92 | attr_reader :limit 93 | 94 | # Instantiates a new Filter 95 | # 96 | # @api public 97 | # 98 | # @example 99 | # Nostr::Filter.new( 100 | # ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 101 | # authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 102 | # kinds: [0, 1, 2], 103 | # since: 1230981305, 104 | # until: 1292190341, 105 | # e: ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 106 | # p: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'] 107 | # ) 108 | # 109 | # @param kwargs [Hash] 110 | # @option kwargs [Array, nil] ids A list of event ids 111 | # @option kwargs [Array, nil] authors A list of pubkeys, the pubkey of an event must be one 112 | # of these 113 | # @option kwargs [Array, nil] kinds A list of a kind numbers 114 | # @option kwargs [Array, nil] e A list of event ids that are referenced in an "e" tag 115 | # @option kwargs [Array] p A list of pubkeys that are referenced in a "p" tag 116 | # @option kwargs [Integer, nil] since A timestamp, events must be newer than this to pass 117 | # @option kwargs [Integer, nil] until A timestamp, events must be older than this to pass 118 | # @option kwargs [Integer, nil] limit Maximum number of events to be returned in the initial query 119 | # 120 | def initialize(**kwargs) 121 | @ids = kwargs[:ids] 122 | @authors = kwargs[:authors] 123 | @kinds = kwargs[:kinds] 124 | @e = kwargs[:e] 125 | @p = kwargs[:p] 126 | @since = kwargs[:since] 127 | @until = kwargs[:until] 128 | @limit = kwargs[:limit] 129 | end 130 | 131 | # Converts the filter to a hash, removing all empty attributes 132 | # 133 | # @api public 134 | # 135 | # @example 136 | # filter.to_h # => 137 | # { 138 | # ids: ['c24881c305c5cfb7c1168be7e9b0e150'], 139 | # authors: ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 140 | # kinds: [0, 1, 2], 141 | # '#e': ['7bdb422f254194ae4bb86d354c0bd5a888fce233ffc77dceb3e844ceec1fcfb2'], 142 | # '#p': ['000000001c5c45196786e79f83d21fe801549fdc98e2c26f96dcef068a5dbcd7'], 143 | # since: 1230981305, 144 | # until: 1292190341 145 | # } 146 | # 147 | # @return [Hash] The filter as a hash. 148 | # 149 | def to_h 150 | { 151 | ids:, 152 | authors:, 153 | kinds:, 154 | '#e': e, 155 | '#p': p, 156 | since:, 157 | until: self.until, 158 | limit: 159 | }.compact 160 | end 161 | 162 | # Compares two filters. Returns true if all attributes are equal and false otherwise 163 | # 164 | # @api public 165 | # 166 | # @example 167 | # filter == filter # => true 168 | # 169 | # @return [Boolean] True if all attributes are equal and false otherwise 170 | # 171 | def ==(other) 172 | to_h == other.to_h 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /docs/core/keys.md: -------------------------------------------------------------------------------- 1 | # Keys 2 | 3 | To [sign events](#signing-an-event), you need a **private key**. To verify signatures, you need a **public key**. The combination of a 4 | private and a public key is called a **keypair**. 5 | 6 | Both public and private keys are 64-character hexadecimal strings. They can be represented in bech32 format, 7 | which is a human-readable format that starts with `nsec` for private keys and `npub` for public keys. 8 | 9 | There are a few ways to generate a keypair. 10 | 11 | ## a) Generating a keypair 12 | 13 | If you don't have any keys, you can generate a keypair using the 14 | [`Nostr::Keygen`](https://www.rubydoc.info/gems/nostr/Nostr/Keygen) class: 15 | 16 | ```ruby 17 | keygen = Nostr::Keygen.new 18 | keypair = keygen.generate_key_pair 19 | 20 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 21 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 22 | ``` 23 | 24 | ## b) Generating a private key and a public key 25 | 26 | Alternatively, if you have already generated a private key, you can extract the corresponding public key by calling 27 | `Keygen#extract_public_key`: 28 | 29 | ```ruby 30 | keygen = Nostr::Keygen.new 31 | 32 | private_key = keygen.generate_private_key 33 | public_key = keygen.extract_public_key(private_key) 34 | 35 | private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 36 | public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 37 | ``` 38 | 39 | ## c) Using existing hexadecimal keys 40 | 41 | If you already have a private key and a public key in hexadecimal format, you can create a keypair using the 42 | `Nostr::KeyPair` class: 43 | 44 | ```ruby 45 | keypair = Nostr::KeyPair.new( 46 | private_key: Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'), 47 | public_key: Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 48 | ) 49 | 50 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 51 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 52 | ``` 53 | 54 | ### d) Use existing bech32 keys 55 | 56 | If you already have a private key and a public key in bech32 format, you can create a keypair using the 57 | `Nostr::KeyPair` class: 58 | 59 | ```ruby 60 | keypair = Nostr::KeyPair.new( 61 | private_key: Nostr::PrivateKey.from_bech32('nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5'), 62 | public_key: Nostr::PublicKey.from_bech32('npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg'), 63 | ) 64 | 65 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 66 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 67 | ``` 68 | 69 | ## e) Using an existing hexadecimal private key 70 | 71 | If you already have a private key in hexadecimal format, you can create a keypair using the method 72 | [`Nostr::Keygen#get_key_pair_from_private_key`](https://www.rubydoc.info/gems/nostr/Nostr/Keygen#get_key_pair_from_private_key-instance_method): 73 | 74 | ```ruby 75 | private_key = Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa') 76 | 77 | keygen= Nostr::Keygen.new 78 | keypair = keygen.get_key_pair_from_private_key(private_key) 79 | 80 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 81 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 82 | ``` 83 | 84 | ## f) Using an existing bech32 private key 85 | 86 | If you already have a private key in bech32 format, you can create a keypair using the methods 87 | [`Nostr::PrivateKey.from_bech32`](https://www.rubydoc.info/gems/nostr/Nostr/PrivateKey.from_bech32-class_method) and 88 | [`Nostr::Keygen#get_key_pair_from_private_key`](https://www.rubydoc.info/gems/nostr/Nostr/Keygen#get_key_pair_from_private_key-instance_method): 89 | 90 | ```ruby 91 | private_key = Nostr::PrivateKey.from_bech32('nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5') 92 | 93 | keygen= Nostr::Keygen.new 94 | keypair = keygen.get_key_pair_from_private_key(private_key) 95 | 96 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 97 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 98 | ``` 99 | 100 | ## Signing an event 101 | 102 | KeyPairs are used to sign [events](../events). To create a signed event, you need to instantiate a 103 | [`Nostr::User`](https://www.rubydoc.info/gems/nostr/Nostr/User) with a keypair: 104 | 105 | ```ruby{8,11-14} 106 | # Use an existing keypair 107 | keypair = Nostr::KeyPair.new( 108 | private_key: Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'), 109 | public_key: Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 110 | ) 111 | 112 | # Add the keypair to a user 113 | user = Nostr::User.new(keypair: keypair) 114 | 115 | # Create signed events 116 | text_note = user.create_event( 117 | kind: Nostr::EventKind::TEXT_NOTE, 118 | content: 'Your feedback is appreciated, now pay $8' 119 | ) 120 | ``` 121 | 122 | ::: details Click me to view the text_note 123 | 124 | ```ruby 125 | # text_note.to_h 126 | { 127 | id: '030fbc71151379e5b58e7428ed6e7f2884e5dfc9087fd64d1dc4cc677f5097c8', 128 | pubkey: '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e', # from the keypair 129 | created_at: 1700119819, 130 | kind: 1, # Nostr::EventKind::TEXT_NOTE, 131 | tags: [], 132 | content: 'Your feedback is appreciated, now pay $8', 133 | sig: '586877896ef6f7d54fa4dd2ade04e3fdc4dfcd6166dd0df696b3c3c768868c0b690338f5baed6ab4fc717785333cb487363384de9fb0f740ac4775522cb4acb3' # signed with the private key from the keypair 134 | } 135 | ``` 136 | ::: 137 | -------------------------------------------------------------------------------- /spec/nostr/crypto_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Nostr::Crypto do 6 | let(:crypto) { described_class.new } 7 | 8 | describe '#check_sig!' do 9 | let(:keypair) do 10 | Nostr::KeyPair.new( 11 | public_key: Nostr::PublicKey.new('15678d8fbc126fa326fac536acd5a6dcb5ef64b3d939abe31d6830cba6cd26d6'), 12 | private_key: Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d') 13 | ) 14 | end 15 | let(:message) { 'Your feedback is appreciated, now pay $8' } 16 | 17 | context 'when the signature is valid' do 18 | it 'returns true' do 19 | signature = crypto.sign_message(message, keypair.private_key) 20 | 21 | expect(crypto.check_sig!(message, keypair.public_key, signature)).to be(true) 22 | end 23 | end 24 | 25 | context 'when the signature is invalid' do 26 | it 'raises an error' do 27 | signature = Nostr::Signature.new('badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb' \ 28 | 'badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb') 29 | 30 | expect do 31 | crypto.check_sig!(message, keypair.public_key, signature) 32 | end.to raise_error(Schnorr::InvalidSignatureError, 'signature verification failed.') 33 | end 34 | end 35 | end 36 | 37 | describe '#valid_sig?' do 38 | let(:keypair) do 39 | Nostr::KeyPair.new( 40 | public_key: Nostr::PublicKey.new('15678d8fbc126fa326fac536acd5a6dcb5ef64b3d939abe31d6830cba6cd26d6'), 41 | private_key: Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d') 42 | ) 43 | end 44 | let(:message) { 'Your feedback is appreciated, now pay $8' } 45 | 46 | context 'when the signature is valid' do 47 | it 'returns true' do 48 | signature = crypto.sign_message(message, keypair.private_key) 49 | 50 | expect(crypto.valid_sig?(message, keypair.public_key, signature)).to be(true) 51 | end 52 | end 53 | 54 | context 'when the signature is invalid' do 55 | it 'returns false' do 56 | signature = Nostr::Signature.new('badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb' \ 57 | 'badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb') 58 | 59 | expect(crypto.valid_sig?(message, keypair.public_key, signature)).to be(false) 60 | end 61 | end 62 | end 63 | 64 | describe '#sign_event' do 65 | let(:keypair) do 66 | Nostr::KeyPair.new( 67 | public_key: Nostr::PublicKey.new('8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca'), 68 | private_key: Nostr::PrivateKey.new('3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757') 69 | ) 70 | end 71 | 72 | let(:event) do 73 | Nostr::Event.new( 74 | kind: Nostr::EventKind::TEXT_NOTE, 75 | content: 'Your feedback is appreciated, now pay $8', 76 | pubkey: keypair.public_key 77 | ) 78 | end 79 | 80 | it 'signs an event' do 81 | signed_event = crypto.sign_event(event, keypair.private_key) 82 | 83 | aggregate_failures do 84 | expect(signed_event.id.length).to eq(64) 85 | expect(signed_event.sig.length).to eq(128) 86 | end 87 | end 88 | end 89 | 90 | describe '#sign_message' do 91 | let(:private_key) { Nostr::PrivateKey.new('7d1e4219a5e7d8342654c675085bfbdee143f0eb0f0921f5541ef1705a2b407d') } 92 | let(:message) { 'Your feedback is appreciated, now pay $8' } 93 | 94 | it 'signs a message' do 95 | signature = crypto.sign_message(message, private_key) 96 | hex_signature = '0fa6d8e26f44ddad9eca5be2b8a25d09338c1767f8bfce384046c8eb771d1120e4bda5ca49' \ 97 | '27e74837f912d4810945af6abf8d38139c1347f2d71ba8c52b175b' 98 | 99 | expect(signature).to eq(Nostr::Signature.new(hex_signature)) 100 | end 101 | end 102 | 103 | describe '#encrypt_text' do 104 | let(:sender_keypair) do 105 | Nostr::KeyPair.new( 106 | public_key: Nostr::PublicKey.new('8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca'), 107 | private_key: Nostr::PrivateKey.new('3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757') 108 | ) 109 | end 110 | 111 | let(:recipient_keypair) do 112 | Nostr::KeyPair.new( 113 | public_key: Nostr::PublicKey.new('6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0'), 114 | private_key: Nostr::PrivateKey.new('22cea01c33eccf30fdd54cb6728f814f6de00c778aafd721e017f4582545f9cf') 115 | ) 116 | end 117 | 118 | it 'encrypts plain text' do 119 | encrypted_text = crypto.encrypt_text(sender_keypair.private_key, recipient_keypair.public_key, 'Twitter Files') 120 | decrypted_text = crypto.decrypt_text(recipient_keypair.private_key, sender_keypair.public_key, encrypted_text) 121 | 122 | expect(decrypted_text).to eq('Twitter Files') 123 | end 124 | end 125 | 126 | describe '#descrypt_text' do 127 | let(:sender_keypair) do 128 | Nostr::KeyPair.new( 129 | public_key: Nostr::PublicKey.new('8a9d69c56e3c691bec8f9565e4dcbe38ae1d88fffeec3ce66b9f47558a3aa8ca'), 130 | private_key: Nostr::PrivateKey.new('3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757') 131 | ) 132 | end 133 | 134 | let(:recipient_keypair) do 135 | Nostr::KeyPair.new( 136 | public_key: Nostr::PublicKey.new('6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0'), 137 | private_key: Nostr::PrivateKey.new('22cea01c33eccf30fdd54cb6728f814f6de00c778aafd721e017f4582545f9cf') 138 | ) 139 | end 140 | 141 | context 'when the encrypted text includes an iv query string' do 142 | it 'decrypts an encrypted text' do 143 | encrypted_text = crypto.encrypt_text(sender_keypair.private_key, recipient_keypair.public_key, 'Twitter Files') 144 | decrypted_text = crypto.decrypt_text(recipient_keypair.private_key, sender_keypair.public_key, encrypted_text) 145 | 146 | expect(decrypted_text).to eq('Twitter Files') 147 | end 148 | end 149 | 150 | context 'when the encrypted text does not include an iv query string' do 151 | it 'returns an empty string' do 152 | encrypted_text = 'wrYQaHDfpOEvyJELSCg1vzsywmlJTz8NqH03eFW44s8iQs869jtSb26Lr4s23gmY?it=v38vAJ3LlJAGZxbmWU4qAg==' 153 | decrypted_text = crypto.decrypt_text(recipient_keypair.private_key, sender_keypair.public_key, encrypted_text) 154 | 155 | expect(decrypted_text).to eq('') 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /docs/common-use-cases/bech32-encoding-and-decoding-(NIP-19).md: -------------------------------------------------------------------------------- 1 | # Encoding/decoding bech-32 strings (NIP-19) 2 | 3 | [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md) standardizes bech32-formatted strings that can be 4 | used to display keys, ids and other information in clients. These formats are not meant to be used anywhere in the core 5 | protocol, they are only meant for displaying to users, copy-pasting, sharing, rendering QR codes and inputting data. 6 | 7 | 8 | In order to guarantee the deterministic nature of the documentation, the examples below assume that there is a `keypair` 9 | variable with the following values: 10 | 11 | ```ruby 12 | keypair = Nostr::KeyPair.new( 13 | private_key: Nostr::PrivateKey.new('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa'), 14 | public_key: Nostr::PublicKey.new('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 15 | ) 16 | 17 | keypair.private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 18 | keypair.public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 19 | ``` 20 | 21 | ## Public key (npub) 22 | 23 | ### Encoding 24 | 25 | ```ruby 26 | npub = Nostr::Bech32.npub_encode('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e') 27 | npub # => 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg' 28 | ``` 29 | 30 | ### Decoding 31 | 32 | ```ruby 33 | type, public_key = Nostr::Bech32.decode('npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg') 34 | type # => 'npub' 35 | public_key # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 36 | ``` 37 | 38 | ## Private key (nsec) 39 | 40 | ### Encoding 41 | 42 | ```ruby 43 | nsec = Nostr::Bech32.nsec_encode('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa') 44 | nsec # => 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5' 45 | ``` 46 | 47 | ### Decoding 48 | 49 | ```ruby 50 | type, private_key = Nostr::Bech32.decode('nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5') 51 | type # => 'npub' 52 | private_key # => '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa' 53 | ``` 54 | 55 | ## Relay (nrelay) 56 | 57 | ### Encoding 58 | 59 | ```ruby 60 | nrelay = Nostr::Bech32.nrelay_encode('wss://relay.damus.io') 61 | nrelay # => 'nrelay1qq28wumn8ghj7un9d3shjtnyv9kh2uewd9hsc5zt2x' 62 | ``` 63 | 64 | ### Decoding 65 | 66 | ```ruby 67 | type, data = Nostr::Bech32.decode('nrelay1qq28wumn8ghj7un9d3shjtnyv9kh2uewd9hsc5zt2x') 68 | 69 | type # => 'nrelay' 70 | data.entries.first.label # => 'relay' 71 | data.entries.first.value # => 'wss://relay.damus.io' 72 | ``` 73 | 74 | ## Event (nevent) 75 | 76 | ### Encoding 77 | 78 | ```ruby{8-12} 79 | user = Nostr::User.new(keypair: keypair) 80 | text_note_event = user.create_event( 81 | kind: Nostr::EventKind::TEXT_NOTE, 82 | created_at: 1700467997, 83 | content: 'Your feedback is appreciated, now pay $8' 84 | ) 85 | 86 | nevent = Nostr::Bech32.nevent_encode( 87 | id: text_note_event.id, 88 | relays: ['wss://relay.damus.io', 'wss://nos.lol'], 89 | kind: Nostr::EventKind::TEXT_NOTE, 90 | ) 91 | 92 | nevent # => 'nevent1qgsqlkuslr3rf56qpmd0m5ndfyl39m7q6l0zcmuly8ue0praxwkjagcpz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dspsgqqqqqqs03k8v3' 93 | ``` 94 | 95 | ### Decoding 96 | 97 | ```ruby 98 | type, event = Nostr::Bech32.decode('nevent1qgsqlkuslr3rf56qpmd0m5ndfyl39m7q6l0zcmuly8ue0praxwkjagcpz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dspsgqqqqqqs03k8v3') 99 | 100 | type # => 'nevent' 101 | event.entries[0].label # => 'author' 102 | event.entries[0].value # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 103 | event.entries[1].relay # => 'relay' 104 | event.entries[1].value # => 'wss://relay.damus.io' 105 | event.entries[2].label # => 'relay' 106 | event.entries[2].value # => 'wss://nos.lol' 107 | event.entries[3].label # => 'kind' 108 | event.entries[3].value # => 1 109 | ``` 110 | 111 | ## Address (naddr) 112 | 113 | ### Encoding 114 | 115 | ```ruby 116 | naddr = Nostr::Bech32.naddr_encode( 117 | pubkey: keypair.public_key, 118 | relays: ['wss://relay.damus.io', 'wss://nos.lol'], 119 | kind: Nostr::EventKind::TEXT_NOTE, 120 | identifier: 'damus', 121 | ) 122 | 123 | naddr # => 'naddr1qgs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nspz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dspsgqqqqqqsqptyv9kh2uc3qfs2p' 124 | ``` 125 | 126 | ### Decoding 127 | 128 | ```ruby 129 | type, addr = Nostr::Bech32.decode('naddr1qgs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nspz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dspsgqqqqqqsqptyv9kh2uc3qfs2p') 130 | 131 | type # => 'naddr' 132 | addr.entries[0].label # => 'author' 133 | addr.entries[0].value # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 134 | addr.entries[1].label # => 'relay' 135 | addr.entries[1].value # => 'wss://relay.damus.io' 136 | addr.entries[2].label # => 'relay' 137 | addr.entries[2].value # => 'wss://nos.lol' 138 | addr.entries[3].label # => 'kind' 139 | addr.entries[3].value # => 1 140 | addr.entries[4].label # => 'identifier' 141 | addr.entries[4].value # => 'damus' 142 | ``` 143 | 144 | ## Profile (nprofile) 145 | 146 | ### Encoding 147 | ```ruby 148 | relay_urls = %w[wss://relay.damus.io wss://nos.lol] 149 | nprofile = Nostr::Bech32.nprofile_encode(pubkey: keypair.public_key, relays: relay_urls) 150 | 151 | nprofile # => nprofile1qqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nspz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dsxe58m5 152 | ``` 153 | 154 | ### Decoding 155 | 156 | ```ruby 157 | type, profile = Nostr::Bech32.decode('nprofile1qqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nspz3mhxue69uhhyetvv9ujuerpd46hxtnfduqs6amnwvaz7tmwdaejumr0dsxe58m5') 158 | 159 | type # => 'nprofile' 160 | profile.entries[0].label # => 'pubkey' 161 | profile.entries[0].value # => '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e' 162 | profile.entries[1].label # => 'relay' 163 | profile.entries[1].value # => 'wss://relay.damus.io' 164 | profile.entries[2].label # => 'relay' 165 | profile.entries[2].value # => 'wss://nos.lol' 166 | ``` 167 | 168 | ## Other simple types (note) 169 | 170 | ### Encoding 171 | 172 | ```ruby{8-9} 173 | user = Nostr::User.new(keypair: keypair) 174 | text_note_event = user.create_event( 175 | kind: Nostr::EventKind::TEXT_NOTE, 176 | created_at: 1700467997, 177 | content: 'Your feedback is appreciated, now pay $8' 178 | ) 179 | 180 | note = Nostr::Bech32.encode(hrp: 'note', data: text_note_event.id) 181 | note # => 'note10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qnx3ujq' 182 | ``` 183 | 184 | ### Decoding 185 | 186 | ```ruby 187 | type, note = Nostr::Bech32.decode('note1pldep78zxnf5qrk6lhfx6jflzthup47793he7g0ej7z86vad963s42v0rr') 188 | type # => 'note' 189 | note # => '0fdb90f8e234d3400edafdd26d493f12efc0d7de2c6f9f21f997847d33ad2ea3' 190 | ``` 191 | --------------------------------------------------------------------------------