Compare commits

...

8 Commits

Author SHA1 Message Date
c0f4e7925e
Use zap comment for description/memo
Some checks failed
continuous-integration/drone/push Build is failing
But use the hashed zap request event for the description hash.
2024-05-04 17:07:23 +02:00
49d24990b4
Add zap model, user relation 2024-05-04 17:05:34 +02:00
619bd954b7
WIP 2024-04-21 10:51:41 +02:00
e27c64b5f1
WIP Check for zaps, send zap receipt on incoming zap tx 2024-04-21 10:35:30 +02:00
b36baf26eb
Refactor WebhooksController 2024-04-21 10:02:17 +02:00
adedaa5f7b
Add task for easily creating test invoices 2024-04-21 10:01:54 +02:00
596ed7fccc
Use lndhub.go v2 endpoint for invoice creation 2024-04-21 10:01:18 +02:00
5685e1b7bc
Move lndhub invoice creation to service 2024-04-16 20:19:15 +02:00
16 changed files with 325 additions and 74 deletions

View File

@ -71,11 +71,7 @@ class LnurlpayController < ApplicationController
end
def metadata(address)
"[[\"text/identifier\", \"#{address}\"], [\"text/plain\", \"Send sats, receive thanks.\"]]"
end
def zap_metadata(event)
"[[\"application/json\", #{event.to_json}]]"
"[[\"text/identifier\",\"#{address}\"],[\"text/plain\",\"Sats for #{address}\"]]"
end
def valid_amount?(amount_in_sats)
@ -92,14 +88,16 @@ class LnurlpayController < ApplicationController
return
end
memo = "To #{address}"
memo = "#{memo}: \"#{comment}\"" if comment.present?
desc = "To #{address}"
desc = "#{desc}: \"#{comment}\"" if comment.present?
payment_request = @user.ln_create_invoice({
amount: amount, # sats
memo: memo,
description_hash: Digest::SHA2.hexdigest(metadata(address)),
})
invoice = LndhubManager::CreateUserInvoice.call(
user: @user, payload: {
amount: amount, # sats
description: desc,
description_hash: Digest::SHA256.hexdigest(metadata(address)),
}
)
render json: {
status: "OK",
@ -108,7 +106,7 @@ class LnurlpayController < ApplicationController
message: "Sats received. Thank you!"
},
routes: [],
pr: payment_request
pr: invoice["payment_request"]
}
end
@ -133,16 +131,17 @@ class LnurlpayController < ApplicationController
return
end
# TODO
# raise zap_metadata(event).inspect
desc = "Zap for #{@user.address}"
desc = "#{desc}: \"#{event.content}\"" if event.content.present?
payment_request = @user.ln_create_invoice({
amount: amount, # sats
# TODO should be npub instead of address?
memo: "Zapped #{@user.address} on Nostr", # TODO include event ID if given
description_hash: Digest::SHA2.hexdigest(zap_metadata(event)),
})
invoice = LndhubManager::CreateUserInvoice.call(
user: @user, payload: {
amount: amount, # sats
description: desc,
description_hash: Digest::SHA256.hexdigest(event.to_json),
}
)
render json: { status: "OK", pr: payment_request }
render json: { status: "OK", pr: invoice["payment_request"] }
end
end

View File

