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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/app/jobs/solrengine/sdp/provision_wallet_job.rb +125 -0
- data/app/jobs/solrengine/sdp/track_transfer_job.rb +161 -0
- data/app/models/solrengine/sdp/transfer.rb +229 -0
- data/lib/generators/solrengine/sdp/install_generator.rb +225 -0
- data/lib/generators/solrengine/sdp/templates/add_solrengine_sdp_to_users.rb +13 -0
- data/lib/generators/solrengine/sdp/templates/cable.yml +23 -0
- data/lib/generators/solrengine/sdp/templates/create_solrengine_sdp_transfers.rb +27 -0
- data/lib/generators/solrengine/sdp/templates/initializer.rb +44 -0
- data/lib/generators/solrengine/sdp/templates/sdp_watcher +134 -0
- data/lib/solrengine/sdp/broadcaster.rb +163 -0
- data/lib/solrengine/sdp/configuration.rb +107 -0
- data/lib/solrengine/sdp/engine.rb +21 -0
- data/lib/solrengine/sdp/errors.rb +20 -0
- data/lib/solrengine/sdp/faucet.rb +115 -0
- data/lib/solrengine/sdp/version.rb +11 -0
- data/lib/solrengine/sdp/wallet_owner.rb +114 -0
- data/lib/solrengine/sdp.rb +128 -0
- data/lib/solrengine-sdp.rb +3 -0
- metadata +109 -0
|
@@ -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
|