solana-sdp

Ruby SDK for the Solana Developer Platform (SDP) wallets, payments, token-issuance, and ramp APIs.

Plain Ruby, zero runtime dependencies (Net::HTTP), typed rescuable errors that mirror SDP's real failure modes, and a retry posture that never re-sends a transfer.

SDP is pre-mainnet, unaudited, and devnet-oriented — so is this gem. Tested against SDP v0.31 (see Version pin below).

Install

Add to your Gemfile:

gem "solana-sdp"

Or install directly:

gem install solana-sdp

Requires Ruby >= 3.2.

Quickstart

Point the client at a running SDP instance (local default: http://127.0.0.1:8787) with an API key:

require "solana-sdp"

# Reads SDP_API_BASE_URL / SDP_API_KEY from ENV, or pass them explicitly.
client = Sdp::Client.new(api_key: "sk_...")

# One-time custody setup for the project (provider defaults to SDP's
# configured custody provider; pass provider: "privy" etc. for managed custody).
client.initialize_custody

# Create a wallet (requires a managed custody provider — see
# Sdp::ProviderCapabilityError below for what happens on local custody).
wallet = client.create_wallet(label: "user-42")
wallet.id         # => "wal_..."  (SDP's walletId — what the payments API expects)
wallet.public_key # => "8x3f..."

# Check balances. A missing token row means "unavailable", never zero —
# SDP swallows RPC failures upstream.
client.wallet_balances(wallet.id).each do |balance|
  puts "#{balance.token}: #{balance.ui_amount} (#{balance.usd_value || "no price"})"
end

# Send a transfer (synchronous sign-and-send; SDP confirms before responding).
transfer = client.create_transfer(
  source: wallet.id,
  destination: "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
  amount: "0.05",
  token: "SOL"
)
transfer.status    # => "confirmed"
transfer.signature # => on-chain signature

# List transfers — returns a lazy enumerator that auto-paginates by
# following meta.hasMore. Pass page: to pin a single page instead.
client.list_transfers(wallet: wallet.id, direction: "outbound").each do |t|
  puts "#{t.created_at} #{t.amount} #{t.token} -> #{t.destination} [#{t.status}]"
end

Also available: prepare_transfer (build but don't sign/send, for non-custodial flows), get_transfer, list_wallets.

list_wallets returns an Array ([Sdp::Wallet, ...]) today — SDP does not paginate /v1/wallets at v0.31, so the result is fetched eagerly. When SDP adds pagination this will become a lazy Enumerator (matching list_transfers). Use Enumerable methods (.find, .each, .map) rather than array indexing or .length to stay forward-compatible.

Custody providers

SDP wallets are created under a custody provider. Configure it once — on the client or via SDP_CUSTODY_PROVIDER — and every wallet operation (initialize_custody, create_wallet, list_wallets) uses it unless you pass an explicit provider::

client = Sdp::Client.new(custody_provider: "privy")   # or set SDP_CUSTODY_PROVIDER
client.custody_provider          # => "privy"
client.create_wallet(label: "user-42")                # uses "privy"
client.create_wallet(label: "x", provider: "turnkey") # per-call override

Provider matrix (v0.2):

Provider Wallet-per-User Status
Privy (managed) Yes Verified end-to-end on devnet
Other managed providers (e.g. Turnkey) Yes (per SDP) Pass-through — forwarded to SDP, not independently verified here
Local custody No Holds a single root wallet; create_wallet raises Sdp::ProviderCapabilityError

Wallet-per-User requires a managed provider. With local custody, SDP exposes one root wallet and rejects POST /v1/wallets — the gem turns that into a typed Sdp::ProviderCapabilityError whose message tells you to set a managed provider (see below). The gem does not maintain an allow-list of provider names — provider: is forwarded to SDP, which is the authority on what it supports, so new SDP providers work without a gem release.

Error taxonomy

Everything raised by this gem subclasses Sdp::Error, which carries #code, #http_status, #details, and #meta alongside the message. Both #details and #meta are Hashes with snake_case symbol keys — SDP's camelCase JSON is converted to Ruby style throughout (e.g. error.details[:field_errors], not "fieldErrors" or :fieldErrors). The taxonomy mirrors how SDP actually fails:

Class Raised when Retryable?
Sdp::ConfigurationError At construction: SDP_API_KEY missing/blank No — fix config
Sdp::BadRequest 400 — the request itself is wrong (validation errors) No
Sdp::ProviderCapabilityError (< BadRequest) The configured custody provider cannot serve the request: 400 wallet-provisioning gate, or 409 on a second initialize_custody No — change provider config
Sdp::Unauthorized 401 — key missing, malformed, or revoked No
Sdp::Forbidden 403 No
Sdp::InsufficientPermissions (< Forbidden) 403 with INSUFFICIENT_PERMISSIONS — key lacks the required scope No
Sdp::NotFound 404 — but see the wallet-scoped key note No
Sdp::Conflict 409 — resource already exists / conflicting state No
Sdp::SigningPending HTTP 202 — accepted, awaiting additional signatures (multisig/approval flows). Not a success No — poll/approve
Sdp::TransactionFailed TRANSACTION_FAILED — the on-chain transaction was attempted and failed (e.g. insufficient lamports) Never — outcome semantics, not transport
Sdp::RateLimited 429 Yes, with backoff
Sdp::Timeout Read timeout — for POSTs the outcome is unknown Reads yes; writes: reconcile first
Sdp::Unavailable Connection refused/reset, connect timeout, or a 5xx that isn't a recognized capability gate Yes — the request wasn't processed
Sdp::TransferExecutionError (< Sdp::Error) 502 SOLANA_RPC_ERROR carrying SDP's NativeAdapter signature — the fee-payment provider cannot submit transactions. Not caught by rescue Sdp::Unavailable — it is not a transient error No — configuration fix

Two of these encode SDP capability gates that otherwise surface as cryptic generic errors (discriminator strings verified against SDP v0.31, documented in lib/sdp/errors.rb):

  • Sdp::ProviderCapabilityError — with local custody, SDP holds a single root wallet and POST /v1/wallets is rejected ("Wallet provisioning not supported for provider: local"). Wallet-per-User requires a managed provider (e.g. privy): pass provider: to create_wallet or set SDP_CUSTODY_PROVIDER. Also raised when initialize_custody is called twice for the same org+project (409) — initialization is one-time.
  • Sdp::TransferExecutionError — with FEE_PAYMENT_PROVIDER=native, SDP can build and sign transfers but cannot submit them; the 502 message contains the NativeAdapter signature. Fix: run Kora and set FEE_PAYMENT_PROVIDER=kora. A 502 that does not match this signature stays Sdp::Unavailable — a real RPC outage is never mislabeled as a configuration problem.

Retry posture

  • GETs retry exactly once on Sdp::Timeout / Sdp::Unavailable (transport-level failures), then raise.
  • POSTs never retry. SDP has no idempotency key at v0.31: re-sending a transfer after a read timeout risks a double-spend, because the first attempt may have landed on-chain. On Sdp::Timeout from a write, reconcile first (e.g. list_transfers filtered by wallet, or match a memo) before re-submitting.
  • Sdp::TransactionFailed is never retried blindly — it reports an on-chain outcome, not a transport failure.
  • Sdp::Unavailable means the request was not processed (connection never opened, or a 5xx without SDP's error envelope), so it is safe to retry — with backoff for Sdp::RateLimited.

The wallet-scoped 404

Wallet-scoped API keys return 404 (not 403) for wallets outside their scope. "Not found" can therefore mean "not yours". Every Sdp::NotFound message carries this hint so the failure is diagnosable from logs.

Version pin

Sdp::COMPATIBLE_SDP_VERSION # => "0.31"

SDP breaks its API between minor versions. Every release of this gem names the SDP version it was tested against, both here and in the Sdp::COMPATIBLE_SDP_VERSION constant. The covered API surface is pinned to a vendored copy of SDP's OpenAPI spec (spec/openapi-v0.31.json); contract tests assert every field this gem reads exists in that spec, and rake "sdp:drift[path/to/newer/openapi.json]" diffs a newer SDP spec against the pin to report exactly which covered endpoints changed. On an SDP version bump: re-vendor the spec, re-run the contract tests, update COMPATIBLE_SDP_VERSION.

Running against a different SDP version may work, but field shapes (e.g. usdValue on balances) are known to change between minors.

Scope

This gem covers SDP's wallets, payments, token-issuance, and ramp surface:

  • Wallets — custody initialization, wallet provisioning and listing, balances.
  • Payments — transfers (create / prepare / list / get).
  • Issuance (token lifecycle) — tokens (list / get / create / deploy) and supply actions (mint / burn), each action with a prepare variant for caller-signed flows.
  • Ramps (sandbox-only) — fiat on/off-ramps: currency discovery, on-ramp quote, on/off-ramp execute, and the sandbox simulate hook. Wired against SDP's ramp surface and verified against the sandbox, not live fiat rails — treat as preview in v0.2.

The issuance compliance actions (freeze/unfreeze, pause, authority, allowlist, seize, force-burn) and the dashboard APIs are out of scope.

Custodial issuance needs Kora. deploy_token, mint_token, and burn_token are custodial sign-and-send and route through SDP's fee-payment adapter — like transfers, they require FEE_PAYMENT_PROVIDER=kora on a self-hosted SDP (the native adapter cannot submit transactions and returns a typed Sdp::TransferExecutionError). The prepare_* variants build an unsigned transaction and are unaffected.

A Rails engine builds on this client — Wallet-per-User provisioning, transfer persistence, and realtime balance updates — as solrengine-sdp.

See also

  • solrengine-sdp — the Rails engine on top of this client: Wallet-per-User provisioning, tracked transfers, live balance updates.
  • solrengine.org — the SolRengine family: the connect-your-wallet stack, and how both custody models compose.
  • Solana Developer Platform — the SDP itself: the wallets + payments API this gem covers.

Development

bundle install
bundle exec rake test     # minitest + WebMock, no network
bundle exec rubocop

License

MIT. See LICENSE.txt.