@ -2,45 +2,67 @@ class WebhooksController < ApplicationController
skip_forgery_protection
before_action :authorize_request
before_action :process_payload
def lndhub
begin
payload = JSON.parse(request.body.read, symbolize_names: true)
head :no_content and return unless payload[:type] == "incoming"
rescue
head :unprocessable_entity and return
@user = User.find_by!(ln_account: @payload[:user_login])
if @zap_request = fetch_nostr_event_from_description
NostrManager::PublishZapReceipt.call(
user: @user, zap_request: @zap_request
)
end
user = User.find_by!(ln_account: payload[:user_login])
notify = user.preferences[:lightning_notify_sats_received]
case notify
when "xmpp"
notify_xmpp(user.address, payload[:amount], payload[:memo])
when "email"
NotificationMailer.with(user: user, amount_sats: payload[:amount])
.lightning_sats_received.deliver_later
end
send_notifications
head :ok
end
private
# TODO refactor into mailer-like generic class/service
def notify_xmpp(address, amt_sats, memo)
payload = {
type: "normal",
from: Setting.xmpp_notifications_from_address,
to: address,
subject: "Sats received!",
body: "#{helpers.number_with_delimiter amt_sats} sats received in your Lightning wallet:\n> #{memo}"
}
XmppSendMessageJob.perform_later(payload)
end
def authorize_request
if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip)
head :forbidden and return
end
end
def process_payload
@payload = JSON.parse(request.body.read, symbolize_names: true)
head :no_content and return unless @payload[:type] == "incoming"
rescue
head :unprocessable_entity and return
end
def fetch_nostr_event_from_description
memo_json = JSON.parse(@payload[:memo])
Nostr::Event.new(**memo_json.to_h.symbolize_keys)
rescue
nil
end
def send_notifications
notify = @user.preferences[:lightning_notify_sats_received]
case notify
when "xmpp"
notify_xmpp
when "email"
notify_email
end
end
# TODO refactor into mailer-like generic class/service
def notify_xmpp
XmppSendMessageJob.perform_later({
type: "normal",
from: Setting.xmpp_notifications_from_address,
to: @user.address,
subject: "Sats received!",
body: "#{helpers.number_with_delimiter @payload[:amount]} sats received in your Lightning wallet:\n> #{@payload[:memo]}"
})
end
def notify_email
NotificationMailer.with(user: @user, amount_sats: @payload[:amount])
.lightning_sats_received.deliver_later
end
end

View File

@ -17,16 +17,15 @@ class User < ApplicationRecord
has_one :invitation, inverse_of: :invitee, foreign_key: 'invited_user_id'
has_one :inviter, through: :invitation, source: :user
has_many :invitees, through: :invitations
has_many :donations, dependent: :nullify
has_many :remote_storage_authorizations
has_many :zaps
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
primary_key: "ln_account", foreign_key: "login"
has_many :accounts, through: :lndhub_user
has_many :remote_storage_authorizations
#
# Validations
#
@ -143,12 +142,6 @@ class User < ApplicationRecord
enable_service Setting.default_services
end
def ln_create_invoice(payload)
lndhub = Lndhub.new
lndhub.authenticate self
lndhub.addinvoice payload
end
def dn
return @dn if defined?(@dn)
@dn = Devise::LDAP::Adapter.get_dn(self.cn)

3
app/models/zap.rb Normal file
View File

@ -0,0 +1,3 @@
class Zap < ApplicationRecord
belongs_to :user
end

View File

@ -0,0 +1,13 @@
module LndhubManager
class CreateUserInvoice < LndhubV2
def initialize(user:, payload:)
@user = user
@payload = payload
end
def call
authenticate @user
create_invoice @payload
end
end
end

View File

@ -0,0 +1,48 @@
module NostrManager
class PublishEvent < NostrManagerService
def initialize(event: nil, relay: nil)
# @relay = relay
@relay = Nostr::Relay.new(url: 'ws://nostr-relay:7777', name: 'strfry')
keypair = Nostr::KeyPair.new(
private_key: Nostr::PrivateKey.new(Setting.nostr_private_key),
public_key: Nostr::PublicKey.new(Setting.nostr_public_key)
)
@user = Nostr::User.new(keypair: keypair)
@event = @user.create_event(
kind: Nostr::EventKind::TEXT_NOTE,
content: "The time is #{Time.now.strftime('%H:%M:%S')}"
)
@client = Nostr::Client.new
end
def call
client, relay, event = @client, @relay, @event
client.on :connect do
puts "Connected to #{relay.url}"
puts "Publishing #{event.id}..."
client.publish event
end
client.on :error do |e|
puts "Error: #{e}"
puts "Closing thread..."
Thread.exit
end
client.on :message do |m|
puts "Message: #{m}"
msg = JSON.parse(m) rescue []
if msg[0] == "OK" && msg[1] == event.id
puts "Event published. Closing thread..."
else
puts "Unexpected message from relay. Closing thread..."
end
Thread.exit
end
puts "Connecting to #{relay.url}..."
client.connect relay
end
end
end

