diff --git a/lib/strike_me/api.rb b/lib/strike_me/api.rb index 7fa8c3a..89b6664 100644 --- a/lib/strike_me/api.rb +++ b/lib/strike_me/api.rb @@ -31,5 +31,7 @@ module StrikeMe autoload_default :Account autoload_default :Invoices autoload_default :InvoiceResponse + autoload_default :Webhook + autoload_default :WebhookEvent end end diff --git a/lib/strike_me/api/client.rb b/lib/strike_me/api/client.rb index 132e7cb..ea91a00 100644 --- a/lib/strike_me/api/client.rb +++ b/lib/strike_me/api/client.rb @@ -26,6 +26,14 @@ module StrikeMe def get(...) @last_response = connection.get(...) end + + def post(...) + @last_response = connection.post(...) + end + + def patch(...) + @last_response = connection.patch(...) + end end end end diff --git a/lib/strike_me/api/invoices.rb b/lib/strike_me/api/invoices.rb index 1a9aee8..7fbaff0 100644 --- a/lib/strike_me/api/invoices.rb +++ b/lib/strike_me/api/invoices.rb @@ -8,6 +8,7 @@ module StrikeMe include Enumerable autoload_default :Invoice + autoload_default :Quote delegate :first, :last, :size, :any?, to: :list @@ -25,6 +26,51 @@ module StrikeMe @list = response.body['items'].map { |item| Invoice.new item } end + + def paid + select(&:paid?) + end + + def find(id) + url = "invoices/#{id}" + response = client.get url + + raise ResponseError.new(url, response.body) unless response.success? + + Invoice.new response.body + end + + # POST invoices — amount is a decimal string per Strike's own API + # (not a float/BTC value), matching what #list/#find already hand + # back untouched in Invoice#amount. + def create(amount:, currency:, description: nil, correlation_id: nil) + url = 'invoices' + body = { + amount: { amount: amount, currency: currency }, + description: description, + correlationId: correlation_id + }.compact + response = client.post(url, body) + + raise ResponseError.new(url, response.body) unless response.success? + + Invoice.new response.body + end + + # POST invoices/{id}/quote — the second step of actually getting a + # payable BOLT 11 (Invoice#create alone only reserves the invoice + # shell; nothing is payable until a quote exists). Quotes expire + # (Quote#expiration) — callers request a fresh one per payment + # attempt rather than caching. + def quote(id, description_hash: nil) + url = "invoices/#{id}/quote" + body = { descriptionHash: description_hash }.compact + response = client.post(url, body) + + raise ResponseError.new(url, response.body) unless response.success? + + Quote.new response.body + end end end end diff --git a/lib/strike_me/api/invoices/invoice.rb b/lib/strike_me/api/invoices/invoice.rb index 6118284..cf43104 100644 --- a/lib/strike_me/api/invoices/invoice.rb +++ b/lib/strike_me/api/invoices/invoice.rb @@ -13,6 +13,7 @@ module StrikeMe attribute :receiverId attribute :payerId + alias_attribute :id, :invoiceId attribute_names.select { _1.end_with? 'Id' }.each do |snake_name| alias_attribute snake_name.underscore, snake_name end @@ -20,6 +21,18 @@ module StrikeMe def paid? state == 'PAID' end + + def unpaid? + state == 'UNPAID' + end + + def pending? + state == 'PENDING' + end + + def cancelled? + state == 'CANCELLED' + end end end end diff --git a/lib/strike_me/api/invoices/quote.rb b/lib/strike_me/api/invoices/quote.rb new file mode 100644 index 0000000..ec6c72c --- /dev/null +++ b/lib/strike_me/api/invoices/quote.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module StrikeMe + module API + class Invoices + # Response of POST invoices/{id}/quote — the actual payable BOLT 11 + # only exists once a quote is issued; Invoice#create alone reserves + # the invoice shell but has nothing a wallet can pay yet. + class Quote < BaseModel + attribute :quoteId + attribute :description + attribute :lnInvoice + attribute :onchainAddress + attribute :expiration, :datetime + attribute :expirationInSec, :integer + attribute :targetAmount + attribute :sourceAmount + attribute :conversionRate + + alias_attribute :id, :quoteId + alias_attribute :bolt11, :lnInvoice + attribute_names.select { _1.end_with? 'Id' }.each do |snake_name| + alias_attribute snake_name.underscore, snake_name + end + + def expired? + expiration.present? && expiration.past? + end + end + end + end +end diff --git a/lib/strike_me/api/webhook.rb b/lib/strike_me/api/webhook.rb new file mode 100644 index 0000000..3a3743e --- /dev/null +++ b/lib/strike_me/api/webhook.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'openssl' +require 'active_support/security_utils' + +module StrikeMe + module API + # Verifies the X-Webhook-Signature header Strike attaches to every + # webhook POST — HMAC-SHA256 of the raw request body, keyed by the + # secret set at webhook-subscription creation time. See + # https://docs.strike.me/webhooks/signature-verification — this + # mirrors Strike's own documented reference implementation exactly + # (hex digest, timing-safe compare), just in Ruby. + # + # `raw_body` must be the exact bytes Strike sent, before any + # JSON-parsing round-trip — re-serializing a parsed body can reorder + # keys or change whitespace and silently break verification. + module Webhook + SIGNATURE_HEADER = 'X-Webhook-Signature' + + def self.valid_signature?(raw_body:, signature:, secret:) + return false if signature.blank? || raw_body.blank? + + expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) + secure_compare(expected, signature) + end + + # ActiveSupport::SecurityUtils.secure_compare raises on a length + # mismatch rather than returning false — a garbled/truncated header + # from a malformed request should fail closed, not raise. + def self.secure_compare(expected, actual) + return false unless expected.bytesize == actual.bytesize + + ActiveSupport::SecurityUtils.secure_compare(expected, actual) + end + end + end +end diff --git a/lib/strike_me/api/webhook_event.rb b/lib/strike_me/api/webhook_event.rb new file mode 100644 index 0000000..ec69664 --- /dev/null +++ b/lib/strike_me/api/webhook_event.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module StrikeMe + module API + # Parses the WebhookEvent payload every Strike webhook POST carries — + # {id, eventType, webhookVersion, data: {entityId, changes}, created, + # deliverySuccess}. Deliberately does NOT carry the changed values + # themselves — Strike's own docs are explicit that the event never + # contains the actual data changes, only that something changed on + # `entity_id`. A real value only comes from calling the matching + # fetch endpoint (e.g. Invoices#find for an invoice.* event) — + # trusting anything else here would mean trusting an unauthenticated + # claim about money having moved. + class WebhookEvent < BaseModel + attribute :id + attribute :eventType + attribute :webhookVersion + attribute :data + attribute :created, :datetime + attribute :deliverySuccess, :boolean + + alias_attribute :event_type, :eventType + alias_attribute :webhook_version, :webhookVersion + alias_attribute :delivery_success, :deliverySuccess + + def entity_id + data['entityId'] || data[:entityId] + end + + def changes + data['changes'] || data[:changes] || [] + end + + def invoice_event? + event_type.to_s.start_with?('invoice.') + end + end + end +end diff --git a/spec/strike_me/api/invoices_spec.rb b/spec/strike_me/api/invoices_spec.rb index d095aaa..f6913ba 100644 --- a/spec/strike_me/api/invoices_spec.rb +++ b/spec/strike_me/api/invoices_spec.rb @@ -22,6 +22,7 @@ RSpec.describe StrikeMe::API::Invoices do 'receiverId' => '41952e07-483c-4cd7-afa4-b6a8ec729809', 'payerId' => 'f75c22bf-625f-4c47-acdf-acb9c596bb8c' ) + expect(list.first.id ).to eq '97dca64c-6dde-4a87-b970-279f1a8e0ac5' expect(list.first.created).to be_a Time expect(list.last.attributes).to eq( @@ -37,4 +38,62 @@ RSpec.describe StrikeMe::API::Invoices do end end end + + describe '#find' do + before do + stub_request(:get, 'https://api.strike.me/v1/invoices/97dca64c-6dde-4a87-b970-279f1a8e0ac5') + .to_return(status: 200, body: '{"invoiceId":"97dca64c-6dde-4a87-b970-279f1a8e0ac5","amount":{"amount":"0.00014","currency":"BTC"},"state":"PAID","created":"2025-08-08T22:28:44.195825+00:00","description":"Hi there","issuerId":"41952e07-483c-4cd7-afa4-b6a8ec729809","receiverId":"41952e07-483c-4cd7-afa4-b6a8ec729809","payerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"}', headers: {'Content-Type' => 'application/json'}) + end + + it 'finds and returns the proper object' do + invoice = subject.find('97dca64c-6dde-4a87-b970-279f1a8e0ac5') + + expect(invoice).to be_a StrikeMe::API::Invoices::Invoice + expect(invoice.id).to eq '97dca64c-6dde-4a87-b970-279f1a8e0ac5' + expect(invoice).to be_paid + end + end + + describe '#create' do + before do + stub_request(:post, 'https://api.strike.me/v1/invoices') + .with(body: {amount: {amount: '10.00', currency: 'USD'}, description: 'Table 3'}) + .to_return(status: 201, body: '{"invoiceId":"602cc206-6b4a-42dd-bc89-9a534bb04034","amount":{"amount":"10.00","currency":"USD"},"state":"UNPAID","created":"2026-09-15T12:00:00+00:00","description":"Table 3","issuerId":"41952e07-483c-4cd7-afa4-b6a8ec729809","receiverId":"41952e07-483c-4cd7-afa4-b6a8ec729809"}', headers: {'Content-Type' => 'application/json'}) + end + + it 'issues a new invoice, unpaid, with no bolt11 yet — that only exists once quoted' do + invoice = subject.create(amount: '10.00', currency: 'USD', description: 'Table 3') + + expect(invoice).to be_a StrikeMe::API::Invoices::Invoice + expect(invoice.id).to eq '602cc206-6b4a-42dd-bc89-9a534bb04034' + expect(invoice).to be_unpaid + end + + it 'omits correlationId/description entirely when not given, rather than sending them null' do + stub = stub_request(:post, 'https://api.strike.me/v1/invoices') + .with(body: {amount: {amount: '10.00', currency: 'USD'}}) + .to_return(status: 201, body: '{"invoiceId":"x","amount":{"amount":"10.00","currency":"USD"},"state":"UNPAID","created":"2026-09-15T12:00:00+00:00","issuerId":"a","receiverId":"a"}', headers: {'Content-Type' => 'application/json'}) + + subject.create(amount: '10.00', currency: 'USD') + expect(stub).to have_been_requested + end + end + + describe '#quote' do + before do + stub_request(:post, 'https://api.strike.me/v1/invoices/602cc206-6b4a-42dd-bc89-9a534bb04034/quote') + .with(body: {}) + .to_return(status: 201, body: '{"quoteId":"a1b2c3","lnInvoice":"lnbc100n1p...","expiration":"2099-01-01T00:00:00+00:00","expirationInSec":300,"targetAmount":{"amount":"10.00","currency":"USD"},"sourceAmount":{"amount":"0.00014","currency":"BTC"},"conversionRate":{"amount":"71428.57","sourceCurrency":"BTC","targetCurrency":"USD"}}', headers: {'Content-Type' => 'application/json'}) + end + + it 'returns a Quote carrying the actual payable bolt11' do + quote = subject.quote('602cc206-6b4a-42dd-bc89-9a534bb04034') + + expect(quote).to be_a StrikeMe::API::Invoices::Quote + expect(quote.bolt11).to eq 'lnbc100n1p...' + expect(quote.id).to eq 'a1b2c3' + expect(quote.expiration).to be_a Time + expect(quote).not_to be_expired + end + end end diff --git a/spec/strike_me/api/webhook_event_spec.rb b/spec/strike_me/api/webhook_event_spec.rb new file mode 100644 index 0000000..e4e99b5 --- /dev/null +++ b/spec/strike_me/api/webhook_event_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe StrikeMe::API::WebhookEvent do + subject(:event) do + described_class.new( + 'id' => '245e40d8-f197-411c-8f20-a326d08da402', + 'eventType' => 'invoice.updated', + 'webhookVersion' => 'v1', + 'data' => { 'entityId' => '602cc206-6b4a-42dd-bc89-9a534bb04034', 'changes' => ['state'] }, + 'created' => '2026-09-15T12:00:00+00:00', + 'deliverySuccess' => true + ) + end + + it 'exposes entity_id and changes from the nested data object' do + expect(event.entity_id).to eq '602cc206-6b4a-42dd-bc89-9a534bb04034' + expect(event.changes).to eq ['state'] + end + + it 'aliases the camelCase attributes to snake_case' do + expect(event.event_type).to eq 'invoice.updated' + expect(event.webhook_version).to eq 'v1' + expect(event.delivery_success).to eq true + end + + it 'parses created as a real Time' do + expect(event.created).to be_a Time + end + + it 'identifies invoice events' do + expect(event).to be_invoice_event + end + + it 'does not carry the actual changed values — only that state changed, not to what' do + expect(event.attributes.keys).not_to include('amount', 'state') + end +end diff --git a/spec/strike_me/api/webhook_spec.rb b/spec/strike_me/api/webhook_spec.rb new file mode 100644 index 0000000..06c0cc3 --- /dev/null +++ b/spec/strike_me/api/webhook_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe StrikeMe::API::Webhook do + let(:secret) { 'a-webhook-subscription-secret' } + let(:raw_body) { '{"id":"245e40d8-f197-411c-8f20-a326d08da402","eventType":"invoice.updated","webhookVersion":"v1","data":{"entityId":"602cc206-6b4a-42dd-bc89-9a534bb04034","changes":["state"]},"created":"2026-09-15T12:00:00+00:00","deliverySuccess":true}' } + let(:real_signature) { OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) } + + describe '.valid_signature?' do + it 'is true for a signature computed with the same secret over the same raw body' do + expect(described_class.valid_signature?(raw_body: raw_body, signature: real_signature, secret: secret)).to eq true + end + + it 'is false for the wrong secret' do + expect(described_class.valid_signature?(raw_body: raw_body, signature: real_signature, secret: 'wrong')).to eq false + end + + it 'is false if the body was tampered with after signing' do + tampered = raw_body.sub('"state"', '"amount"') + expect(described_class.valid_signature?(raw_body: tampered, signature: real_signature, secret: secret)).to eq false + end + + it 'is false for a blank signature, rather than raising' do + expect(described_class.valid_signature?(raw_body: raw_body, signature: '', secret: secret)).to eq false + expect(described_class.valid_signature?(raw_body: raw_body, signature: nil, secret: secret)).to eq false + end + + it 'is false (not a raised length-mismatch error) for a garbled/truncated signature' do + expect(described_class.valid_signature?(raw_body: raw_body, signature: 'not-a-real-length', secret: secret)).to eq false + end + end +end diff --git a/strike_me-api.gemspec b/strike_me-api.gemspec index 3ed48d8..6ccd185 100644 --- a/strike_me-api.gemspec +++ b/strike_me-api.gemspec @@ -8,15 +8,14 @@ Gem::Specification.new do |spec| spec.authors = ['Benjamin ter Kuile'] spec.email = ['bterkuile@gmail.com'] - spec.summary = 'TODO: Write a short summary, because RubyGems requires one.' - spec.description = 'TODO: Write a longer description or delete this line.' - spec.homepage = 'TODO: Put your gem website or public repo URL here.' + spec.summary = 'Ruby client for the Strike (strike.me) API' + spec.description = 'Invoice creation/quoting, account/balance reads, and webhook signature verification for the Strike Lightning payments API.' + spec.homepage = 'https://gitlab.com/benja-2/strike_me-api' spec.license = 'MIT' spec.required_ruby_version = '>= 3.1.0' spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://gitlab.com/benja-2/strike_me-api' - spec.metadata['changelog_uri'] = 'TODO: Put your gem CHANGELOG.md URL here.' # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git.