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,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