View File

@ -0,0 +1,10 @@
module NostrManager
class PublishZapReceipt < NostrManagerService
def initialize(user:, zap_request:)
@user, @zap_request = user, zap_request
end
def call
end
end
end

View File

@ -3,3 +3,4 @@
- default
- mailers
- remotestorage
- nostr

View File

@ -0,0 +1,13 @@
class CreateZaps < ActiveRecord::Migration[7.1]
def change
create_table :zaps do |t|
t.references :user, null: false, foreign_key: true
t.json :request
t.json :receipt
t.text :payment_request
t.bigint :amount
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_03_16_153558) do
ActiveRecord::Schema[7.1].define(version: 2024_04_22_171653) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@ -136,8 +136,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_16_153558) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
create_table "zaps", force: :cascade do |t|
t.integer "user_id", null: false
t.json "request"
t.json "receipt"
t.text "payment_request"
t.bigint "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_zaps_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "remote_storage_authorizations", "app_catalog_web_apps", column: "web_app_id"
add_foreign_key "remote_storage_authorizations", "users"
add_foreign_key "zaps", "users"
end

55
lib/nostr/event_kind.rb Normal file
View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Nostr
# Defines the event kinds that can be emitted by clients.
module EventKind
# The content is set to a stringified JSON object +{name: <username>, about: <string>,
# picture: <url, string>}+ describing the user who created the event. A relay may delete past set_metadata
# events once it gets a new one for the same pubkey.
#
# @return [Integer]
#
SET_METADATA = 0
# The content is set to the text content of a note (anything the user wants to say).
# Non-plaintext notes should instead use kind 1000-10000 as described in NIP-16.
#
# @return [Integer]
#
TEXT_NOTE = 1
# The content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to
# recommend to its followers.
#
# @return [Integer]
#
RECOMMEND_SERVER = 2
# A special event with kind 3, meaning "contact list" is defined as having a list of p tags, one for each of
# the followed/known profiles one is following.
#
# @return [Integer]
#
CONTACT_LIST = 3
# A special event with kind 4, meaning "encrypted direct message". An event of this kind has its +content+
# equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a
# shared cipher generated by combining the recipient's public-key with the sender's private-key.
#
# @return [Integer]
#
ENCRYPTED_DIRECT_MESSAGE = 4
# NIP-57 Zap request
#
# @return [Integer]
#
ZAP_REQUEST = 9734
# NIP-57 Zap receipt
#
# @return [Integer]
#
ZAP_RECEIPT = 9735
end
end

View File

@ -1,3 +1,6 @@
require "digest"
require "pp"
namespace :lndhub do
desc "Generate wallets for all users"
task :generate_wallets => :environment do |t, args|
@ -22,6 +25,21 @@ namespace :lndhub do
puts "--\nSum of user balances: #{sum} sats"
end
desc "Create test invoice"
task :create_test_invoice, [:username, :amount, :description] => :environment do |t, args|
user = User.find_by cn: args[:username]
desc = args[:description]
hash = Digest::SHA256.hexdigest(desc)
invoice = LndhubManager::CreateUserInvoice.call(user: user, payload: {
"amount": args[:amount].to_i,
"description": desc,
"description_hash": hash
})
pp invoice
end
desc "Migrate existing accounts to lndhub.go"
task :migrate => :environment do |t, args|
# user = User.find_by cn: "jimmy"

7
spec/factories/zaps.rb Normal file
View File

@ -0,0 +1,7 @@
FactoryBot.define do
factory :zap do
user { nil }
request { "" }
receipt { "" }
end
end

