solrengine-sdp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SDP realtime wallet watcher — run as its own process alongside your web
5
+ # server (Procfile.dev: `watcher: bin/sdp_watcher`).
6
+ #
7
+ # Holds one accountSubscribe WebSocket per wallet-ready user. On any account
8
+ # change, Solrengine::Sdp::Broadcaster re-fetches the displayed data from SDP
9
+ # and pushes the app's configured Turbo Stream updates — the doorbell
10
+ # pattern: the WebSocket carries no data, it only signals THAT something
11
+ # changed.
12
+ #
13
+ # SOL-ONLY in v0.1: the system-account subscription sees lamport changes on
14
+ # the wallet address itself. SPL token deposits land in Associated Token
15
+ # Accounts this subscription does NOT see — token balances are still correct
16
+ # on page load, they just don't ring the doorbell. ATA subscriptions are
17
+ # planned follow-up work.
18
+ #
19
+ # Degradation contract: if this process isn't running, screens are correct on
20
+ # load — they just don't update live.
21
+
22
+ require_relative "../config/environment"
23
+
24
+ SCAN_INTERVAL = 30 # seconds between wallet-ready re-scans (picks up new wallets)
25
+
26
+ rpc = Solrengine::Rpc.configuration
27
+ puts "[SdpWatcher] Network: #{rpc.network}"
28
+ puts "[SdpWatcher] WS: #{rpc.ws_url.sub(/api-key=.*/, "api-key=***")}"
29
+
30
+ # Register the engine's broadcaster on the realtime subscriber registry.
31
+ Solrengine::Sdp.start_realtime!
32
+ puts "[SdpWatcher] Broadcaster subscribed as #{Solrengine::Sdp::REALTIME_SUBSCRIBER_NAME.inspect}"
33
+
34
+ # --- Boot-time broadcast self-check ------------------------------------------
35
+ # A broadcast that raises here means the cable backend is broken (e.g. the
36
+ # Solid Cable database is missing — run bin/rails db:prepare). Better to die
37
+ # loudly now than to broadcast into the void all session.
38
+ if defined?(Turbo)
39
+ Turbo::StreamsChannel.broadcast_replace_to(
40
+ "solrengine_sdp_ping", target: "solrengine_sdp_ping", html: ""
41
+ )
42
+ puts "[SdpWatcher] Cable self-check broadcast sent"
43
+
44
+ # The async adapter accepts broadcasts but delivers them IN-PROCESS ONLY:
45
+ # everything this watcher pushes would be silently dropped — no error, no
46
+ # log, the browser just never updates. Warn as loudly as we can.
47
+ cable = begin
48
+ ActionCable.server.config.cable
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ adapter = cable && (cable["adapter"] || cable[:adapter])
53
+ if adapter.to_s == "async"
54
+ warn <<~MSG
55
+ [SdpWatcher] ################################################################
56
+ [SdpWatcher] # Action Cable is using the `async` adapter — IN-PROCESS ONLY #
57
+ [SdpWatcher] ################################################################
58
+ [SdpWatcher] Cross-process broadcasts from this watcher will be SILENTLY
59
+ [SdpWatcher] DROPPED: no error, no log, the browser simply never updates.
60
+ [SdpWatcher] Switch development to solid_cable (or redis) in config/cable.yml
61
+ [SdpWatcher] and RESTART your web server — a running Puma holds the old
62
+ [SdpWatcher] adapter in memory. See the solrengine-sdp README.
63
+ MSG
64
+ else
65
+ puts "[SdpWatcher] Cable adapter: #{adapter || "unknown"} (cross-process delivery OK)"
66
+ end
67
+ else
68
+ warn "[SdpWatcher] turbo-rails is not loaded — broadcasts are no-ops. Add `gem \"turbo-rails\"`."
69
+ end
70
+
71
+ monitors = {}
72
+ running = true
73
+
74
+ %w[INT TERM].each do |signal|
75
+ trap(signal) { running = false }
76
+ end
77
+
78
+ user_model = Solrengine::Sdp.configuration.user_model
79
+
80
+ while running
81
+ begin
82
+ addresses = user_model.wallet_ready.pluck(:wallet_address)
83
+
84
+ addresses.each do |address|
85
+ next if monitors[address]
86
+
87
+ puts "[SdpWatcher] Subscribing to #{address}"
88
+ monitor = Solrengine::Realtime::AccountMonitor.new(address)
89
+ monitor.start
90
+ monitors[address] = monitor
91
+
92
+ # Re-fetch-and-broadcast on every NEW subscription: the wallet may have
93
+ # changed before we started watching (post-provisioning funding lag, or
94
+ # this watcher restarting). On its own thread — the broadcaster sleeps
95
+ # between retries and must not stall the scan loop.
96
+ Thread.new do
97
+ Solrengine::Sdp::Broadcaster.call(address)
98
+ rescue => e
99
+ puts "[SdpWatcher] Catch-up broadcast for #{address.inspect} failed: #{e.class}: #{e.message}"
100
+ end
101
+ rescue ArgumentError => e
102
+ # One malformed address must not poison the whole scan pass — log it
103
+ # and keep subscribing the rest (AccountMonitor validates base58).
104
+ puts "[SdpWatcher] Skipping #{address.inspect}: #{e.message}"
105
+ end
106
+
107
+ (monitors.keys - addresses).each do |address|
108
+ puts "[SdpWatcher] Unsubscribing from #{address}"
109
+ monitors.delete(address).stop
110
+ end
111
+ rescue => e
112
+ puts "[SdpWatcher] Scan error: #{e.class}: #{e.message}"
113
+ end
114
+
115
+ # Transfer-tracking recovery: re-enqueue TrackTransferJob for unsettled
116
+ # rows nothing is polling anymore (a crash between the row's create! and
117
+ # the tracking enqueue, or queue data loss). Safe to run every pass — the
118
+ # model's updated_at guard skips rows under active tracking.
119
+ begin
120
+ resumed = Solrengine::Sdp::Transfer.resume_tracking!
121
+ puts "[SdpWatcher] Resumed tracking for #{resumed} unsettled transfer(s)" if resumed > 0
122
+ rescue => e
123
+ puts "[SdpWatcher] Transfer recovery error: #{e.class}: #{e.message}"
124
+ end
125
+
126
+ SCAN_INTERVAL.times do
127
+ break unless running
128
+ sleep 1
129
+ end
130
+ end
131
+
132
+ puts "[SdpWatcher] Shutting down…"
133
+ monitors.each_value(&:stop)
134
+ Solrengine::Sdp.stop_realtime!
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Sdp
5
+ # Turns a chain-change signal ("this wallet's account changed") into the
6
+ # app's configured broadcasts — or into silence. The WebSocket
7
+ # notification is only a doorbell: it carries no data. The broadcaster
8
+ # re-fetches everything displayed from the authoritative source (SDP) and
9
+ # broadcasts only when every fetch succeeded, so screens never regress
10
+ # from good content to an "unavailable" state mid-session; the last good
11
+ # content simply stays.
12
+ #
13
+ # The app supplies broadcast targets — via Solrengine::Sdp.configure or
14
+ # the targets: argument — as an ordered array of hashes:
15
+ #
16
+ # config.broadcast_targets = [
17
+ # { name: :activity,
18
+ # fetch: ->(user) { ActivityFeed.entries_for(user) },
19
+ # render: ->(user, entries) {
20
+ # Turbo::StreamsChannel.broadcast_update_to(
21
+ # user.realtime_stream, target: "activity",
22
+ # partial: "activity/feed", locals: { entries: entries })
23
+ # } },
24
+ # { name: :balance, fetch: ..., render: ... }
25
+ # ]
26
+ #
27
+ # The ENGINE owns the doorbell invariants; the lambdas own the app
28
+ # specifics:
29
+ #
30
+ # * All-or-nothing: every target's fetch runs first, in configured
31
+ # order. Any fetch raising — or returning :unavailable — means NO
32
+ # renders at all this attempt (nil is valid data; signal "I could not
33
+ # fetch" with :unavailable or an exception).
34
+ # * Consumed doorbells retry: a WebSocket notification never re-fires,
35
+ # so a transient SDP hiccup would otherwise permanently miss the
36
+ # update. The WHOLE cycle (fetch + render) retries up to
37
+ # configuration.broadcast_retries attempts, sleeping
38
+ # broadcast_retry_delay seconds between attempts (zero it in tests).
39
+ # Safe to sleep: solrengine-realtime invokes subscribers on a
40
+ # dedicated per-wallet broadcast thread.
41
+ # * Priority order: renders run in the configured array order — put
42
+ # fast, money-bearing regions first, decorative ones last.
43
+ # * Request-context-free: lambdas run in a watcher process, outside any
44
+ # HTTP request. Current.user, session, and request-thread locals are
45
+ # nil; partials need explicit locals.
46
+ #
47
+ # USD enrichment inside fetch lambdas: Solrengine::Sdp.usd_value_for(balance)
48
+ # — SDP's usd_value when present, Jupiter-derived when solrengine-tokens
49
+ # is available, nil otherwise. Price failures never fail a fetch.
50
+ #
51
+ # Turbo deliberately never appears in this class: render lambdas do the
52
+ # actual broadcasting, so the engine core carries no turbo-rails
53
+ # dependency and the invariants are testable without it.
54
+ class Broadcaster
55
+ TARGET_KEYS = %i[name fetch render].freeze
56
+
57
+ def self.call(wallet_address, targets: nil)
58
+ new(wallet_address, targets: targets).call
59
+ end
60
+
61
+ def initialize(wallet_address, targets: nil)
62
+ @wallet_address = wallet_address
63
+ @targets = validate_targets(targets || configuration.broadcast_targets)
64
+ end
65
+
66
+ # Resolves the wallet owner and runs the broadcast cycle. Unknown or
67
+ # not-yet-ready wallets (the fee payer, external counterparties) are a
68
+ # silent no-op — not ours to broadcast. Returns true when a cycle
69
+ # completed, false when retries were exhausted, nil on no-op.
70
+ def call
71
+ if @targets.empty?
72
+ logger&.info(
73
+ "[Solrengine::Sdp::Broadcaster] No broadcast_targets configured — nothing to broadcast. " \
74
+ "Set config.broadcast_targets in a Solrengine::Sdp.configure block to enable realtime updates."
75
+ )
76
+ return
77
+ end
78
+
79
+ # This executes on a long-lived per-wallet broadcast thread
80
+ # (solrengine-realtime), NOT inside an HTTP request. Rails only
81
+ # auto-releases AR connections at request boundaries, so a bare
82
+ # find_by here permanently holds a connection on the thread. With
83
+ # a default pool of 5 that means the sixth wallet's broadcast
84
+ # raises ConnectionTimeoutError — rescued by the realtime registry
85
+ # and silently dropped. with_connection returns the lease to the
86
+ # pool as soon as the block exits, before the fetch/render/sleep
87
+ # cycle starts. App-provided fetch lambdas may also touch the DB;
88
+ # that is the app's concern and is intentionally out of this scope.
89
+ user = ActiveRecord::Base.connection_pool.with_connection do
90
+ configuration.user_model.wallet_ready.find_by(wallet_address: @wallet_address)
91
+ end
92
+ return unless user
93
+
94
+ attempts = configuration.broadcast_retries
95
+ attempts.times do |attempt|
96
+ return true if attempt_broadcast(user)
97
+
98
+ sleep configuration.broadcast_retry_delay unless attempt == attempts - 1
99
+ end
100
+
101
+ logger&.warn(
102
+ "[Solrengine::Sdp::Broadcaster] Giving up on #{@wallet_address}: " \
103
+ "SDP unavailable — keeping last good content"
104
+ )
105
+ false
106
+ end
107
+
108
+ private
109
+
110
+ # One fetch-and-broadcast attempt. True when every fetch succeeded and
111
+ # every render ran; false on any failure so the caller can retry.
112
+ # Never renders partial data. A render raising also fails the attempt —
113
+ # the whole cycle re-runs, which is safe because renders re-broadcast
114
+ # the same regions with fresh data.
115
+ def attempt_broadcast(user)
116
+ data = fetch_all(user)
117
+ return false unless data
118
+
119
+ @targets.each { |target| target[:render].call(user, data[target[:name]]) }
120
+ true
121
+ rescue StandardError => e
122
+ logger&.warn(
123
+ "[Solrengine::Sdp::Broadcaster] Attempt for #{@wallet_address} failed: #{e.class}: #{e.message}"
124
+ )
125
+ false
126
+ end
127
+
128
+ # Runs every fetch in configured order. Returns the data keyed by
129
+ # target name, or nil as soon as any fetch returns :unavailable.
130
+ def fetch_all(user)
131
+ @targets.each_with_object({}) do |target, data|
132
+ value = target[:fetch].call(user)
133
+ return nil if value == :unavailable
134
+
135
+ data[target[:name]] = value
136
+ end
137
+ end
138
+
139
+ # Misconfigured targets are a programming error, not a transient
140
+ # failure — fail loudly at construction, outside the retry loop.
141
+ def validate_targets(targets)
142
+ targets = Array(targets)
143
+ targets.each do |target|
144
+ unless target.respond_to?(:[]) && target[:name] &&
145
+ target[:fetch].respond_to?(:call) && target[:render].respond_to?(:call)
146
+ raise ConfigurationError,
147
+ "Each broadcast target needs #{TARGET_KEYS.map(&:inspect).join(', ')} " \
148
+ "with callable fetch/render, got: #{target.inspect}"
149
+ end
150
+ end
151
+ targets
152
+ end
153
+
154
+ def configuration
155
+ Solrengine::Sdp.configuration
156
+ end
157
+
158
+ def logger
159
+ configuration.logger
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Sdp
5
+ # Engine configuration: explicit attributes with ENV fallbacks
6
+ # (rpc/auth family pattern — a real class, not bare mattr_accessor).
7
+ #
8
+ # Solrengine::Sdp.configure do |config|
9
+ # config.api_key = Rails.application.credentials.dig(:sdp, :api_key)
10
+ # config.user_class = "Account"
11
+ # end
12
+ class Configuration
13
+ DEFAULT_BASE_URL = "http://127.0.0.1:8787"
14
+ DEFAULT_EXPIRED_TRANSFER_DEADLINE = 15 * 60 # seconds
15
+ DEFAULT_TRANSFER_POLL_INTERVAL = 3 # seconds — confirmation is usually seconds away
16
+ DEFAULT_PROVISIONING_LEASE = 10 * 60 # seconds — see provisioning_lease below
17
+
18
+ attr_writer :api_key, :base_url, :custody_provider, :label_namespace, :logger
19
+ # provisioning_lease (seconds): how long a wallet-owner row may sit in
20
+ # "provisioning" untouched before the claim is considered abandoned
21
+ # (worker died between claim and settle) and another job may take it
22
+ # over. Any live job renews the row's updated_at well within this
23
+ # window, so a takeover can only hit a genuinely dead claim.
24
+ attr_accessor :user_class, :expired_transfer_deadline, :transfer_poll_interval,
25
+ :provisioning_lease,
26
+ :broadcast_retry_delay, :broadcast_retries, :broadcast_targets
27
+
28
+ def initialize
29
+ @user_class = "User"
30
+ @expired_transfer_deadline = DEFAULT_EXPIRED_TRANSFER_DEADLINE
31
+ @transfer_poll_interval = DEFAULT_TRANSFER_POLL_INTERVAL
32
+ @provisioning_lease = DEFAULT_PROVISIONING_LEASE
33
+ @broadcast_retry_delay = 2
34
+ @broadcast_retries = 3
35
+ # Ordered array of {name:, fetch:, render:} hashes — see Broadcaster.
36
+ # Empty by default: the Broadcaster logs a hint and broadcasts
37
+ # nothing until the app configures its targets.
38
+ @broadcast_targets = []
39
+ end
40
+
41
+ def api_key
42
+ @api_key || ENV["SDP_API_KEY"]
43
+ end
44
+
45
+ def base_url
46
+ @base_url || ENV.fetch("SDP_API_BASE_URL", DEFAULT_BASE_URL)
47
+ end
48
+
49
+ def custody_provider
50
+ @custody_provider || ENV["SDP_CUSTODY_PROVIDER"]
51
+ end
52
+
53
+ # Prefix for SDP wallet labels ("#{label_namespace}-user-#{id}").
54
+ # Defaults to the Rails application name, else "app".
55
+ def label_namespace
56
+ @label_namespace || default_label_namespace
57
+ end
58
+
59
+ # Lazily constantized so the engine can be configured before the app's
60
+ # user model is loadable (initializer-time safe).
61
+ def user_model
62
+ Object.const_get(user_class)
63
+ end
64
+
65
+ # Engine log sink: Rails.logger in a Rails host, $stdout otherwise.
66
+ # Tests assign a StringIO-backed logger to assert on log lines.
67
+ def logger
68
+ @logger ||= default_logger
69
+ end
70
+
71
+ # Boot check used by the engine's after_initialize hook, also callable
72
+ # directly. A missing key must fail loudly at boot, not at the first
73
+ # wallet call.
74
+ def validate!
75
+ return self unless api_key.to_s.strip.empty?
76
+
77
+ raise ConfigurationError,
78
+ "Solrengine::Sdp api_key is not set. The engine cannot talk to the SDP API without it. " \
79
+ "Set the SDP_API_KEY environment variable (start the local SDP stack and export the " \
80
+ "seeded key) or assign config.api_key in a Solrengine::Sdp.configure block. " \
81
+ "Optionally set SDP_API_BASE_URL (default: #{DEFAULT_BASE_URL})."
82
+ end
83
+
84
+ private
85
+
86
+ def default_logger
87
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
88
+ Rails.logger
89
+ else
90
+ require "logger"
91
+ ::Logger.new($stdout)
92
+ end
93
+ end
94
+
95
+ def default_label_namespace
96
+ name = rails_application_name
97
+ name.nil? || name.empty? ? "app" : name
98
+ end
99
+
100
+ def rails_application_name
101
+ return nil unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
102
+
103
+ Rails.application.class.name&.split("::")&.first&.downcase
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Sdp
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Solrengine::Sdp
7
+
8
+ # A missing API key must fail loudly at boot, not at the first wallet
9
+ # call. Exemptions:
10
+ # - test env: suites stub HTTP and configure explicitly
11
+ # - infrastructure rake tasks (assets:, db:, app:, ...): CI and
12
+ # Docker image builds run these without production secrets
13
+ config.after_initialize do
14
+ next if Rails.env.test?
15
+ next if Solrengine::Sdp.exempt_rake_context?
16
+
17
+ Solrengine::Sdp.configuration.validate!
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Sdp
5
+ # Engine-level errors. Transport/API errors raised while talking to SDP
6
+ # live in the solana-sdp client gem (Sdp::Error and subclasses); these
7
+ # are the errors the ENGINE itself raises.
8
+ class Error < StandardError; end
9
+
10
+ # Raised at boot/configure time when the engine cannot operate
11
+ # (see Configuration#validate!).
12
+ class ConfigurationError < Error; end
13
+
14
+ # Raised by Transfer.execute! when the balance preflight shows the source
15
+ # wallet cannot cover amount + fee buffer. Raised BEFORE any row is
16
+ # created and before any POST is made — there is nothing to reconcile,
17
+ # the app just renders the message and lets the user adjust the amount.
18
+ class InsufficientBalance < Error; end
19
+ end
20
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Solrengine
8
+ module Sdp
9
+ # DEVNET-ONLY faucet client: POSTs JSON-RPC requestAirdrop straight to a
10
+ # Solana devnet RPC for a wallet's public key. Mainnet has no faucet —
11
+ # pointing this at a mainnet RPC just yields Unavailable errors. Picking
12
+ # a devnet/localnet RPC URL is the caller's responsibility (the default
13
+ # is the public devnet endpoint).
14
+ #
15
+ # One attempt, NEVER retried — an airdrop that timed out may still land,
16
+ # and retrying just burns the per-address faucet allowance. Callers decide
17
+ # what to do on failure (e.g. fall back to a treasury transfer).
18
+ class Faucet
19
+ DEFAULT_RPC_URL = "https://api.devnet.solana.com"
20
+ OPEN_TIMEOUT = 2 # seconds — fail fast, funding flows are user-facing
21
+ READ_TIMEOUT = 5 # seconds
22
+
23
+ # The faucet reports rate limiting either as HTTP 429 or as a JSON-RPC
24
+ # error whose message mentions the airdrop/rate limit.
25
+ RATE_LIMIT_PATTERN = /rate.?limit|too many requests|airdrop limit|limit reached|429/i
26
+
27
+ class Error < Solrengine::Sdp::Error; end
28
+
29
+ # HTTP 429, or a JSON-RPC error that reads like a rate limit. The caller
30
+ # should cool down before asking again.
31
+ class RateLimited < Error; end
32
+
33
+ # Connection failure, non-2xx status, or an unusable/erroneous RPC
34
+ # response — the airdrop definitely did not happen. The faucet may work
35
+ # again shortly; use a fallback now.
36
+ class Unavailable < Error; end
37
+
38
+ # The request was sent but no response arrived in time. The airdrop MAY
39
+ # still land — the outcome is unknown, so callers must NOT treat this as
40
+ # failure and must not double-fund through a fallback.
41
+ class TimedOut < Error; end
42
+
43
+ attr_reader :rpc_url
44
+
45
+ def initialize(rpc_url: ENV.fetch("SOLANA_RPC_URL", DEFAULT_RPC_URL),
46
+ open_timeout: OPEN_TIMEOUT,
47
+ read_timeout: READ_TIMEOUT)
48
+ @rpc_url = rpc_url
49
+ @open_timeout = open_timeout
50
+ @read_timeout = read_timeout
51
+ end
52
+
53
+ # Requests `lamports` for `address`. Returns the airdrop transaction
54
+ # signature on success; raises RateLimited, TimedOut, or Unavailable
55
+ # otherwise.
56
+ def request_airdrop(address, lamports)
57
+ uri = URI.parse(@rpc_url)
58
+ request = Net::HTTP::Post.new(uri)
59
+ request["Content-Type"] = "application/json"
60
+ request.body = JSON.generate(
61
+ jsonrpc: "2.0", id: 1, method: "requestAirdrop", params: [ address, lamports ]
62
+ )
63
+
64
+ response = Net::HTTP.start(
65
+ uri.host, uri.port,
66
+ use_ssl: uri.scheme == "https",
67
+ open_timeout: @open_timeout,
68
+ read_timeout: @read_timeout
69
+ ) { |http| http.request(request) }
70
+
71
+ handle(response)
72
+ rescue Net::OpenTimeout => e
73
+ # Connection never opened — the airdrop request was definitely not
74
+ # sent. Unavailable (not TimedOut) so a funding fallback may run
75
+ # without any double-funding risk.
76
+ raise Unavailable, "Faucet unreachable (connect timeout): #{e.message}"
77
+ rescue Net::ReadTimeout => e
78
+ raise TimedOut, "Faucet timed out: #{e.message}"
79
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError, EOFError => e
80
+ raise Unavailable, "Faucet unreachable: #{e.message}"
81
+ end
82
+
83
+ private
84
+
85
+ def handle(response)
86
+ status = response.code.to_i
87
+ raise RateLimited, "Faucet rate limited (HTTP 429)" if status == 429
88
+ raise Unavailable, "Faucet returned HTTP #{status}" unless (200..299).cover?(status)
89
+
90
+ body = parse_json(response.body)
91
+ raise Unavailable, "Faucet returned an unreadable response" unless body.is_a?(Hash)
92
+
93
+ if (error = body["error"])
94
+ message = error.is_a?(Hash) ? error["message"].to_s : error.to_s
95
+ raise RateLimited, "Faucet rate limited: #{message}" if message.match?(RATE_LIMIT_PATTERN)
96
+
97
+ raise Unavailable, "Faucet error: #{message.empty? ? 'unknown' : message}"
98
+ end
99
+
100
+ signature = body["result"]
101
+ raise Unavailable, "Faucet response carried no signature" if signature.to_s.empty?
102
+
103
+ signature
104
+ end
105
+
106
+ def parse_json(raw)
107
+ return nil if raw.nil? || raw.empty?
108
+
109
+ JSON.parse(raw)
110
+ rescue JSON::ParserError
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Sdp
5
+ VERSION = "0.1.0"
6
+
7
+ # The SDP release this engine version is tested against. SDP breaks its
8
+ # API between minors; bump this (and re-verify) on every SDP upgrade.
9
+ COMPATIBLE_SDP_VERSION = "0.28"
10
+ end
11
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Solrengine
6
+ module Sdp
7
+ # Mixin for the host app's user (wallet-owner) model. Expects four columns
8
+ # (the install generator adds them):
9
+ #
10
+ # sdp_wallet_id :string — SDP's walletId once provisioned
11
+ # wallet_address :string — the wallet's Solana public key
12
+ # sdp_provisioning_state :string, default: "pending", null: false
13
+ # sdp_provisioning_error :string — renderable failure reason
14
+ #
15
+ # Provisioning is a state machine, not a fire-and-forget job:
16
+ #
17
+ # pending → provisioning → ready | failed
18
+ # failed → pending (retry_provisioning!)
19
+ # provisioning (stale) → pending (retry_provisioning! — a
20
+ # row whose updated_at is past Configuration#provisioning_lease was
21
+ # abandoned by a dead worker, never settled by a live one)
22
+ #
23
+ # ProvisionWalletJob drives every transition; "still provisioning" and
24
+ # "permanently wallet-less" are always distinguishable, and a failure
25
+ # carries a reason the app can render and re-trigger.
26
+ #
27
+ # Provisioning timing is the app's call — the concern deliberately wires
28
+ # NO callback. To provision on signup, add one line to the host model:
29
+ #
30
+ # after_create_commit :provision_wallet!
31
+ #
32
+ # States are plain strings rather than a Rails enum so the mixin cannot
33
+ # collide with the host model's own enums or generated methods.
34
+ module WalletOwner
35
+ extend ActiveSupport::Concern
36
+
37
+ PROVISIONING_STATES = %w[pending provisioning ready failed].freeze
38
+
39
+ included do
40
+ # Users whose custody wallet is fully provisioned — the only ones that
41
+ # can move money or appear in wallet-keyed UI (recipients, feeds).
42
+ scope :wallet_ready, -> { where(sdp_provisioning_state: "ready") }
43
+ end
44
+
45
+ # Tolerates NULL (rows predating the column default): no state is pending.
46
+ def wallet_provisioning_state
47
+ self[:sdp_provisioning_state].presence || "pending"
48
+ end
49
+
50
+ def wallet_pending?
51
+ wallet_provisioning_state == "pending"
52
+ end
53
+
54
+ def wallet_provisioning?
55
+ wallet_provisioning_state == "provisioning"
56
+ end
57
+
58
+ def wallet_ready?
59
+ wallet_provisioning_state == "ready"
60
+ end
61
+
62
+ def wallet_failed?
63
+ wallet_provisioning_state == "failed"
64
+ end
65
+
66
+ # SDP wallet label this user provisions under. The namespace prefix
67
+ # guards against cross-app collisions when several apps share one SDP
68
+ # project (see Configuration#label_namespace).
69
+ def sdp_wallet_label
70
+ "#{Solrengine::Sdp.configuration.label_namespace}-user-#{id}"
71
+ end
72
+
73
+ # A provisioning row whose lease has lapsed: no live job has touched it
74
+ # within Configuration#provisioning_lease (every claim/retry/settle
75
+ # renews updated_at), so the worker that claimed it died before
76
+ # settling. Stale rows are re-enqueueable; fresh ones belong to a live
77
+ # job and must be left alone.
78
+ def wallet_provisioning_stale?
79
+ wallet_provisioning? &&
80
+ updated_at <= Time.current - Solrengine::Sdp.configuration.provisioning_lease
81
+ end
82
+
83
+ # Enqueues provisioning. No-op (with a log line) when the wallet is
84
+ # already ready or a live job currently owns the row; from failed it
85
+ # re-enqueues — the job's claim accepts failed rows, so an explicit
86
+ # reset via retry_provisioning! is equivalent but also clears the error.
87
+ # A STALE provisioning row (lease lapsed — the claiming worker died
88
+ # before settling) also re-enqueues: the job's claim takes over expired
89
+ # leases, and label adoption makes the re-run double-provision-safe.
90
+ def provision_wallet!
91
+ if wallet_ready? || (wallet_provisioning? && !wallet_provisioning_stale?)
92
+ Solrengine::Sdp.configuration.logger&.info(
93
+ "[Solrengine::Sdp] provision_wallet! no-op for #{self.class.name}##{id}: " \
94
+ "state is #{wallet_provisioning_state}"
95
+ )
96
+ return false
97
+ end
98
+
99
+ ProvisionWalletJob.perform_later(self)
100
+ end
101
+
102
+ # Re-arms a failed row — or a STALE provisioning row abandoned by a
103
+ # dead worker: clears the stored reason, resets to pending, and
104
+ # enqueues a fresh job. Anything else (including provisioning rows a
105
+ # live job still owns) is a no-op returning false.
106
+ def retry_provisioning!
107
+ return false unless wallet_failed? || wallet_provisioning_stale?
108
+
109
+ update!(sdp_provisioning_state: "pending", sdp_provisioning_error: nil)
110
+ ProvisionWalletJob.perform_later(self)
111
+ end
112
+ end
113
+ end
114
+ end