Self-Hosting SDP: A Field Guide
Running your own Solana Developer Platform instance to back Wallet-per-User — the runtime, custody, fee-payer, realtime, and pricing decisions that aren't in the README, learned from shipping a neobank demo on it.
Wallet-per-User needs a running Solana Developer Platform (SDP) instance behind it. You can use a managed one, or self-host. This guide is the second path: the configuration decisions that aren’t obvious from SDP’s README, gathered while running a self-hosted stack for Lamport, a devnet neobank demo.
SDP is pre-mainnet, unaudited, and devnet-oriented — so is this guide. It tracks SDP v0.31 (the version the gems pin). SDP changes its API and behavior between minor versions; re-verify against your own version. Where the gems turn a sharp edge into a typed error, it’s noted.
The short version — a self-hosted instance that can actually move money for per-user wallets needs four non-default choices:
- The Node runtime, not workerd.
- A managed custody provider, not local custody.
- Kora as the fee payer, not the native adapter.
- Your own RPC (a real one), and your own price source if it isn’t Helius.
The rest of this page is why.
1. Run the Node runtime, not workerd
SDP ships two ways to run the API: wrangler dev (the workerd runtime) and a plain Node server. The README leads with workerd. Custody routes hang forever on workerd.
With SDP_DEPLOYMENT_MODE=self_hosted and a local signer under wrangler dev, POST /v1/wallets/initialize (and the first-touch GET/POST /v1/wallets) log the request and then never respond — past two minutes, still nothing. The identical call against the Node runtime returns in ~340ms. The likely cause is a keychain/crypto dependency that doesn’t work under workerd’s local mode; SDP’s own smoke tests run on Node, so it goes unseen.
Run the Node server for anything that touches custody:
# Node runtime — Redis-backed, custody routes work
ENVIRONMENT=local API_VERSION=v1 REDIS_URL=redis://127.0.0.1:6379 \
tsx apps/sdp-api/src/server.ts
Custody config created on Node is visible to both runtimes afterward, so “initialize once on Node, then run whatever you like” also works — but the simplest answer is to stay on Node. The gems don’t care which runtime answers; they only see the hang as an Sdp::Timeout.
2. Local custody can’t do Wallet-per-User — use a managed provider
SDP’s local custody provider is the only one with supportsAdditionalWalletCreation: false. In practice that means: one local custody config per org+project (a second initialize returns 409), exactly one “Root Signing Wallet” under it, and POST /v1/wallets answered with 400 — "Wallet provisioning not supported for provider: local". The provisionWallet: true API-key path hits the same gate.
So a self-hosted instance on pure-local custody gives every app one wallet. Wallet-per-User — the whole point of the custodial path — requires a managed provider: Privy, CDP, or Turnkey on an individual-wallet tier. This limitation isn’t stated in SDP’s docs; you discover it at the first create_wallet.
The client turns the capability gate into a named error rather than a bare 400:
client.create_wallet(label: "user-42")
# Sdp::ProviderCapabilityError:
# "Wallet provisioning not supported for provider: local.
# Wallet-per-User requires a managed provider (e.g. privy) — set
# SDP_CUSTODY_PROVIDER or pass provider:."
Configure the managed provider in SDP, then point the gem at it once — config.custody_provider = "privy" (engine) or SDP_CUSTODY_PROVIDER / Sdp::Client.new(custody_provider:) (client). Every wallet call is then provider-scoped.
3. Transfers need Kora as the fee payer (today)
Every transfer execution path ends in feePayment.signAndSend. With FEE_PAYMENT_PROVIDER=native, that throws — NativeAdapter.signAndSend is a stub (“not supported — use KoraAdapter”). The result is HTTP 502 SOLANA_RPC_ERROR on every transfer, even with a fully funded fee payer.
The trap: SDP’s “minimum self-hosted set” in .dev.vars.example instructs FEE_PAYMENT_PROVIDER=native and explicitly says not to default to Kora. So the documented zero-dependency config is one whose payments API cannot pay. You hit it on your first transfer.
Run Kora:
# infra/kora/docker-compose.yml — needs a funded devnet fee payer
SIGNER_PRIVATE_KEY=<funded-devnet-keypair> RPC_URL=<your-rpc>
# SDP env
FEE_PAYMENT_PROVIDER=kora
KORA_RPC_URL=http://localhost:8080
The client surfaces the native-adapter failure as a typed, non-transient error so it’s never mistaken for a flaky RPC:
# with FEE_PAYMENT_PROVIDER=native, on the first transfer:
# Sdp::TransferExecutionError — "the fee-payment provider cannot submit
# transactions. Run Kora and set FEE_PAYMENT_PROVIDER=kora."
Changing upstream. A working native fee-payer implementation has been verified on devnet and accepted by the SDP team, who are landing it in-house. Once it ships in an SDP release, native fee payment becomes viable and Kora becomes optional. Until then, Kora is required for transfers. Re-check against your SDP version.
4. Realtime: build the doorbell yourself
SDP has no webhooks, streams, or subscription endpoint for wallet activity. A live UI has two options: poll the REST endpoints on a timer, or subscribe to the chain directly and use that as a trigger. Polling at UI-friendly intervals hammers /v1/payments/* and the RPC behind it, flickers, and still lags real events.
The pattern that works is a chain doorbell: a watcher process holds a per-wallet accountSubscribe WebSocket to the RPC; when an account changes, it re-fetches the authoritative balances from SDP (not from the chain) and broadcasts a Turbo Stream. The chain only rings the bell — SDP stays the source of truth.
You don’t have to build it: solrengine-sdp ships this on top of solrengine-realtime. Configure what the watcher re-fetches and renders when a wallet changes:
# config/initializers/solrengine_sdp.rb
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 }
)
} }
]
5. USD values: bring your own price source if you’re not on Helius
A fiat line next to token amounts is table stakes for a wallet UI. SDP’s payments balances endpoint (GET /v1/payments/wallets/:id/balances) historically returned bare balances with no usdValue, even though the field exists in adjacent schemas — an asymmetry that reads as “supported” until the response says otherwise.
As of SDP v0.29 (PR #432) the payments handler does populate usdValue — but it sources prices from Helius DAS. If your RPC isn’t Helius, balances still come back bare. So a self-hoster on a non-Helius RPC needs a price fallback. solrengine-tokens provides one (Jupiter price/v3, cached 60s); wire it as the soft fallback and your UI gets a USD line regardless of which RPC backs SDP.
Two RPC gotchas while you’re here:
- The public devnet RPC 403-blocks
getTokenAccountsByOwner, which breaks SPL balance reads — use a free Helius or Triton devnet key, notapi.devnet.solana.com. - Helius hands you a mainnet URL by default. Pasting it points the whole stack at mainnet silently: SPL errors vanish, balances read 0 (wrong cluster), no warning. Use a
devnet.helius-rpc.comURL for a devnet stack.
Minimum working self-hosted config
# Runtime
ENVIRONMENT=local
API_VERSION=v1
REDIS_URL=redis://127.0.0.1:6379 # Node runtime, not wrangler/workerd
# Custody — a managed provider, not local (Wallet-per-User needs it)
SDP_CUSTODY_PROVIDER=privy # + the provider's own credentials
# Fee payer — Kora, not native (today)
FEE_PAYMENT_PROVIDER=kora
KORA_RPC_URL=http://localhost:8080
# RPC — a real devnet endpoint, not the public one
SOLANA_RPC_URL=https://devnet.helius-rpc.com/?api-key=...
With those four choices made, the gems work as documented: per-user wallets provision, transfers confirm, balances push live, and USD values render. Each sharp edge above surfaces as a typed error that names the fix, so a misconfiguration tells you what it wants instead of failing in the dark.
See also
- Wallet-per-User (SDP) — the gems and the two custody models.
- solrengine-realtime — the WebSocket doorbell this guide’s §4 builds on.
- solrengine-tokens — the price fallback from §5.
License
MIT