19
spec/fixtures/lndhub/incoming-zap.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"id": 58,
"type": "incoming",
"user_login": "123456abcdef",
"amount": 21000,
"fee": 0,
"memo": "{\"id\":\"3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff\",\"sig\":\"e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2\",\"pubkey\":\"730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c\",\"created_at\":1712487443,\"kind\":9734,\"tags\":[[\"relays\",\"wss://nostr.kosmos.org\",\"wss://relay.example.com\"],[\"p\",\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\"]],\"content\":\"\"}",
"description_hash": "b1a9910724bc9c1b03b4eba2e2d78ac69a8ac7b244e6ff6d4e7391bf6893f26a",
"payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu",
"destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946",
"r_hash": "03aa6aeb2ed5a569f1a40925041f7d7163458e96b705e5eafe0f10188575dc08",
"preimage": "3539663535656537343331663432653165396430623966633664656664646563",
"keysend": false,
"state": "settled",
"created_at": "2023-01-11T09:22:57.546364Z",
"expires_at": "2023-01-12T09:22:57.547209Z",
"updated_at": "2023-01-11T09:22:58.046236131Z",
"settled_at": "2023-01-11T09:22:58.046232174Z"
}

View File

@ -48,13 +48,22 @@ RSpec.describe "/lnurlpay", type: :request do
end
describe "GET /lnurlpay/:username/invoice" do
let(:invoice) {
{
"payment_hash" => "35778ccdb8319b5104e41d2043d18446bf91bcd51450b5f1cf1b6082a7cc6203",
"payment_request" => "lnbc210n1pnzzr6rpp5x4mcendcxxd4zp8yr5sy85vyg6ler0x4z3gttuw0rdsg9f7vvgpshp52y6nf64apaqta2kjuwp2apglewqa9fva2mada6x2mmdj20t57jdscqzzsxqyz5vqsp5a3h88efdc436wunupz293gdtvm5843yfcfc8hxm2rpdunaetl39q9qyyssq07ec02dqr4epa73ssy0lzwglw49aa9rfywlp0c7jpnf448uapsgqch79d4222xqlh8674lzddvcyptpnwqqq8vpppf8djrn8yjf53dqpzwx5kh",
"expires_at" => "2024-04-19T12:17:07.725314947Z"
}
}
before do
allow_any_instance_of(User).to receive(:ln_create_invoice).and_return("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an")
allow(LndhubManager::CreateUserInvoice).to receive(:call)
.and_return(invoice)
end
it "returns a formatted lnurlpay response" do
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 50000, comment: "Coffee time!"
amount: 21000, comment: "Coffee time!"
})
expect(response).to have_http_status(:ok)
@ -63,7 +72,7 @@ RSpec.describe "/lnurlpay", type: :request do
expect(res["status"]).to eq('OK')
expect(res["successAction"]["tag"]).to eq('message')
expect(res["successAction"]["message"]).to match('Thank you')
expect(res["pr"]).to eq("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an")
expect(res["pr"]).to eq("lnbc210n1pnzzr6rpp5x4mcendcxxd4zp8yr5sy85vyg6ler0x4z3gttuw0rdsg9f7vvgpshp52y6nf64apaqta2kjuwp2apglewqa9fva2mada6x2mmdj20t57jdscqzzsxqyz5vqsp5a3h88efdc436wunupz293gdtvm5843yfcfc8hxm2rpdunaetl39q9qyyssq07ec02dqr4epa73ssy0lzwglw49aa9rfywlp0c7jpnf448uapsgqch79d4222xqlh8674lzddvcyptpnwqqq8vpppf8djrn8yjf53dqpzwx5kh")
end
describe "amount too low" do
@ -109,7 +118,7 @@ RSpec.describe "/lnurlpay", type: :request do
describe "with valid request event" do
let(:event) {
{
Nostr::Event.new(
id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff",
pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c",
created_at: 1712487443,
@ -119,27 +128,35 @@ RSpec.describe "/lnurlpay", type: :request do
["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]],
content: "",
sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2"
)
}
let(:invoice) {
{
"payment_hash" => "d6fa1d4b66587ab247d7b1f7608f2aed780a0cca021d09a30279c3dd9ff80f85",
"payment_request" => "lnbc210n1pnzzyvjpp56map6jmxtpaty37hk8mkpre2a4uq5rx2qgwsngcz08pam8lcp7zshp5xs7v3qlx0j0gyu9grrzx9xgews3t9vq64v30579le9z9wqr6fc5scqzzsxqyz5vqsp5kmltj5eayh47c6trwj8wdrz5nxymqp0eqwtk7k5nk6ytyz522nvs9qyyssqvkluufkp34gtzxdg0uyqcsdum2n34xz94tqr4jfwwx53czteutvj7eptz4lm5vcu0m8jqzxck484ycxzcqgqlqmpj2r3jxjlj4x6nygp8fvnag",
"expires_at" => "2024-04-19T12:26:58.432434748Z"
}
}
it "returns an invoice" do
expect_any_instance_of(User).to receive(:ln_create_invoice)
.with(
amount: 2100,
memo: "Zapped satoshi@kosmos.org on Nostr",
description_hash: "540279cd9da15279c8299d6d9ff1ab2a79eb259ee218adf3de393e1abe723077"
)
.and_return("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an")
expect(LndhubManager::CreateUserInvoice).to receive(:call)
.with(user: user, payload: {
amount: 21,
description: event.to_json,
description_hash: "b1a9910724bc9c1b03b4eba2e2d78ac69a8ac7b244e6ff6d4e7391bf6893f26a"
})
.and_return(invoice)
get lnurlpay_invoice_path(username: "satoshi", params: {
amount: 2100000, nostr: event.to_json
amount: 21000, nostr: event.to_json
})
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["status"]).to eq('OK')
expect(res["pr"]).to match(/^lnbc/)
expect(res["pr"]).to eq("lnbc210n1pnzzyvjpp56map6jmxtpaty37hk8mkpre2a4uq5rx2qgwsngcz08pam8lcp7zshp5xs7v3qlx0j0gyu9grrzx9xgews3t9vq64v30579le9z9wqr6fc5scqzzsxqyz5vqsp5kmltj5eayh47c6trwj8wdrz5nxymqp0eqwtk7k5nk6ytyz522nvs9qyyssqvkluufkp34gtzxdg0uyqcsdum2n34xz94tqr4jfwwx53czteutvj7eptz4lm5vcu0m8jqzxck484ycxzcqgqlqmpj2r3jxjlj4x6nygp8fvnag")
end
end
end

View File

@ -53,9 +53,7 @@ RSpec.describe "Webhooks", type: :request do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/incoming.json", File.dirname(__FILE__)))) }
before do
user.save! #FIXME this should not be necessary
end
before { user.save! } #FIXME this should not be necessary
it "returns a 200 status" do
post "/webhooks/lndhub", params: payload.to_json
@ -63,9 +61,15 @@ RSpec.describe "Webhooks", type: :request do
end
it "does not send notifications by default" do
post "/webhooks/lndhub", params: payload.to_json
expect(enqueued_jobs.size).to eq(0)
end
it "does not send a zap receipt" do
expect(NostrManager::PublishZapReceipt).not_to receive(:call)
post "/webhooks/lndhub", params: payload.to_json
end
context "notification preference set to 'xmpp'" do
before do
Setting.xmpp_notifications_from_address = "botka@kosmos.org"
@ -103,5 +107,22 @@ RSpec.describe "Webhooks", type: :request do
end
end
end
describe "Valid payload for zap transaction" do
let(:user) { create :user, ln_account: "123456abcdef" }
let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/incoming-zap.json", File.dirname(__FILE__)))) }
before { user.save! } #FIXME this should not be necessary
it "returns a 200 status" do
post "/webhooks/lndhub", params: payload.to_json
expect(response).to have_http_status(:ok)
end
it "sends a zap receipt" do
expect(NostrManager::PublishZapReceipt).to receive(:call)
post "/webhooks/lndhub", params: payload.to_json
end
end
end
end