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,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Solrengine
|
|
7
|
+
module Sdp
|
|
8
|
+
# Engine-owned record of every transfer the app attempts through SDP —
|
|
9
|
+
# the row IS the audit trail and the thing the app renders (R6: every
|
|
10
|
+
# transfer settles into a renderable terminal state).
|
|
11
|
+
#
|
|
12
|
+
# Status mapping (the HTD table, one-to-one):
|
|
13
|
+
#
|
|
14
|
+
# SDP pending/processing → "processing" (non-terminal, polled)
|
|
15
|
+
# SDP confirmed → "confirmed" (user-facing success; tracking
|
|
16
|
+
# CONTINUES to finalized)
|
|
17
|
+
# SDP finalized → "finalized" (terminal)
|
|
18
|
+
# SDP failed → "failed" (terminal; SDP error captured)
|
|
19
|
+
# engine-local → "expired" (terminal; stuck in processing
|
|
20
|
+
# past expired_transfer_deadline)
|
|
21
|
+
# engine-local → "unknown" (POST read-timeout: outcome
|
|
22
|
+
# unknown; reconciled by memo
|
|
23
|
+
# token via list_transfers)
|
|
24
|
+
#
|
|
25
|
+
# The create POST is NEVER retried — SDP has no idempotency key, so a
|
|
26
|
+
# blind re-send risks a double-spend (demo TransfersController /
|
|
27
|
+
# RecurringExecution discipline). The row is created BEFORE the POST so
|
|
28
|
+
# even a crash mid-request leaves evidence to reconcile against.
|
|
29
|
+
class Transfer < ActiveRecord::Base
|
|
30
|
+
self.table_name = "solrengine_sdp_transfers"
|
|
31
|
+
|
|
32
|
+
# ~5000 lamports: one signature's network fee, held back from the
|
|
33
|
+
# spendable balance so a SOL send can pay for its own transaction.
|
|
34
|
+
FEE_BUFFER = BigDecimal("0.000005")
|
|
35
|
+
|
|
36
|
+
# App memo and engine token compose rather than replace each other:
|
|
37
|
+
# "rent | sdp-1a2b3c4d5e6f7a8b". The token half is what timeout
|
|
38
|
+
# reconciliation scans for; the app half survives round-trip to chain.
|
|
39
|
+
MEMO_TOKEN_PREFIX = "sdp-"
|
|
40
|
+
MEMO_SEPARATOR = " | "
|
|
41
|
+
|
|
42
|
+
STATUSES = %w[processing confirmed finalized failed expired unknown].freeze
|
|
43
|
+
# confirmed is NOT terminal: it is user-facing success, but tracking
|
|
44
|
+
# continues until SDP reports finalized.
|
|
45
|
+
TERMINAL_STATUSES = %w[finalized failed expired].freeze
|
|
46
|
+
|
|
47
|
+
SDP_STATUS_MAP = {
|
|
48
|
+
"pending" => "processing",
|
|
49
|
+
"processing" => "processing",
|
|
50
|
+
"confirmed" => "confirmed",
|
|
51
|
+
"finalized" => "finalized",
|
|
52
|
+
"failed" => "failed"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
validates :source_wallet_id, :destination, :token, :amount, :memo_token, presence: true
|
|
56
|
+
validates :status, inclusion: { in: STATUSES }
|
|
57
|
+
|
|
58
|
+
# Rows TrackTransferJob still owes a verdict on — includes confirmed,
|
|
59
|
+
# which is user-facing success but not yet finalized (tracking continues
|
|
60
|
+
# until SDP reports finalized).
|
|
61
|
+
scope :unsettled, -> { where.not(status: TERMINAL_STATUSES) }
|
|
62
|
+
scope :terminal, -> { where(status: TERMINAL_STATUSES) }
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Creates the row, runs the balance preflight, POSTs the transfer to
|
|
66
|
+
# SDP exactly once, and enqueues confirmation tracking. Returns the
|
|
67
|
+
# row in whatever state the POST left it (the app renders from it).
|
|
68
|
+
#
|
|
69
|
+
# Raises InsufficientBalance — before any row or POST — when the
|
|
70
|
+
# preflight shows the SOL balance can't cover amount + FEE_BUFFER.
|
|
71
|
+
def execute!(source:, destination:, amount:, token: "SOL", memo: nil)
|
|
72
|
+
# Normalize to a plain decimal string ("1.5", never "0.15e1"):
|
|
73
|
+
# stored and sent as a string so no float drift ever touches money.
|
|
74
|
+
amount = BigDecimal(amount.to_s).to_s("F")
|
|
75
|
+
|
|
76
|
+
# Preflight is SOL-only in v0.1 (SPL would need the mint row plus a
|
|
77
|
+
# separate SOL fee check); for SPL tokens the POST is the authority.
|
|
78
|
+
ensure_balance_covers!(source, amount) if token == "SOL"
|
|
79
|
+
|
|
80
|
+
transfer = create!(
|
|
81
|
+
source_wallet_id: source,
|
|
82
|
+
destination: destination,
|
|
83
|
+
token: token,
|
|
84
|
+
amount: amount,
|
|
85
|
+
memo: memo,
|
|
86
|
+
memo_token: "#{MEMO_TOKEN_PREFIX}#{SecureRandom.hex(8)}",
|
|
87
|
+
status: "processing",
|
|
88
|
+
submitted_at: Time.current
|
|
89
|
+
)
|
|
90
|
+
transfer.submit_to_sdp!
|
|
91
|
+
TrackTransferJob.perform_later(transfer) unless transfer.terminal?
|
|
92
|
+
transfer
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Recovery entry point: re-enqueues TrackTransferJob for unsettled
|
|
96
|
+
# rows nothing is tracking anymore. Closes the crash window between
|
|
97
|
+
# create!/submit_to_sdp! and the tracking enqueue, and adopts orphans
|
|
98
|
+
# after queue data loss. The updated_at guard (two poll intervals)
|
|
99
|
+
# avoids double-enqueueing rows under ACTIVE tracking — every poll
|
|
100
|
+
# and settle touches the row, so a row untouched for two intervals
|
|
101
|
+
# has no live tracker. Returns the count enqueued.
|
|
102
|
+
def resume_tracking!
|
|
103
|
+
cutoff = Time.current - (Solrengine::Sdp.configuration.transfer_poll_interval * 2)
|
|
104
|
+
count = 0
|
|
105
|
+
unsettled.where(updated_at: ..cutoff).find_each do |transfer|
|
|
106
|
+
TrackTransferJob.perform_later(transfer)
|
|
107
|
+
count += 1
|
|
108
|
+
end
|
|
109
|
+
count
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def engine_status_for(sdp_status)
|
|
113
|
+
# Unrecognized SDP statuses map to "processing": non-terminal, keep
|
|
114
|
+
# polling — safer than inventing a verdict for a status SDP adds in
|
|
115
|
+
# a later version.
|
|
116
|
+
SDP_STATUS_MAP.fetch(sdp_status.to_s, "processing")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Never hand SDP a transfer that can't pay for itself — but only
|
|
122
|
+
# block on a balance we positively read. A nil balance (SOL row
|
|
123
|
+
# missing or balances unreadable) does NOT block: SDP omits rows on
|
|
124
|
+
# RPC hiccups, and the POST — not the preflight — is the authority
|
|
125
|
+
# on whether the transfer can execute.
|
|
126
|
+
def ensure_balance_covers!(wallet_id, amount)
|
|
127
|
+
balance = sol_balance(wallet_id)
|
|
128
|
+
return if balance.nil?
|
|
129
|
+
|
|
130
|
+
required = BigDecimal(amount) + FEE_BUFFER
|
|
131
|
+
return unless required > balance
|
|
132
|
+
|
|
133
|
+
raise InsufficientBalance,
|
|
134
|
+
"Insufficient SOL: wallet #{wallet_id} holds #{balance.to_s("F")} SOL but " \
|
|
135
|
+
"#{required.to_s("F")} is required (#{amount} + #{FEE_BUFFER.to_s("F")} fee buffer)."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# SOL balance as BigDecimal, or nil when unknown — nil is never zero.
|
|
139
|
+
def sol_balance(wallet_id)
|
|
140
|
+
row = Solrengine::Sdp.client.wallet_balances(wallet_id).find { |b| b.token == "SOL" }
|
|
141
|
+
return nil if row.nil? || row.ui_amount.nil?
|
|
142
|
+
|
|
143
|
+
BigDecimal(row.ui_amount.to_s)
|
|
144
|
+
rescue ::Sdp::Error, ArgumentError, TypeError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
STATUSES.each do |name|
|
|
150
|
+
define_method("#{name}?") { status == name }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def terminal?
|
|
154
|
+
TERMINAL_STATUSES.include?(status)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The memo actually sent to SDP: app memo (when given) + engine token.
|
|
158
|
+
def composed_memo
|
|
159
|
+
[ memo, memo_token ].compact.join(MEMO_SEPARATOR)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The single, never-retried POST. Every outcome lands on the row:
|
|
163
|
+
#
|
|
164
|
+
# success → adopt SDP's id/status/signature (mapped status)
|
|
165
|
+
# SigningPending → stay processing; 202 details noted on sdp_error
|
|
166
|
+
# Timeout → "unknown" (outcome unknown — reconcile by memo token)
|
|
167
|
+
# Unavailable → "failed", "unsent:" prefix (request never processed)
|
|
168
|
+
# other Sdp::Error (TransferExecutionError/TransactionFailed/rejections)
|
|
169
|
+
# → "failed" with the renderable message (AE2)
|
|
170
|
+
def submit_to_sdp!
|
|
171
|
+
adopt!(
|
|
172
|
+
Solrengine::Sdp.client.create_transfer(
|
|
173
|
+
source: source_wallet_id,
|
|
174
|
+
destination: destination,
|
|
175
|
+
amount: amount,
|
|
176
|
+
token: token,
|
|
177
|
+
memo: composed_memo
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
rescue ::Sdp::SigningPending => e
|
|
181
|
+
# HTTP 202: accepted, awaiting additional signatures. Non-terminal —
|
|
182
|
+
# tracked like any processing transfer once SDP exposes the id.
|
|
183
|
+
update!(
|
|
184
|
+
status: "processing",
|
|
185
|
+
sdp_transfer_id: e.details.is_a?(Hash) ? (e.details[:transfer_id] || e.details[:id]) : nil,
|
|
186
|
+
sdp_error: "signing_pending: #{e.message}"
|
|
187
|
+
)
|
|
188
|
+
rescue ::Sdp::Timeout
|
|
189
|
+
# Read timeout on the POST: SDP may or may not have executed it.
|
|
190
|
+
# NEVER re-POST; TrackTransferJob reconciles via the memo token.
|
|
191
|
+
update!(status: "unknown")
|
|
192
|
+
rescue ::Sdp::Unavailable => e
|
|
193
|
+
# Connection refused/reset or a 5xx without SDP's error shape: the
|
|
194
|
+
# request was never processed, so no money moved. The "unsent:"
|
|
195
|
+
# prefix distinguishes this from an on-chain failure.
|
|
196
|
+
settle!("failed", sdp_error: "unsent: #{e.message}")
|
|
197
|
+
rescue ::Sdp::Error => e
|
|
198
|
+
# TransferExecutionError (the Kora/FL-11 gate, AE2), TransactionFailed,
|
|
199
|
+
# and every other SDP rejection: terminal, message renderable as-is.
|
|
200
|
+
settle!("failed", sdp_error: e.message)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Maps an Sdp::Transfer struct onto this row per the status table.
|
|
204
|
+
# Tolerates omitted optional fields (struct members are nil): existing
|
|
205
|
+
# id/signature are never clobbered with nil.
|
|
206
|
+
def adopt!(sdp_transfer)
|
|
207
|
+
mapped = self.class.engine_status_for(sdp_transfer.status)
|
|
208
|
+
attributes = {
|
|
209
|
+
sdp_transfer_id: sdp_transfer.id || sdp_transfer_id,
|
|
210
|
+
sdp_status: sdp_transfer.status,
|
|
211
|
+
status: mapped,
|
|
212
|
+
signature: sdp_transfer.signature || signature
|
|
213
|
+
}
|
|
214
|
+
attributes[:sdp_error] = sdp_transfer.error if sdp_transfer.error
|
|
215
|
+
attributes[:settled_at] = Time.current if TERMINAL_STATUSES.include?(mapped) && settled_at.nil?
|
|
216
|
+
update!(attributes)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Lands the row in a terminal state and timestamps the verdict.
|
|
220
|
+
def settle!(terminal_status, sdp_error: nil)
|
|
221
|
+
update!(
|
|
222
|
+
status: terminal_status,
|
|
223
|
+
sdp_error: sdp_error || self[:sdp_error],
|
|
224
|
+
settled_at: Time.current
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Solrengine
|
|
7
|
+
module Sdp
|
|
8
|
+
# `bin/rails generate solrengine:sdp:install`
|
|
9
|
+
#
|
|
10
|
+
# One run gives a default Rails app everything Wallet-per-User needs:
|
|
11
|
+
#
|
|
12
|
+
# * two timestamped migrations (WalletOwner columns on users + the
|
|
13
|
+
# solrengine_sdp_transfers table) — skipped when a migration of the
|
|
14
|
+
# same name already exists, so re-running never duplicates them
|
|
15
|
+
# * config/initializers/solrengine_sdp.rb (ENV-backed configuration)
|
|
16
|
+
# * `include Solrengine::Sdp::WalletOwner` injected into app/models/user.rb
|
|
17
|
+
# * bin/sdp_watcher + a Procfile.dev entry for it
|
|
18
|
+
# * SDP_* keys appended to .env
|
|
19
|
+
# * a working development cable adapter (see #configure_cable_adapter —
|
|
20
|
+
# the default async adapter silently drops the watcher's broadcasts)
|
|
21
|
+
class InstallGenerator < Rails::Generators::Base
|
|
22
|
+
source_root File.expand_path("templates", __dir__)
|
|
23
|
+
|
|
24
|
+
MIGRATIONS = %w[add_solrengine_sdp_to_users create_solrengine_sdp_transfers].freeze
|
|
25
|
+
|
|
26
|
+
# The exact development block `rails new` emits — the only async config
|
|
27
|
+
# this generator rewrites mechanically. Anything else async-but-custom
|
|
28
|
+
# gets loud manual instructions instead of risky surgery.
|
|
29
|
+
ASYNC_DEVELOPMENT_BLOCK = "development:\n adapter: async\n"
|
|
30
|
+
SOLID_CABLE_DEVELOPMENT_BLOCK = <<~YAML
|
|
31
|
+
development:
|
|
32
|
+
adapter: solid_cable
|
|
33
|
+
polling_interval: 0.1.seconds
|
|
34
|
+
message_retention: 1.hour
|
|
35
|
+
YAML
|
|
36
|
+
|
|
37
|
+
def create_migrations
|
|
38
|
+
base = Time.now.utc
|
|
39
|
+
MIGRATIONS.each_with_index do |name, offset|
|
|
40
|
+
if migration_exists?(name)
|
|
41
|
+
say_status :skip, "db/migrate/*_#{name}.rb already exists", :yellow
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# +offset keeps the two versions unique within a single run.
|
|
46
|
+
version = (base + offset).strftime("%Y%m%d%H%M%S")
|
|
47
|
+
copy_file "#{name}.rb", "db/migrate/#{version}_#{name}.rb"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_initializer
|
|
52
|
+
copy_file "initializer.rb", "config/initializers/solrengine_sdp.rb"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def include_wallet_owner_in_user_model
|
|
56
|
+
user_model = "app/models/user.rb"
|
|
57
|
+
unless exists?(user_model)
|
|
58
|
+
say_status :skip, "#{user_model} not found — add `include Solrengine::Sdp::WalletOwner` " \
|
|
59
|
+
"to your wallet-owner model and point config.user_class at it", :yellow
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
return if read(user_model).include?("Solrengine::Sdp::WalletOwner")
|
|
63
|
+
|
|
64
|
+
inject_into_class user_model, "User", <<-RUBY
|
|
65
|
+
include Solrengine::Sdp::WalletOwner
|
|
66
|
+
|
|
67
|
+
# Provision a custody wallet on signup (opt-in — uncomment to enable):
|
|
68
|
+
# after_create_commit :provision_wallet!
|
|
69
|
+
|
|
70
|
+
RUBY
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def install_watcher
|
|
74
|
+
copy_file "sdp_watcher", "bin/sdp_watcher"
|
|
75
|
+
chmod "bin/sdp_watcher", 0o755
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def update_procfile
|
|
79
|
+
if exists?("Procfile.dev")
|
|
80
|
+
unless read("Procfile.dev").include?("sdp_watcher:")
|
|
81
|
+
append_to_file "Procfile.dev", "sdp_watcher: bin/sdp_watcher\n"
|
|
82
|
+
end
|
|
83
|
+
else
|
|
84
|
+
create_file "Procfile.dev", "web: bin/rails server\nsdp_watcher: bin/sdp_watcher\n"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def append_env_keys
|
|
89
|
+
return if exists?(".env") && read(".env").include?("SDP_API_KEY")
|
|
90
|
+
|
|
91
|
+
if exists?(".env")
|
|
92
|
+
append_to_file ".env", env_block
|
|
93
|
+
else
|
|
94
|
+
create_file ".env", env_block
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The cable adapter is part of this engine's correctness, not a nicety:
|
|
99
|
+
# the default `async` adapter accepts broadcasts but delivers them
|
|
100
|
+
# in-process only, so everything bin/sdp_watcher (a separate process)
|
|
101
|
+
# pushes would be silently dropped — no error, the browser just never
|
|
102
|
+
# updates. Development must be on a cross-process adapter.
|
|
103
|
+
#
|
|
104
|
+
# Decision (documented): the meta-gem's surgery force-replaces
|
|
105
|
+
# database.yml with its own multi-database layout; this generator stays
|
|
106
|
+
# single-database and writes a solid_cable development block WITHOUT
|
|
107
|
+
# connects_to (Solid Cable then uses the primary database) plus loud
|
|
108
|
+
# instructions for the gem + table. bin/sdp_watcher proves the result
|
|
109
|
+
# with a boot-time broadcast self-check.
|
|
110
|
+
def configure_cable_adapter
|
|
111
|
+
unless exists?("config/cable.yml")
|
|
112
|
+
copy_file "cable.yml", "config/cable.yml"
|
|
113
|
+
say_cable_instructions
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
adapter = development_cable_adapter
|
|
118
|
+
if adapter == "async"
|
|
119
|
+
if read("config/cable.yml").include?(ASYNC_DEVELOPMENT_BLOCK)
|
|
120
|
+
gsub_file "config/cable.yml", ASYNC_DEVELOPMENT_BLOCK, SOLID_CABLE_DEVELOPMENT_BLOCK
|
|
121
|
+
say_cable_instructions
|
|
122
|
+
else
|
|
123
|
+
say_cable_refusal("development uses the async adapter in a non-default layout")
|
|
124
|
+
end
|
|
125
|
+
elsif adapter.is_a?(String) && !adapter.empty?
|
|
126
|
+
say_status :identical, "config/cable.yml development adapter is #{adapter} — cable adapter OK", :blue
|
|
127
|
+
else
|
|
128
|
+
say_cable_refusal("could not determine the development adapter")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def show_post_install
|
|
133
|
+
say "\n solrengine-sdp installed!", :green
|
|
134
|
+
say <<~MSG
|
|
135
|
+
|
|
136
|
+
Next steps:
|
|
137
|
+
1. bin/rails db:migrate
|
|
138
|
+
2. Fill in .env: SDP_API_KEY (needs custody:admin + wallets:* + payments:* scopes),
|
|
139
|
+
SDP_API_BASE_URL, SDP_CUSTODY_PROVIDER
|
|
140
|
+
3. Wallet-per-User needs a MANAGED custody provider (e.g. Privy) and Kora for
|
|
141
|
+
transfer execution — see the solrengine-sdp README "Prerequisites"
|
|
142
|
+
4. To provision wallets on signup, uncomment in app/models/user.rb:
|
|
143
|
+
after_create_commit :provision_wallet!
|
|
144
|
+
5. bin/dev — Procfile.dev now runs bin/sdp_watcher alongside the web server
|
|
145
|
+
|
|
146
|
+
MSG
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Destination-rooted file helpers: plain File.exist?("Procfile.dev")
|
|
152
|
+
# would resolve against the process CWD, which differs from the target
|
|
153
|
+
# app in generator tests.
|
|
154
|
+
def exists?(relative_path)
|
|
155
|
+
File.exist?(File.join(destination_root, relative_path))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def read(relative_path)
|
|
159
|
+
File.read(File.join(destination_root, relative_path))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def migration_exists?(name)
|
|
163
|
+
Dir.glob(File.join(destination_root, "db", "migrate", "*_#{name}.rb")).any?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def development_cable_adapter
|
|
167
|
+
config = YAML.safe_load(read("config/cable.yml"), aliases: true)
|
|
168
|
+
return nil unless config.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
development = config["development"]
|
|
171
|
+
development.is_a?(Hash) ? development["adapter"] : nil
|
|
172
|
+
rescue Psych::Exception
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def env_block
|
|
177
|
+
<<~ENV
|
|
178
|
+
# Solana Developer Platform (solrengine-sdp). The API key needs the
|
|
179
|
+
# custody:admin, wallets:* and payments:* scopes. For Wallet-per-User
|
|
180
|
+
# the SDP project must use a managed custody provider (e.g. privy) —
|
|
181
|
+
# local custody is a single root wallet and cannot provision per-user
|
|
182
|
+
# wallets — and Kora for fee payment (FEE_PAYMENT_PROVIDER=kora).
|
|
183
|
+
SDP_API_KEY=
|
|
184
|
+
SDP_API_BASE_URL=http://127.0.0.1:8787
|
|
185
|
+
SDP_CUSTODY_PROVIDER=privy
|
|
186
|
+
ENV
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def say_cable_instructions
|
|
190
|
+
say <<~MSG, :yellow
|
|
191
|
+
|
|
192
|
+
config/cable.yml development now uses Solid Cable (the default async
|
|
193
|
+
adapter delivers in-process only and would silently drop everything
|
|
194
|
+
bin/sdp_watcher broadcasts). To finish:
|
|
195
|
+
|
|
196
|
+
1. Ensure `gem "solid_cable"` is in your Gemfile (Rails 8 apps have it;
|
|
197
|
+
older apps: `bundle add solid_cable && bin/rails solid_cable:install`,
|
|
198
|
+
then re-check that cable.yml development still says solid_cable).
|
|
199
|
+
2. Create the solid_cable_messages table in your DEVELOPMENT database
|
|
200
|
+
(development runs Solid Cable on the primary database):
|
|
201
|
+
bin/rails runner 'load Rails.root.join("db/cable_schema.rb")'
|
|
202
|
+
3. Restart your web server — a running Puma keeps the old adapter in memory.
|
|
203
|
+
|
|
204
|
+
bin/sdp_watcher verifies the cable backend with a broadcast self-check at boot.
|
|
205
|
+
MSG
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def say_cable_refusal(reason)
|
|
209
|
+
say <<~MSG, :red
|
|
210
|
+
|
|
211
|
+
NOT touching config/cable.yml: #{reason}.
|
|
212
|
+
The async adapter delivers broadcasts in-process only — everything
|
|
213
|
+
bin/sdp_watcher pushes would be silently dropped. Please make the
|
|
214
|
+
development adapter cross-process yourself (solid_cable or redis), e.g.:
|
|
215
|
+
|
|
216
|
+
#{SOLID_CABLE_DEVELOPMENT_BLOCK.gsub(/^/, " ")}
|
|
217
|
+
then create the solid_cable_messages table in your development database:
|
|
218
|
+
bin/rails runner 'load Rails.root.join("db/cable_schema.rb")'
|
|
219
|
+
|
|
220
|
+
bin/sdp_watcher verifies the cable backend with a broadcast self-check at boot.
|
|
221
|
+
MSG
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# The four columns Solrengine::Sdp::WalletOwner expects on the wallet-owner
|
|
2
|
+
# model. Using a model other than User? Rename the table below and set
|
|
3
|
+
# `config.user_class` in config/initializers/solrengine_sdp.rb.
|
|
4
|
+
class AddSolrengineSdpToUsers < ActiveRecord::Migration[7.1]
|
|
5
|
+
def change
|
|
6
|
+
add_column :users, :sdp_wallet_id, :string # SDP's walletId once provisioned
|
|
7
|
+
add_column :users, :wallet_address, :string # the wallet's Solana public key
|
|
8
|
+
add_column :users, :sdp_provisioning_state, :string, default: "pending", null: false
|
|
9
|
+
add_column :users, :sdp_provisioning_error, :string # renderable failure reason
|
|
10
|
+
|
|
11
|
+
add_index :users, :sdp_provisioning_state
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Created by solrengine:sdp:install.
|
|
2
|
+
#
|
|
3
|
+
# Rails' default `async` adapter delivers broadcasts IN-PROCESS ONLY:
|
|
4
|
+
# everything bin/sdp_watcher pushes from its own process would be silently
|
|
5
|
+
# dropped. Solid Cable relays through the database and works across
|
|
6
|
+
# processes. With no `connects_to`, development uses the primary database —
|
|
7
|
+
# the solid_cable_messages table must exist there (see the solrengine-sdp
|
|
8
|
+
# README, "Cable adapter").
|
|
9
|
+
development:
|
|
10
|
+
adapter: solid_cable
|
|
11
|
+
polling_interval: 0.1.seconds
|
|
12
|
+
message_retention: 1.hour
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
adapter: test
|
|
16
|
+
|
|
17
|
+
production:
|
|
18
|
+
adapter: solid_cable
|
|
19
|
+
connects_to:
|
|
20
|
+
database:
|
|
21
|
+
writing: cable
|
|
22
|
+
polling_interval: 0.1.seconds
|
|
23
|
+
message_retention: 1.day
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Engine-owned record of every transfer the app attempts through SDP — the
|
|
2
|
+
# row is the audit trail and the thing the app renders. See
|
|
3
|
+
# Solrengine::Sdp::Transfer for the status state machine.
|
|
4
|
+
class CreateSolrengineSdpTransfers < ActiveRecord::Migration[7.1]
|
|
5
|
+
def change
|
|
6
|
+
create_table :solrengine_sdp_transfers do |t|
|
|
7
|
+
t.string :sdp_transfer_id # nil until SDP responds (POST timeout → reconcile)
|
|
8
|
+
t.string :source_wallet_id, null: false
|
|
9
|
+
t.string :destination, null: false
|
|
10
|
+
t.string :token, null: false, default: "SOL"
|
|
11
|
+
t.string :amount, null: false # decimal string — never a float
|
|
12
|
+
t.string :memo # the app's memo (composes with memo_token)
|
|
13
|
+
t.string :memo_token, null: false # engine reconcile token
|
|
14
|
+
t.string :status, null: false, default: "processing"
|
|
15
|
+
t.string :sdp_status
|
|
16
|
+
t.string :signature
|
|
17
|
+
t.string :sdp_error
|
|
18
|
+
t.datetime :submitted_at
|
|
19
|
+
t.datetime :settled_at
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :solrengine_sdp_transfers, :memo_token, unique: true
|
|
25
|
+
add_index :solrengine_sdp_transfers, :status
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# solrengine-sdp — custodial Solana wallets via the Solana Developer Platform.
|
|
2
|
+
#
|
|
3
|
+
# api_key, base_url, and custody_provider already fall back to SDP_API_KEY,
|
|
4
|
+
# SDP_API_BASE_URL, and SDP_CUSTODY_PROVIDER from ENV; the explicit
|
|
5
|
+
# assignments below just make the wiring visible. A missing api_key fails
|
|
6
|
+
# loudly at boot (Solrengine::Sdp::ConfigurationError), not at the first
|
|
7
|
+
# wallet call.
|
|
8
|
+
Solrengine::Sdp.configure do |config|
|
|
9
|
+
config.api_key = ENV["SDP_API_KEY"]
|
|
10
|
+
config.base_url = ENV.fetch("SDP_API_BASE_URL", "http://127.0.0.1:8787")
|
|
11
|
+
|
|
12
|
+
# Wallet-per-User requires a MANAGED custody provider (e.g. "privy") —
|
|
13
|
+
# SDP's local custody holds a single root wallet and rejects per-user
|
|
14
|
+
# wallet provisioning (Sdp::ProviderCapabilityError).
|
|
15
|
+
config.custody_provider = ENV["SDP_CUSTODY_PROVIDER"]
|
|
16
|
+
|
|
17
|
+
# Prefix for SDP wallet labels ("#{label_namespace}-user-#{id}") — guards
|
|
18
|
+
# against collisions when several apps share one SDP project. Defaults to
|
|
19
|
+
# the Rails application name.
|
|
20
|
+
# config.label_namespace = "myapp"
|
|
21
|
+
|
|
22
|
+
# The wallet-owner model (the one including Solrengine::Sdp::WalletOwner).
|
|
23
|
+
# Defaults to "User".
|
|
24
|
+
# config.user_class = "Account"
|
|
25
|
+
|
|
26
|
+
# Realtime broadcast targets — what bin/sdp_watcher re-fetches and pushes
|
|
27
|
+
# when a wallet's account changes on chain. Ordered: put fast,
|
|
28
|
+
# money-bearing regions first. The lambdas run in the watcher process,
|
|
29
|
+
# OUTSIDE any HTTP request: Current attributes and the session are
|
|
30
|
+
# unavailable, so partials must receive explicit locals.
|
|
31
|
+
#
|
|
32
|
+
# config.broadcast_targets = [
|
|
33
|
+
# { name: :balance,
|
|
34
|
+
# fetch: ->(user) { Solrengine::Sdp.client.wallet_balances(user.sdp_wallet_id) },
|
|
35
|
+
# render: ->(user, balances) {
|
|
36
|
+
# Turbo::StreamsChannel.broadcast_update_to(
|
|
37
|
+
# [ user, :wallet ],
|
|
38
|
+
# target: "wallet_balance",
|
|
39
|
+
# partial: "wallets/balance",
|
|
40
|
+
# locals: { balances: balances }
|
|
41
|
+
# )
|
|
42
|
+
# } }
|
|
43
|
+
# ]
|
|
44
|
+
end
|