From c3b82fc2a9e6e3002298993cdf87b098e4e3089f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 16 Apr 2024 19:13:10 +0200 Subject: [PATCH] WIP Verify and respond to zap requests --- app/controllers/lnurlpay_controller.rb | 96 +++++--- .../nostr_manager/verify_zap_request.rb | 51 +++++ spec/fixtures/nostr/zap_request_event.json | 22 ++ spec/requests/lnurlpay_spec.rb | 63 +++++- .../nostr_manager/verify_zap_request_spec.rb | 206 ++++++++++++++++++ 5 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 app/services/nostr_manager/verify_zap_request.rb create mode 100644 spec/fixtures/nostr/zap_request_event.json create mode 100644 spec/services/nostr_manager/verify_zap_request_spec.rb diff --git a/app/controllers/lnurlpay_controller.rb b/app/controllers/lnurlpay_controller.rb index bc595bc..1708903 100644 --- a/app/controllers/lnurlpay_controller.rb +++ b/app/controllers/lnurlpay_controller.rb @@ -43,7 +43,7 @@ class LnurlpayController < ApplicationController # GET /lnurlpay/:username/invoice def invoice - amount = params[:amount].to_i / 1000 # msats + amount = params[:amount].to_i / 1000 # msats to sats comment = params[:comment] || "" address = @user.address @@ -52,33 +52,19 @@ class LnurlpayController < ApplicationController return end - if !valid_comment?(comment) - render json: { status: "ERROR", reason: "Comment too long" } - return + if params[:nostr].present? + handle_zap_request amount, params[:nostr], params[:lnurl] + else + handle_pay_request address, amount, comment end - - memo = "To #{address}" - memo = "#{memo}: \"#{comment}\"" if comment.present? - - payment_request = @user.ln_create_invoice({ - amount: amount, # we create invoices in sats - memo: memo, - description_hash: Digest::SHA2.hexdigest(metadata(address)), - }) - - render json: { - status: "OK", - successAction: { - tag: "message", - message: "Sats received. Thank you!" - }, - routes: [], - pr: payment_request - } end private + def check_service_available + http_status :not_found unless Setting.lndhub_enabled? + end + def find_user @user = User.where(cn: params[:username], ou: Setting.primary_domain).first http_status :not_found if @user.nil? @@ -88,6 +74,10 @@ class LnurlpayController < ApplicationController "[[\"text/identifier\", \"#{address}\"], [\"text/plain\", \"Send sats, receive thanks.\"]]" end + def zap_metadata(event) + "[[\"application/json\", #{event.to_json}]]" + end + def valid_amount?(amount_in_sats) amount_in_sats <= MAX_SATS && amount_in_sats >= MIN_SATS end @@ -96,9 +86,63 @@ class LnurlpayController < ApplicationController comment.length <= MAX_COMMENT_CHARS end - private + def handle_pay_request(address, amount, comment) + if !valid_comment?(comment) + render json: { status: "ERROR", reason: "Comment too long" } + return + end - def check_service_available - http_status :not_found unless Setting.lndhub_enabled? + memo = "To #{address}" + memo = "#{memo}: \"#{comment}\"" if comment.present? + + payment_request = @user.ln_create_invoice({ + amount: amount, # sats + memo: memo, + description_hash: Digest::SHA2.hexdigest(metadata(address)), + }) + + render json: { + status: "OK", + successAction: { + tag: "message", + message: "Sats received. Thank you!" + }, + routes: [], + pr: payment_request + } + end + + def nostr_event_from_payload(nostr_param) + event_obj = JSON.parse(nostr_param).transform_keys(&:to_sym) + Nostr::Event.new(**event_obj) + rescue => e + return nil + end + + def valid_zap_request?(amount, event, lnurl) + NostrManager::VerifyZapRequest.call( + amount: amount, event: event, lnurl: lnurl + ) + end + + def handle_zap_request(amount, nostr_param, lnurl_param) + event = nostr_event_from_payload(nostr_param) + + unless event.present? && valid_zap_request?(amount*1000, event, lnurl_param) + render json: { status: "ERROR", reason: "Invalid zap request" } + return + end + + # TODO + # raise zap_metadata(event).inspect + + 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)), + }) + + render json: { status: "OK", pr: payment_request } end end diff --git a/app/services/nostr_manager/verify_zap_request.rb b/app/services/nostr_manager/verify_zap_request.rb new file mode 100644 index 0000000..ad05024 --- /dev/null +++ b/app/services/nostr_manager/verify_zap_request.rb @@ -0,0 +1,51 @@ +module NostrManager + class VerifyZapRequest < NostrManagerService + def initialize(amount:, event:, lnurl: nil) + @amount, @event, @lnurl = amount, event, lnurl + end + + # https://github.com/nostr-protocol/nips/blob/27fef638e2460139cc9078427a0aec0ce4470517/57.md#appendix-d-lnurl-server-zap-request-validation + def call + tags = parse_tags(@event.tags) + + @event.verify_signature && + @event.kind == 9734 && + tags.present? && + valid_p_tag?(tags[:p]) && + valid_e_tag?(tags[:e]) && + valid_a_tag?(tags[:a]) && + valid_amount_tag?(tags[:amount]) && + valid_lnurl_tag?(tags[:lnurl]) + end + + def valid_p_tag?(tag) + return false unless tag.present? && tag.length == 1 + key = Nostr::PublicKey.new(tag.first) rescue nil + key.present? + end + + def valid_e_tag?(tag) + return true unless tag.present? + # TODO validate format of event ID properly + tag.length == 1 && tag.first.is_a?(String) + end + + def valid_a_tag?(tag) + return true unless tag.present? + # TODO validate format of event coordinate properly + tag.length == 1 && tag.first.is_a?(String) + end + + def valid_amount_tag?(tag) + return true unless tag.present? + amount = tag.first + amount.is_a?(String) && amount.to_i == @amount + end + + def valid_lnurl_tag?(tag) + return true unless tag.present? + # TODO validate lnurl matching recipient's lnurlp + tag.first.is_a?(String) + end + end +end diff --git a/spec/fixtures/nostr/zap_request_event.json b/spec/fixtures/nostr/zap_request_event.json new file mode 100644 index 0000000..79854e6 --- /dev/null +++ b/spec/fixtures/nostr/zap_request_event.json @@ -0,0 +1,22 @@ +{ + "kind": 9734, + "created_at": 1712066899, + "content": "", + "tags": [ + ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], + ["amount", "21000"], + [ + "relays", + "wss://relay.primal.net", + "wss://relay.snort.social", + "wss://nostr.wine", + "wss://relay.damus.io", + "wss://eden.nostr.land", + "wss://atlas.nostr.land", + "wss://nostr.bitcoiner.social", + "wss://relay.mostr.pub", + "wss://relay.mostr.pub/", + "wss://nostr-01.bolt.observer" + ] + ] +} diff --git a/spec/requests/lnurlpay_spec.rb b/spec/requests/lnurlpay_spec.rb index b3f9a17..707b711 100644 --- a/spec/requests/lnurlpay_spec.rb +++ b/spec/requests/lnurlpay_spec.rb @@ -1,9 +1,8 @@ require 'rails_helper' RSpec.describe "/lnurlpay", type: :request do - context "Non-existent user" do - describe "GET /.well-known/lnurlpay/:username" do + describe "GET /.well-known/lnurlp/:username" do it "returns a 404" do get lightning_address_path(username: "csw") expect(response).to have_http_status(:not_found) @@ -32,7 +31,7 @@ RSpec.describe "/lnurlpay", type: :request do login_as user, :scope => :user end - describe "GET /.well-known/lnurlpay/:username" do + describe "GET /.well-known/lnurlp/:username" do it "returns a formatted Lightning Address response" do get lightning_address_path(username: "satoshi") @@ -67,29 +66,83 @@ RSpec.describe "/lnurlpay", type: :request do expect(res["pr"]).to eq("lnbc50u1p3lwgknpp52g78gqya5euvzjc53fc6hkmlm2rfjhcd305tcmc0g9gaestav48sdq4gdhkven9v5sx6mmwv4ujzcqzpgxqyz5vqsp5skkz4jlqr6tkvv2g9739ygrjupc4rkqd94mc7dfpj3pgx3f6w7qs9qyyssq7mf3fzcuxlmkr9nqatcch3u8uf4gjyawe052tejz8e9fqxu4pncqk3qklt8g6ylpshg09xyjquyrgtc72vcw5cp0dzcf406apyua7dgpnfn7an") end - context "amount too low" do + describe "amount too low" do it "returns an error" do get lnurlpay_invoice_path(username: "satoshi", params: { amount: 5000, comment: "Coffee time!" }) + expect(response).to have_http_status(:ok) + res = JSON.parse(response.body) expect(res["status"]).to eq('ERROR') expect(res["reason"]).to eq('Invalid amount') end end - context "comment too long" do + describe "comment too long" do it "returns an error" do get lnurlpay_invoice_path(username: "satoshi", params: { amount: 5000000, comment: "Coffee time is the best time, so here's some money for you to get some. May I suggest to sample some Pacamara beans from El Salvador?" }) + expect(response).to have_http_status(:ok) + res = JSON.parse(response.body) expect(res["status"]).to eq('ERROR') expect(res["reason"]).to eq('Comment too long') end end + + context "zap request" do + describe "with invalid request event" do + it "returns an error" do + get lnurlpay_invoice_path(username: "satoshi", params: { + amount: 2100000, nostr: { foo: "bar" }.to_json + }) + + res = JSON.parse(response.body) + expect(res["status"]).to eq('ERROR') + expect(res["reason"]).to eq('Invalid zap request') + end + end + + describe "with valid request event" do + let(:event) { + { + id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712487443, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]], + content: "", + sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2" + } + } + + 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") + + get lnurlpay_invoice_path(username: "satoshi", params: { + amount: 2100000, 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/) + end + end + end end describe "GET /.well-known/keysend/:username/" do diff --git a/spec/services/nostr_manager/verify_zap_request_spec.rb b/spec/services/nostr_manager/verify_zap_request_spec.rb new file mode 100644 index 0000000..1981132 --- /dev/null +++ b/spec/services/nostr_manager/verify_zap_request_spec.rb @@ -0,0 +1,206 @@ +require 'rails_helper' + +RSpec.describe NostrManager::VerifyZapRequest, type: :model do + describe "Signature invalid" do + let(:event) { + Nostr::Event.new( + id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712487443, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]], + content: "", + sig: "1234562bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns false" do + expect(subject).to be(false) + end + end + + describe "p tag missing" do + let(:event) { + Nostr::Event.new( + id: "eb53c5e728b625831e318c7ab09373502dd4a41ed11c17f80f32fd1c3fe1252b", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712580669, + kind: 9734, + tags: [["relays", "wss://nostr.kosmos.org","wss://relay.example.com"]], + content: "", + sig: "8e22dcb3e91d080c9549ea0808b018194cc352dde3056a4911796d6f0239de7e1955f287c7e6769909e59ab0ffa09e307178c32128dc451823fb00102ed7e80a" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns false" do + expect(subject).to be(false) + end + end + + describe "p tag not a valid pubkey" do + let(:event) { + Nostr::Event.new( + id: "f2c23f038cf4d0307147d40ddceb87e34ee94acf632f5a67217a55a0b41c5d95", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712581702, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org","wss://relay.example.com"], + ["p","123456abcdef"] + ], + content: "", + sig: "b33e6b231273d38629f5efb86325ce3b4e02a7d84dfa1f37167ca2de8392cea654ae34000fc365ddfc6e21dc8ce2365fcb000ccb9064fbbcd54c7ea4a943955b" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns false" do + expect(subject).to be(false) + end + end + + describe "Minimum valid request" do + let(:event) { + Nostr::Event.new( + id: "3cf02d7f0ccd9711c25098fc50b3a7ab880326e4e51cc8c7a7b59f147cff4fff", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712487443, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org", "wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"]], + content: "", + sig: "e9e9bb2bac4267a107ab5c3368f504b4f11b8e3b5ae875a1d63c74d6934138d2521dc35815b6f534fc5d803cbf633736d871886368bb8f92c4ad3837a68a06f2" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns true" do + expect(subject).to be(true) + end + end + + describe "Optional amount property" do + describe "does not match given amount" do + let(:event) { + Nostr::Event.new( + id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712579812, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org","wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"], + ["amount", "21000"] + ], + content: "", + sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns false" do + expect(subject).to be(false) + end + end + + describe "matches given amount" do + let(:event) { + Nostr::Event.new( + id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712579812, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org","wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"], + ["amount", "21000"] + ], + content: "", + sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 21000, event: event, lnurl: nil) + } + + it "returns true" do + expect(subject).to be(true) + end + end + end + + describe "Optional amount property" do + describe "does not match given amount" do + let(:event) { + Nostr::Event.new( + id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712579812, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org","wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"], + ["amount", "21000"] + ], + content: "", + sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 100000, event: event, lnurl: nil) + } + + it "returns false" do + expect(subject).to be(false) + end + end + + describe "matches given amount" do + let(:event) { + Nostr::Event.new( + id: "be3d3ba15a257546f6fbba849d4717641fd4ea9f21ae6e9278a045f31d212c5e", + pubkey: "730b43e6f62c2ab22710b046e481802c8ac1108ed2cb9c21dff808d57ba24b6c", + created_at: 1712579812, + kind: 9734, + tags: [ + ["relays", "wss://nostr.kosmos.org","wss://relay.example.com"], + ["p", "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"], + ["amount", "21000"] + ], + content: "", + sig: "2ca9cfd0fdaf43dd8a50ab586e83da4bd9d592def8ed198536f5e3e7aad3537818687e42d98eb61d60e33dbd848c1eecf72b68fd98376bbabdab7e029e810869" + ) + } + + subject { + NostrManager::VerifyZapRequest.call(amount: 21000, event: event, lnurl: nil) + } + + it "returns true" do + expect(subject).to be(true) + end + end + end +end