Skip to content
SolRengine

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 instanceself-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.