Getting Started: rails new → a custodial-wallet app on SDP
A step-by-step walkthrough from an empty Rails app to a working custodial Solana app — email signup with a provisioned wallet per user, and a points token you mint and burn — built on the solrengine-sdp engine. The Kudos reference app, from scratch.
This walks from rails new to a working custodial Solana app: users sign up
with an email, your app provisions a wallet for each of them, and you issue a
points token you can mint (earn) and burn (redeem). It’s the
Kudos reference app, built from scratch.
It uses the Wallet-per-User path on the Solana Developer Platform (SDP). For the wallets-and-payments side of the same path, see Lamport.
Before you start
SDP is pre-mainnet, unaudited, and devnet-oriented, and it has real infrastructure requirements. You need:
- A running SDP instance — self-hosted or managed.
- A managed custody provider (e.g. Privy) — local custody holds one root wallet and can’t provision a wallet per user.
- Kora as the fee payer, and a real RPC (a Helius-class endpoint) for SPL balance reads.
The full source for everything below is the Kudos repo.
1. New app + gems
rails new kudos --css=tailwind --javascript=esbuild
cd kudos
# Gemfile
gem "solana-sdp", "~> 0.2" # Ruby client for the SDP API
gem "solrengine-sdp", "~> 0.3" # Rails engine: Wallet-per-User + token issuance
gem "solrengine-realtime", "~> 0.2" # the doorbell that pushes live balances
gem "solrengine-rpc", "~> 0.1" # chain subscription for the balance monitor
bundle install
bin/rails generate solrengine:sdp:install # migrations, initializer, bin/sdp_watcher, Procfile.dev, .env keys
bin/rails db:migrate
The install generator also switches dev Action Cable to Solid Cable (the doorbell needs a real adapter).
2. Configure SDP
The generated initializer reads three env vars — a missing API key fails loudly at boot:
# .env
SDP_API_KEY=... # required; Bearer key for the SDP API
SDP_API_BASE_URL=http://127.0.0.1:8787 # SDP base URL (this is the default)
SDP_CUSTODY_PROVIDER=privy # a managed provider; local custody can't do wallet-per-user
3. A wallet per user
Make signup provision a custody wallet. Auth is plain Rails — it’s custodial, so there’s no wallet sign-in:
# app/models/user.rb
class User < ApplicationRecord
include Solrengine::Sdp::WalletOwner # wallet state, sdp_wallet_id, provision_wallet!
has_secure_password
# Async so signup never blocks on the SDP API. The dashboard polls the wallet
# state pending → provisioning → ready.
after_create_commit :provision_wallet!
end
WalletOwner gives you wallet_ready?, wallet_address, sdp_wallet_id, and the
provisioning state machine. ProvisionWalletJob does the API call off the request.
4. Issue the points token
A token needs a mint authority — a custody wallet your business controls. Find-or-create one by a stable label so setup is idempotent:
# app/models/treasury.rb
class Treasury
LABEL = "kudos-treasury"
def self.wallet_id
client = Solrengine::Sdp.client
(client.list_wallets.find { |w| w.label == LABEL } || client.create_wallet(label: LABEL)).id
end
end
Then register the token off-chain and deploy the mint on-chain. Points are whole
numbers, so decimals: 0:
token = Solrengine::Sdp::Token.register!(
name: "Kudos Points", symbol: "KUDO", decimals: 0,
signing_wallet_id: Treasury.wallet_id
)
token.deploy! # custodial sign-and-send; records the mint address. Never retried.
token.deployed? # => true
5. Earn = mint
Minting points to a user is one call. Record-then-enqueue, so it never blocks the request and never double-mints (SDP has no idempotency key, so the engine writes an audit row + atomic claim and sends a single, never-retried POST):
# app/controllers/earnings_controller.rb
token.mint!(destination: current_user.wallet_address, amount: 10)
The demo mints a fixed amount from a button; a real app calls the same mint! on
a genuine event — a purchase, a referral. Only the trigger differs.
6. Redeem = burn
A burn is signed by the holder’s wallet (burn authority is the token-account owner, not the mint authority). Check affordability against the on-chain balance first:
# app/controllers/redemptions_controller.rb
balance = PointsBalance.for(current_user, token: token) # reads the chain via SDP
if balance.nil?
redirect_to dashboard_path, alert: "Couldn't read your balance — try again."
elsif balance < reward.points_cost
redirect_to dashboard_path, alert: "You need #{reward.points_cost} points."
else
token.burn!(
source: current_user.wallet_address,
signing_wallet_id: current_user.sdp_wallet_id,
amount: reward.points_cost
)
end
7. Live balance, no polling
Because your app initiates every mint and burn, the settling job is the doorbell.
Wire what to re-render in the initializer’s broadcast_targets, then run
bin/sdp_watcher (it’s already in your Procfile.dev):
# config/initializers/solrengine_sdp.rb
config.broadcast_targets = [
{
name: :points_balance,
fetch: ->(user) { PointsBalance.for(user) || :unavailable },
render: ->(user, balance) {
Turbo::StreamsChannel.broadcast_replace_to(
user.realtime_stream, target: "points_balance",
partial: "dashboard/balance", locals: { balance: balance }
)
}
}
]
These lambdas run in the watcher process — outside any request — so everything
comes from the user argument, not Current or the session. Hit “Earn,” and the
number just moves.
8. Run it
bin/rails db:seed # optional: a starter rewards catalog
bin/dev # web · js · css · jobs · sdp_watcher
Sign up, deploy the token once, earn, then redeem — the balance updates live.
That’s the whole custodial issuance loop. The complete app — views, tests, the rewards catalog, the doorbell partials — is the Kudos reference app. For the infrastructure behind it, see the self-hosting field guide.