solrengine-sdp
Rails engine for Wallet-per-User custodial Solana wallets backed by the Solana Developer Platform (SDP). Your users sign up with an email — the engine provisions an SDP custody wallet for each of them, persists and tracks every transfer to a renderable terminal state, and pushes live balance updates to the browser when money moves on chain.
It composes the SolRengine family: the solana-sdp API client underneath, solrengine-realtime for WebSocket account subscriptions, and (optionally) solrengine-tokens as a USD price source. This is the "you hold wallets for your users" path; for "your users bring their own wallets", see the rest of the family at solrengine.org.
Prerequisites
Honest list — SDP is pre-mainnet and devnet-oriented, and Wallet-per-User has real infrastructure requirements:
| You need | Why | Without it |
|---|---|---|
| A running SDP instance (self-hosted dev stack or managed) | The engine talks to SDP's wallets + payments API | Nothing works; boot check fails on a missing key |
| A managed custody provider (e.g. Privy) configured in SDP | Per-user wallet provisioning | Local custody holds a single root wallet and rejects POST /v1/wallets (Sdp::ProviderCapabilityError) |
Kora as SDP's fee-payment provider (FEE_PAYMENT_PROVIDER=kora) |
Transfer execution | The native adapter can build and sign transfers but cannot submit them (Sdp::TransferExecutionError) |
An SDP API key with custody:admin, wallets:*, and payments:* scopes |
Custody init, provisioning, balances, transfers | 403 Sdp::InsufficientPermissions |
A non-async Action Cable adapter in development |
The watcher broadcasts from its own process | The install generator handles this — see Cable adapter |
| A Helius-class RPC endpoint (for SPL token balances) | Public devnet RPC lacks the indexing SDP uses for token balances | SOL still works; SPL balance rows may be missing |
Quickstart
From zero to a confirmed transfer updating the screen live:
rails new mywallet
cd mywallet
Add to the Gemfile:
gem "solrengine-sdp"
gem "dotenv-rails", groups: [ :development, :test ] # or load .env your own way
Then:
bundle install
bin/rails generate solrengine:sdp:install
bin/rails db:migrate
The generator created migrations, config/initializers/solrengine_sdp.rb, bin/sdp_watcher, a Procfile.dev entry, .env keys, and switched development Action Cable to Solid Cable (follow its printed instructions for the solid_cable_messages table). Fill in .env:
SDP_API_KEY=sk_... # custody:admin + wallets:* + payments:* scopes
SDP_API_BASE_URL=http://127.0.0.1:8787 # your SDP instance
SDP_CUSTODY_PROVIDER=privy # managed provider — see Prerequisites
Opt in to provisioning on signup — uncomment in app/models/user.rb:
after_create_commit :provision_wallet!
Run everything (web + watcher):
bin/dev
Sign up a user — provision_wallet! drives pending → provisioning → ready and fills wallet_address. Fund it from the devnet faucet and move money:
user = User.last
user.wallet_ready? # => true
# Devnet-only faucet (1 SOL). One attempt, never retried.
Solrengine::Sdp::Faucet.new.request_airdrop(user.wallet_address, 1_000_000_000)
# Persisted, tracked transfer — the returned row is what you render.
transfer = Solrengine::Sdp::Transfer.execute!(
source: user.sdp_wallet_id,
destination: "RecipientPublicKeyBase58...",
amount: "0.1",
memo: "first transfer"
)
transfer.status # "processing" → tracked to "confirmed" → "finalized"
With broadcast_targets configured (see Realtime) and a turbo_stream_from subscription on the page, the recipient's balance region updates live the moment the transfer lands — that is bin/sdp_watcher ringing the doorbell.
Configuration
config/initializers/solrengine_sdp.rb (generated):
Solrengine::Sdp.configure do |config|
config.api_key = ENV["SDP_API_KEY"]
# ...
end
| Attribute | Default | Purpose |
|---|---|---|
api_key |
ENV["SDP_API_KEY"] |
SDP API key. Missing key fails at boot (ConfigurationError), not at the first wallet call. |
base_url |
ENV["SDP_API_BASE_URL"], else http://127.0.0.1:8787 |
SDP API base URL. |
custody_provider |
ENV["SDP_CUSTODY_PROVIDER"] |
Custody provider passed on wallet creation. Must be a managed provider for Wallet-per-User. |
label_namespace |
Rails app name, else "app" |
Prefix for SDP wallet labels ("#{namespace}-user-#{id}"); guards collisions when apps share an SDP project. |
user_class |
"User" |
The wallet-owner model (the one including Solrengine::Sdp::WalletOwner). |
logger |
Rails.logger |
Engine log sink. |
expired_transfer_deadline |
900 (seconds) |
Transfers stuck in processing past this settle as expired. |
transfer_poll_interval |
3 (seconds) |
TrackTransferJob re-poll cadence. |
broadcast_retries |
3 |
Attempts per doorbell ring (the notification never re-fires). |
broadcast_retry_delay |
2 (seconds) |
Sleep between broadcast attempts (zero it in tests). |
broadcast_targets |
[] |
Ordered {name:, fetch:, render:} hashes — see Realtime. Empty means: log a hint, broadcast nothing. |
Realtime
The WebSocket account subscription is a doorbell, not a data feed: the notification only signals that a wallet's account changed. bin/sdp_watcher (its own process, in Procfile.dev) holds one subscription per wallet-ready user; on any change Solrengine::Sdp::Broadcaster re-fetches everything displayed from the authoritative source (SDP) and pushes your configured Turbo Stream updates.
The engine owns the doorbell invariants:
- All-or-nothing — every target's
fetchruns first; any failure (raise or:unavailable) means no renders this attempt, so screens never regress from good content to an error state. Last good content stays. - Consumed doorbells retry — the whole cycle retries
broadcast_retriestimes, because a WebSocket notification never re-fires. - Priority order — renders run in configured order; put money-bearing regions first.
- Request-context-free — lambdas run in the watcher process: no
Current, no session, partials need explicit locals.
config.broadcast_targets = [
{ name: :balance,
fetch: ->(user) { Solrengine::Sdp.client.wallet_balances(user.sdp_wallet_id) },
render: ->(user, balances) {
Turbo::StreamsChannel.broadcast_update_to(
[ user, :wallet ],
target: "wallet_balance",
partial: "wallets/balance",
locals: { balances: balances }
)
} }
]
USD enrichment inside fetch lambdas: Solrengine::Sdp.usd_value_for(balance) — SDP's own usd_value when present, Jupiter-derived when solrengine-tokens is installed, nil otherwise. Price failures never fail a fetch.
SOL-only doorbell in v0.1: the system-account subscription sees lamport changes on the wallet address itself. SPL deposits land in Associated Token Accounts this subscription does not see — token balances are correct on page load, they just don't ring the doorbell yet. ATA subscriptions are planned.
Degradation contract: if the watcher isn't running, screens are correct on load — they just don't update live.
Cable adapter
Rails' default async Action Cable adapter delivers broadcasts in-process only — everything the watcher pushes from its own process is silently dropped: no error, no log, the browser just never updates. The install generator rewrites the development adapter to Solid Cable (or tells you exactly what to do when your cable.yml isn't the stock layout), and bin/sdp_watcher performs a boot-time broadcast self-check plus an explicit async-adapter warning so a broken cable backend dies loudly instead of broadcasting into the void.
Transfers
Solrengine::Sdp::Transfer is the engine-owned audit row — created before the POST to SDP, so even a crash mid-request leaves evidence to reconcile against. The create POST is never retried (SDP has no idempotency key; a blind re-send risks a double-spend); timeouts are reconciled by a unique memo token instead.
| Engine status | From | Terminal? | Meaning |
|---|---|---|---|
processing |
SDP pending/processing (and unrecognized statuses) |
No | Submitted; TrackTransferJob polls until a verdict. |
confirmed |
SDP confirmed |
No | User-facing success — tracking continues to finalized. |
finalized |
SDP finalized |
Yes | Done. |
failed |
SDP failed, SDP rejections, or unreachable-SDP (sdp_error prefixed unsent:) |
Yes | Renderable reason on sdp_error. |
expired |
engine-local | Yes | Stuck in processing past expired_transfer_deadline — verdict, not limbo. |
unknown |
engine-local | No | POST read-timeout: outcome unknown. Reconciled via the memo token through SDP's transfer list — adopted if found, failed if provably absent. |
Transfer.execute! runs a SOL balance preflight (amount + 0.000005 fee buffer) and raises InsufficientBalance before any row or POST when the wallet provably can't cover it; an unreadable balance never blocks — the POST is the authority.
Errors
Engine errors (all < Solrengine::Sdp::Error < StandardError):
| Error | Raised |
|---|---|
Solrengine::Sdp::ConfigurationError |
Boot/configure time: missing API key, malformed broadcast targets. |
Solrengine::Sdp::InsufficientBalance |
Transfer.execute! preflight — before any row or POST exists. |
Solrengine::Sdp::Faucet::RateLimited / TimedOut / Unavailable |
Devnet faucet outcomes — TimedOut means the airdrop may still land; don't double-fund. |
Transport and API errors raised while talking to SDP come from the client gem — Sdp::Error and its subclasses, including the two capability gates (Sdp::ProviderCapabilityError for local-custody provisioning, Sdp::TransferExecutionError for the native fee adapter). See the solana-sdp error taxonomy.
SDP compatibility
Tested against SDP v0.28 (Solrengine::Sdp::COMPATIBLE_SDP_VERSION). SDP is pre-1.0 and breaks its API between minors; the compatible version is bumped — and the suite re-verified — on every SDP upgrade rather than claiming an open-ended range.
Local development
The Gemfile path-sources sibling checkouts: ../solana-sdp, ../solrengine-realtime (needs the feat/subscriber-registry branch for the 0.2 registry), ../solrengine-rpc, and ../solrengine-tokens (optional price source, dev-only — it is not a gemspec dependency). Clone them next to this repo, then:
bundle install
bundle exec rake test
bundle exec rubocop
Note: until solana-sdp and the realtime 0.2 branch are pushed to GitHub, CI's sibling-clone steps will fail remotely; local development is unaffected.
See also
- solana-sdp — the plain-Ruby SDP API client this engine builds on (usable without Rails).
- solrengine — the meta-gem for the connect-your-wallet path; this engine is deliberately not among its dependencies (custodial mode is opt-in).
- solrengine.org — the SolRengine family: the connect-your-wallet stack, and how both custody models compose.
License
MIT