solana-sdp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e006d934cc75d32a431afa36112f78edb06d66dce75632eb3d5247b8c491d29b
4
- data.tar.gz: 3766da4b325de50624bb961f60521e07705eb3b70a9115e5be4681152c6083a7
3
+ metadata.gz: 40f9c383ab53b7693e6e15e12e335c0208d8633cfa8a750ec7d6a3084a4756c0
4
+ data.tar.gz: 9ecfe4071cf77cf3b8325e1bd3706dd268cc6066b28133609bf93eb855968a32
5
5
  SHA512:
6
- metadata.gz: e43f33123cccad9386df7844d227861ffa61499d6a873a90629d32273a62e1f7c9b365add4eff823bf504f204e78106d7f3456de4ec823ed8042772912b42e9e
7
- data.tar.gz: 245615dd77504bd7a4d5890e9868ad01b0137f5c58d8aba6efd02c5b20dbedb98e64d2fecb36cfd705989bd13481e81767bf7ffbf9db210abf1a022d4f56d319
6
+ metadata.gz: 82039b60fa7238c66705ea80e82e9c2e885d003e831c802912788332c89add10a8d33a5cfea315d40b4ba5e6d4771ec52040ba9a714ec15bda237eff2cad9c5b
7
+ data.tar.gz: db82971084b1dad62945fc36539b7be3c354d8cd0049ac45905c26cb1823ba5edc2ac16bb5bd682841b80a82837b12d1f74b38f91a6b4f5d15267cd0beaded48
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # solana-sdp
2
2
 
3
- Ruby SDK for the [Solana Developer Platform](https://github.com/solana-foundation/solana-developer-platform) (SDP) wallets and payments API.
3
+ Ruby SDK for the [Solana Developer Platform](https://github.com/solana-foundation/solana-developer-platform) (SDP) wallets, payments, token-issuance, and ramp APIs.
4
4
 
5
5
  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.
6
6
 
7
- > SDP is pre-mainnet, unaudited, and devnet-oriented — so is this gem. Tested against SDP **v0.28** (see [Version pin](#version-pin) below).
7
+ > SDP is pre-mainnet, unaudited, and devnet-oriented — so is this gem. Tested against SDP **v0.31** (see [Version pin](#version-pin) below).
8
8
 
9
9
  ## Install
10
10
 
@@ -67,7 +67,28 @@ end
67
67
 
68
68
  Also available: `prepare_transfer` (build but don't sign/send, for non-custodial flows), `get_transfer`, `list_wallets`.
69
69
 
70
- `list_wallets` returns an **Array** (`[Sdp::Wallet, ...]`) today — SDP does not paginate `/v1/wallets` at v0.28, 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.
70
+ `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.
71
+
72
+ ## Custody providers
73
+
74
+ 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:`:
75
+
76
+ ```ruby
77
+ client = Sdp::Client.new(custody_provider: "privy") # or set SDP_CUSTODY_PROVIDER
78
+ client.custody_provider # => "privy"
79
+ client.create_wallet(label: "user-42") # uses "privy"
80
+ client.create_wallet(label: "x", provider: "turnkey") # per-call override
81
+ ```
82
+
83
+ Provider matrix (v0.2):
84
+
85
+ | Provider | Wallet-per-User | Status |
86
+ |---|---|---|
87
+ | **Privy** (managed) | Yes | **Verified** end-to-end on devnet |
88
+ | Other managed providers (e.g. Turnkey) | Yes (per SDP) | Pass-through — forwarded to SDP, not independently verified here |
89
+ | **Local** custody | **No** | Holds a single root wallet; `create_wallet` raises `Sdp::ProviderCapabilityError` |
90
+
91
+ 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.
71
92
 
72
93
  ## Error taxonomy
73
94
 
@@ -90,7 +111,7 @@ Everything raised by this gem subclasses `Sdp::Error`, which carries `#code`, `#
90
111
  | `Sdp::Unavailable` | Connection refused/reset, connect timeout, or a 5xx that isn't a recognized capability gate | Yes — the request wasn't processed |
91
112
  | `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 |
92
113
 
93
- Two of these encode SDP capability gates that otherwise surface as cryptic generic errors (discriminator strings verified against SDP v0.28, documented in `lib/sdp/errors.rb`):
114
+ 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`):
94
115
 
95
116
  - **`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.
96
117
  - **`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.
@@ -98,7 +119,7 @@ Two of these encode SDP capability gates that otherwise surface as cryptic gener
98
119
  ## Retry posture
99
120
 
100
121
  - **GETs retry exactly once** on `Sdp::Timeout` / `Sdp::Unavailable` (transport-level failures), then raise.
101
- - **POSTs never retry.** SDP has no idempotency key at v0.28: 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.
122
+ - **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.
102
123
  - `Sdp::TransactionFailed` is never retried blindly — it reports an on-chain outcome, not a transport failure.
103
124
  - `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`.
104
125
 
@@ -109,16 +130,25 @@ Wallet-scoped API keys return **404 (not 403)** for wallets outside their scope.
109
130
  ## Version pin
110
131
 
111
132
  ```ruby
112
- Sdp::COMPATIBLE_SDP_VERSION # => "0.28"
133
+ Sdp::COMPATIBLE_SDP_VERSION # => "0.31"
113
134
  ```
114
135
 
115
- 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.28.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`.
136
+ 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`.
116
137
 
117
138
  Running against a different SDP version may work, but field shapes (e.g. `usdValue` on balances) are known to change between minors.
118
139
 
119
140
  ## Scope
120
141
 
121
- This gem covers SDP's wallets and payments surface: custody initialization, wallet provisioning and listing, balances, and transfers (create/prepare/list/get). Ramps, issuance, and the dashboard APIs are out of scope.
142
+ This gem covers SDP's wallets, payments, token-issuance, and ramp surface:
143
+
144
+ - **Wallets** — custody initialization, wallet provisioning and listing, balances.
145
+ - **Payments** — transfers (create / prepare / list / get).
146
+ - **Issuance** (token lifecycle) — tokens (list / get / create / deploy) and supply actions (mint / burn), each action with a `prepare` variant for caller-signed flows.
147
+ - **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.
148
+
149
+ The issuance compliance actions (freeze/unfreeze, pause, authority, allowlist, seize, force-burn) and the dashboard APIs are out of scope.
150
+
151
+ > **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.
122
152
 
123
153
  A Rails engine builds on this client — Wallet-per-User provisioning, transfer persistence, and realtime balance updates — as [solrengine-sdp](https://github.com/solrengine/sdp).
124
154
 
data/lib/sdp/client.rb CHANGED
@@ -4,11 +4,14 @@ require "net/http"
4
4
  require "openssl"
5
5
  require "json"
6
6
  require "uri"
7
+ require "bigdecimal"
7
8
 
8
9
  require_relative "errors"
9
10
  require_relative "pagination"
10
11
  require_relative "resources/wallets"
11
12
  require_relative "resources/payments"
13
+ require_relative "resources/issuance"
14
+ require_relative "resources/ramps"
12
15
 
13
16
  module Sdp
14
17
  # Zero-dependency Net::HTTP request core for the SDP API.
@@ -25,6 +28,8 @@ module Sdp
25
28
  class Client
26
29
  include Resources::Wallets
27
30
  include Resources::Payments
31
+ include Resources::Issuance
32
+ include Resources::Ramps
28
33
 
29
34
  DEFAULT_BASE_URL = "http://127.0.0.1:8787"
30
35
  OPEN_TIMEOUT = 2 # seconds — fail fast when the stack isn't up
@@ -40,10 +45,11 @@ module Sdp
40
45
  # meta (hasMore/page), so the request layer always carries both.
41
46
  Response = Struct.new(:data, :meta, keyword_init: true)
42
47
 
43
- attr_reader :base_url
48
+ attr_reader :base_url, :custody_provider
44
49
 
45
50
  def initialize(base_url: ENV.fetch("SDP_API_BASE_URL", DEFAULT_BASE_URL),
46
51
  api_key: ENV["SDP_API_KEY"],
52
+ custody_provider: ENV["SDP_CUSTODY_PROVIDER"],
47
53
  open_timeout: OPEN_TIMEOUT,
48
54
  read_timeout: READ_TIMEOUT)
49
55
  # Strip first, then guard: an ENV key with a trailing newline passes a
@@ -69,6 +75,13 @@ module Sdp
69
75
  "host, got #{@base_url.inspect}. Pass base_url: or set the SDP_API_BASE_URL environment variable."
70
76
  end
71
77
 
78
+ # The default custody provider for wallet operations, configured once here
79
+ # (or via SDP_CUSTODY_PROVIDER) so callers don't repeat provider: on every
80
+ # create_wallet/initialize_custody. Blank → nil (fall through to SDP's own
81
+ # default). This is what makes the ProviderCapabilityError hint actionable.
82
+ provider = custody_provider.to_s.strip
83
+ @custody_provider = provider.empty? ? nil : provider
84
+
72
85
  @open_timeout = open_timeout
73
86
  @read_timeout = read_timeout
74
87
  end
@@ -225,7 +238,7 @@ module Sdp
225
238
 
226
239
  # FL-10/FL-11: SDP reports custody/fee-payment capability gates as
227
240
  # generic 400/409/502 responses. Discriminators verified against SDP
228
- # v0.28 (pattern constants documented in errors.rb). The upstream
241
+ # v0.31 (pattern constants documented in errors.rb). The upstream
229
242
  # message is preserved and the fix is appended, so logs keep the
230
243
  # original evidence.
231
244
  def capability_gate(klass, status, code, message, path)
@@ -289,5 +302,24 @@ module Sdp
289
302
  def underscore_key(key)
290
303
  key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
291
304
  end
305
+
306
+ # Shared resource helpers — defined once on the HTTP layer the resource
307
+ # modules mix into, rather than duplicated per module.
308
+
309
+ # Percent-encode a caller-supplied id before interpolating it into a path.
310
+ def encode_path_segment(segment)
311
+ URI.encode_uri_component(segment.to_s)
312
+ end
313
+
314
+ # Serialize a money amount to a plain decimal string. Integer/String pass
315
+ # through unchanged (Integer#to_s never uses scientific notation); a Float
316
+ # is routed through BigDecimal so a small value like 1e-07 serializes as
317
+ # "0.0000001" rather than "1.0e-07", which SDP rejects. Prefer passing
318
+ # base-unit amounts as strings — Floats are lossy.
319
+ def amount_string(amount)
320
+ return amount.to_s unless amount.is_a?(Float)
321
+
322
+ BigDecimal(amount.to_s).to_s("F")
323
+ end
292
324
  end
293
325
  end
data/lib/sdp/coverage.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Sdp
4
4
  # The curated SDP surface this gem covers, pinned against the vendored
5
- # OpenAPI spec (spec/openapi-v0.28.json). Consumed by the contract tests
5
+ # OpenAPI spec (spec/openapi-v0.31.json). Consumed by the contract tests
6
6
  # (test/sdp/contract_test.rb) and the sdp:drift rake task.
7
7
  #
8
8
  # Path templates use the {param} style of SDP's generated spec. When a
@@ -21,9 +21,25 @@ module Sdp
21
21
  BALANCE_FIELDS = %w[token mint amount uiAmount decimals usdValue].freeze
22
22
  # Fields read by Sdp::Transfer.from_hash.
23
23
  TRANSFER_FIELDS = %w[id direction status signature token amount source destination memo error createdAt].freeze
24
+ # Fields read by Sdp::Token.from_hash. extensions is passed through untyped.
25
+ TOKEN_FIELDS = %w[id projectId signingWalletId mintAddress mintAuthority freezeAuthority name symbol
26
+ decimals description uri imageUrl template extensions totalSupply maxSupply isMintable
27
+ isFreezable requiresAllowlist status deployedAt createdAt updatedAt].freeze
28
+ # Fields read by Sdp::TokenTransaction.from_hash (the mint/burn action record).
29
+ TOKEN_TX_FIELDS = %w[id tokenId type status signature serializedTx params slot blockTime fee error
30
+ createdAt updatedAt].freeze
31
+ # The unsigned-transaction envelope shared by the .../prepare responses.
32
+ PREPARED_TX_FIELDS = %w[serialized blockhash lastValidBlockHeight].freeze
33
+ # Fields read by Sdp::RampQuote.from_hash. The *Currency members are passed
34
+ # through untyped, so only their presence at data.quote is pinned.
35
+ RAMP_QUOTE_FIELDS = %w[id provider status deliveryMode hostedUrl paymentInstructions exchangeRate
36
+ totalSendingAmount sendingCurrency totalReceivingAmount receivingCurrency
37
+ feesIncluded feeCurrency expiresAt].freeze
38
+ # Fields read by Sdp::RampExecution.from_hash.
39
+ RAMP_EXECUTION_FIELDS = %w[id provider status redirectUrl paymentInstructions reference].freeze
24
40
 
25
41
  COVERED_ENDPOINTS = [
26
- # NOTE: at v0.28 the initialize 201 response has NO data envelope —
42
+ # NOTE: at v0.31 the initialize 201 response has NO data envelope —
27
43
  # configId/publicKey/walletId sit at the schema root.
28
44
  Endpoint.new(method: "post", path: "/v1/wallets/initialize", success_status: "201",
29
45
  reads: { [] => %w[configId publicKey walletId] }),
@@ -43,7 +59,59 @@ module Sdp
43
59
  Endpoint.new(method: "get", path: "/v1/payments/transfers", success_status: "200",
44
60
  reads: { [ "data", "[]" ] => TRANSFER_FIELDS }),
45
61
  Endpoint.new(method: "get", path: "/v1/payments/transfers/{transferId}", success_status: "200",
46
- reads: { %w[data transfer] => TRANSFER_FIELDS })
62
+ reads: { %w[data transfer] => TRANSFER_FIELDS }),
63
+
64
+ # Issuance — token lifecycle + supply actions (v0.2). list returns a bare
65
+ # data array; create/get/deploy wrap the token in data.token. mint/burn
66
+ # return the action record at data.transaction; mint also carries
67
+ # data.tokenAccount. The prepare variants differ: deploy/prepare puts the
68
+ # unsigned tx at data.transaction with a sibling data.mint, while
69
+ # mint/burn prepare keep the record at data.transaction and the unsigned
70
+ # tx at data.preparedTransaction.
71
+ Endpoint.new(method: "get", path: "/v1/issuance/tokens", success_status: "200",
72
+ reads: { [ "data", "[]" ] => TOKEN_FIELDS }),
73
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens", success_status: "201",
74
+ reads: { %w[data token] => TOKEN_FIELDS }),
75
+ Endpoint.new(method: "get", path: "/v1/issuance/tokens/{tokenId}", success_status: "200",
76
+ reads: { %w[data token] => TOKEN_FIELDS }),
77
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/deploy", success_status: "200",
78
+ reads: { %w[data token] => TOKEN_FIELDS }),
79
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/deploy/prepare", success_status: "200",
80
+ reads: { %w[data] => %w[transaction mint simulation],
81
+ %w[data transaction] => PREPARED_TX_FIELDS }),
82
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/mint", success_status: "200",
83
+ reads: { %w[data] => %w[transaction tokenAccount],
84
+ %w[data transaction] => TOKEN_TX_FIELDS }),
85
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/mint/prepare", success_status: "200",
86
+ reads: { %w[data] => %w[transaction preparedTransaction tokenAccount simulation],
87
+ %w[data transaction] => TOKEN_TX_FIELDS,
88
+ %w[data preparedTransaction] => PREPARED_TX_FIELDS }),
89
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/burn", success_status: "200",
90
+ reads: { %w[data transaction] => TOKEN_TX_FIELDS }),
91
+ Endpoint.new(method: "post", path: "/v1/issuance/tokens/{tokenId}/burn/prepare", success_status: "200",
92
+ reads: { %w[data] => %w[transaction preparedTransaction simulation],
93
+ %w[data transaction] => TOKEN_TX_FIELDS,
94
+ %w[data preparedTransaction] => PREPARED_TX_FIELDS }),
95
+
96
+ # Ramps (v0.2, sandbox-only). currency endpoints return nested discovery
97
+ # data; quote wraps the record in data.quote, execute in data.ramp; the
98
+ # sandbox hook returns data.transaction (untyped passthrough).
99
+ Endpoint.new(method: "get", path: "/v1/payments/ramps/onramp/currency", success_status: "200",
100
+ reads: { %w[data] => %w[currencies pairs supportHash],
101
+ %w[data currencies] => %w[sources destinations],
102
+ [ "data", "pairs", "[]" ] => %w[source dest providers] }),
103
+ Endpoint.new(method: "get", path: "/v1/payments/ramps/offramp/currency", success_status: "200",
104
+ reads: { %w[data] => %w[currencies pairs supportHash],
105
+ %w[data currencies] => %w[sources destinations],
106
+ [ "data", "pairs", "[]" ] => %w[source dest providers] }),
107
+ Endpoint.new(method: "post", path: "/v1/payments/ramps/onramp/quote", success_status: "200",
108
+ reads: { %w[data quote] => RAMP_QUOTE_FIELDS }),
109
+ Endpoint.new(method: "post", path: "/v1/payments/ramps/onramp/execute", success_status: "200",
110
+ reads: { %w[data ramp] => RAMP_EXECUTION_FIELDS }),
111
+ Endpoint.new(method: "post", path: "/v1/payments/ramps/offramp/execute", success_status: "200",
112
+ reads: { %w[data ramp] => RAMP_EXECUTION_FIELDS }),
113
+ Endpoint.new(method: "post", path: "/v1/payments/ramps/sandbox/simulate", success_status: "200",
114
+ reads: { %w[data] => %w[transaction] })
47
115
  ].freeze
48
116
 
49
117
  class << self
@@ -69,7 +137,7 @@ module Sdp
69
137
 
70
138
  # Follows "$ref": "#/components/schemas/X" pointers (recursively, with
71
139
  # a depth guard). SDP's generated spec is fully inlined today (zero
72
- # $refs at v0.28) — this keeps the guard working if that changes.
140
+ # $refs at v0.31) — this keeps the guard working if that changes.
73
141
  def resolve(spec, node, depth = 0)
74
142
  return node unless node.is_a?(Hash) && node["$ref"].is_a?(String)
75
143
  return nil if depth > 10
data/lib/sdp/errors.rb CHANGED
@@ -38,7 +38,7 @@ module Sdp
38
38
  # Subclasses BadRequest so existing rescues keep working; never retryable —
39
39
  # the same request fails until the provider configuration changes.
40
40
  class ProviderCapabilityError < BadRequest
41
- # Verified against SDP v0.28: assertCustodyProviderCanCreateWallet
41
+ # Verified against SDP v0.31: assertCustodyProviderCanCreateWallet
42
42
  # (apps/sdp-api/src/services/custody-provider-lifecycle.service.ts) and
43
43
  # createProviderWallet (services/domain/signing/provider-wallet-lifecycle.ts)
44
44
  # both throw:
@@ -54,7 +54,7 @@ module Sdp
54
54
  # mislabeling an RPC outage as "configure Kora" would be worse than a
55
55
  # generic error.
56
56
  class TransferExecutionError < Error
57
- # Verified against SDP v0.28: NativeAdapter#signAndSend
57
+ # Verified against SDP v0.31: NativeAdapter#signAndSend
58
58
  # (apps/sdp-api/src/services/adapters/fee-payment/native/native.adapter.ts)
59
59
  # throws:
60
60
  # "NativeAdapter.signAndSend not supported - use KoraAdapter for gasless transactions"
@@ -16,12 +16,12 @@ module Sdp
16
16
  # :pageSize is clamped client-side to MAX_PAGE_SIZE — SDP rejects larger
17
17
  # values, and a clamped request is more useful than a guaranteed 400.
18
18
  #
19
- # Non-paginated list endpoints (GET /v1/wallets at v0.28) flow through the
19
+ # Non-paginated list endpoints (GET /v1/wallets at v0.31) flow through the
20
20
  # same helper: their meta carries no hasMore, so enumeration stops after a
21
21
  # single fetch — and they pick up auto-pagination for free if SDP paginates
22
22
  # them in a later version.
23
23
  module Pagination
24
- MAX_PAGE_SIZE = 100 # SDP's pageSize ceiling (v0.28)
24
+ MAX_PAGE_SIZE = 100 # SDP's pageSize ceiling (v0.31)
25
25
  MAX_PAGES = 10_000 # defensive ceiling; empty-page + hasMore guards are the primary stop
26
26
 
27
27
  # client — anything exposing #get(path, query:) → Client::Response
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Sdp
6
+ # A token as SDP's issuance API reports it. Like every SDP resource, optional
7
+ # fields are OMITTED (absent, not null), so each member is nil when SDP didn't
8
+ # send it. extensions is passed through as the raw snake_cased hash — the
9
+ # Token-2022 extension set is large and provider-shaped, so this gem surfaces
10
+ # it untyped rather than modelling it.
11
+ Token = Struct.new(:id, :project_id, :signing_wallet_id, :mint_address,
12
+ :mint_authority, :freeze_authority, :name, :symbol, :decimals,
13
+ :description, :uri, :image_url, :template, :extensions,
14
+ :total_supply, :max_supply, :mintable, :freezable,
15
+ :requires_allowlist, :status, :deployed_at, :created_at, :updated_at,
16
+ keyword_init: true) do
17
+ def self.from_hash(hash)
18
+ hash ||= {}
19
+ new(
20
+ id: hash[:id],
21
+ project_id: hash[:project_id],
22
+ signing_wallet_id: hash[:signing_wallet_id],
23
+ mint_address: hash[:mint_address],
24
+ mint_authority: hash[:mint_authority],
25
+ freeze_authority: hash[:freeze_authority],
26
+ name: hash[:name],
27
+ symbol: hash[:symbol],
28
+ decimals: hash[:decimals],
29
+ description: hash[:description],
30
+ uri: hash[:uri],
31
+ image_url: hash[:image_url],
32
+ template: hash[:template],
33
+ extensions: hash[:extensions],
34
+ total_supply: hash[:total_supply],
35
+ max_supply: hash[:max_supply],
36
+ mintable: hash[:is_mintable],
37
+ freezable: hash[:is_freezable],
38
+ requires_allowlist: hash[:requires_allowlist],
39
+ status: hash[:status],
40
+ deployed_at: hash[:deployed_at],
41
+ created_at: hash[:created_at],
42
+ updated_at: hash[:updated_at]
43
+ )
44
+ end
45
+ end
46
+
47
+ # An issuance transaction — the action record SDP returns for mint/burn —
48
+ # distinct from a payments Transfer (different shape, different endpoints).
49
+ # token_account is the associated token account SDP returns alongside a mint
50
+ # (a sibling of the transaction in the envelope, folded in here); nil when SDP
51
+ # didn't send one (e.g. burn).
52
+ TokenTransaction = Struct.new(:id, :token_id, :type, :status, :signature,
53
+ :serialized_tx, :params, :slot, :block_time, :fee,
54
+ :error, :token_account, :created_at, :updated_at,
55
+ keyword_init: true) do
56
+ def self.from_hash(hash, token_account: nil)
57
+ hash ||= {}
58
+ new(
59
+ id: hash[:id],
60
+ token_id: hash[:token_id],
61
+ type: hash[:type],
62
+ status: hash[:status],
63
+ signature: hash[:signature],
64
+ serialized_tx: hash[:serialized_tx],
65
+ params: hash[:params],
66
+ slot: hash[:slot],
67
+ block_time: hash[:block_time],
68
+ fee: hash[:fee],
69
+ error: hash[:error],
70
+ token_account: token_account,
71
+ created_at: hash[:created_at],
72
+ updated_at: hash[:updated_at]
73
+ )
74
+ end
75
+ end
76
+
77
+ # Result of a .../prepare issuance call: the unsigned transaction the caller
78
+ # must sign and submit before blockhash expiry, plus context. SDP nests this
79
+ # two different ways and from_action/from_deploy normalize both:
80
+ #
81
+ # - mint/burn prepare → the provisional record under :transaction, the
82
+ # unsigned tx under :prepared_transaction (transaction is a TokenTransaction).
83
+ # - deploy prepare → :transaction IS the unsigned tx envelope and the new
84
+ # mint address rides alongside under :mint (transaction is nil — there is no
85
+ # action record yet).
86
+ # The mint/burn associated token account lives on #transaction (TokenTransaction)
87
+ # when SDP returns one; deploy carries no action record. There is deliberately
88
+ # no top-level token_account — it would only ever duplicate transaction.token_account.
89
+ PreparedTokenTransaction = Struct.new(:transaction, :serialized, :blockhash,
90
+ :last_valid_block_height, :mint, :simulation,
91
+ keyword_init: true) do
92
+ def self.from_action(hash)
93
+ hash ||= {}
94
+ prepared = hash[:prepared_transaction] || {}
95
+ new(
96
+ transaction: TokenTransaction.from_hash(hash[:transaction], token_account: hash[:token_account]),
97
+ serialized: prepared[:serialized],
98
+ blockhash: prepared[:blockhash],
99
+ last_valid_block_height: prepared[:last_valid_block_height],
100
+ mint: nil,
101
+ simulation: hash[:simulation]
102
+ )
103
+ end
104
+
105
+ def self.from_deploy(hash)
106
+ hash ||= {}
107
+ tx = hash[:transaction] || {}
108
+ new(
109
+ transaction: nil,
110
+ serialized: tx[:serialized],
111
+ blockhash: tx[:blockhash],
112
+ last_valid_block_height: tx[:last_valid_block_height],
113
+ mint: hash[:mint],
114
+ simulation: hash[:simulation]
115
+ )
116
+ end
117
+ end
118
+
119
+ module Resources
120
+ # Token issuance: the core lifecycle (list / get / create / deploy) and the
121
+ # supply actions (mint / burn), each action with a prepare variant for
122
+ # caller-signed (non-custodial) flows. Mint/burn/deploy are money-path and
123
+ # follow the same never-retry-on-write posture as transfers.
124
+ #
125
+ # Out of scope at v0.2: the compliance actions (freeze/unfreeze, pause,
126
+ # authority, allowlist, seize, force-burn) — they roughly double the surface
127
+ # and target stablecoin issuers, not the general dev-tool path.
128
+ module Issuance
129
+ # GET /v1/issuance/tokens → Enumerator yielding Sdp::Token.
130
+ # Auto-paginates on meta.hasMore (see Pagination); re-sends filters per
131
+ # page. Filters (camelCased on the wire): status:, page:, page_size:.
132
+ # (SDP v0.31 documents no other query filters on this endpoint.)
133
+ def list_tokens(status: nil, page: nil, page_size: nil)
134
+ query = { status: status, page: page, pageSize: page_size }.compact
135
+ Pagination.enumerate(self, "/v1/issuance/tokens", query) do |response|
136
+ rows = response.data.is_a?(Array) ? response.data : []
137
+ rows.map { |token| Token.from_hash(token) }
138
+ end
139
+ end
140
+
141
+ # GET /v1/issuance/tokens/:id → Sdp::Token
142
+ def get_token(token_id)
143
+ Token.from_hash(token_node(get("/v1/issuance/tokens/#{encode_path_segment(token_id)}")))
144
+ end
145
+
146
+ # POST /v1/issuance/tokens → Sdp::Token.
147
+ # Registers the token record; it is NOT on-chain until #deploy_token.
148
+ # Never retried (write). maxSupply is a DECIMAL token-amount string (whole
149
+ # tokens, e.g. "1000000" = one million tokens) — SDP applies the token's
150
+ # decimals, same convention as transfer amounts. NOT base units.
151
+ def create_token(name:, symbol:, signing_wallet_id:, decimals: nil, max_supply: nil,
152
+ description: nil, uri: nil, image_url: nil, template: nil,
153
+ mintable: nil, freezable: nil, requires_allowlist: nil)
154
+ payload = {
155
+ name: name, symbol: symbol, signingWalletId: signing_wallet_id,
156
+ decimals: decimals, maxSupply: max_supply, description: description,
157
+ uri: uri, imageUrl: image_url, template: template,
158
+ isMintable: mintable, isFreezable: freezable, requiresAllowlist: requires_allowlist
159
+ }.compact
160
+ Token.from_hash(token_node(post("/v1/issuance/tokens", payload)))
161
+ end
162
+
163
+ # POST /v1/issuance/tokens/:id/deploy → Sdp::Token (now on-chain).
164
+ # Custodial sign-and-send; never retried.
165
+ def deploy_token(token_id)
166
+ Token.from_hash(token_node(post(deploy_path(token_id))))
167
+ end
168
+
169
+ # POST /v1/issuance/tokens/:id/deploy/prepare → Sdp::PreparedTokenTransaction
170
+ # Builds — does not send — the deploy tx for caller-signed flows; the new
171
+ # mint address is on #mint.
172
+ def prepare_deploy_token(token_id)
173
+ PreparedTokenTransaction.from_deploy(post("#{deploy_path(token_id)}/prepare").data)
174
+ end
175
+
176
+ # POST /v1/issuance/tokens/:id/mint → Sdp::TokenTransaction.
177
+ # Custodial sign-and-send mint to destination; never retried. amount is a
178
+ # DECIMAL token-amount string (whole tokens, e.g. "1.5") — SDP scales it by
179
+ # the token's decimals, NOT base units. The associated token account is on
180
+ # #token_account.
181
+ # (Noun-suffixed to match create_token/deploy_token and to leave room for a
182
+ # future #freeze_token without colliding with Ruby's Object#freeze.)
183
+ def mint_token(token_id, signing_wallet_id:, destination:, amount:, memo: nil)
184
+ action_result(post(mint_path(token_id), mint_payload(signing_wallet_id, destination, amount, memo)))
185
+ end
186
+
187
+ # POST /v1/issuance/tokens/:id/mint/prepare → Sdp::PreparedTokenTransaction
188
+ # Builds — does not sign or send — the mint tx for caller-signed flows.
189
+ def prepare_mint_token(token_id, signing_wallet_id:, destination:, amount:, memo: nil)
190
+ PreparedTokenTransaction.from_action(
191
+ post("#{mint_path(token_id)}/prepare", mint_payload(signing_wallet_id, destination, amount, memo)).data
192
+ )
193
+ end
194
+
195
+ # POST /v1/issuance/tokens/:id/burn → Sdp::TokenTransaction.
196
+ # Custodial sign-and-send burn from source; never retried. amount is a
197
+ # DECIMAL token-amount string, same as #mint_token (NOT base units).
198
+ def burn_token(token_id, signing_wallet_id:, source:, amount:, memo: nil)
199
+ action_result(post(burn_path(token_id), burn_payload(signing_wallet_id, source, amount, memo)))
200
+ end
201
+
202
+ # POST /v1/issuance/tokens/:id/burn/prepare → Sdp::PreparedTokenTransaction
203
+ # Builds — does not sign or send — the burn tx for caller-signed flows.
204
+ def prepare_burn_token(token_id, signing_wallet_id:, source:, amount:, memo: nil)
205
+ PreparedTokenTransaction.from_action(
206
+ post("#{burn_path(token_id)}/prepare", burn_payload(signing_wallet_id, source, amount, memo)).data
207
+ )
208
+ end
209
+
210
+ private
211
+
212
+ # create/get/deploy wrap the token in a data.token envelope; stay tolerant
213
+ # of a bare token (or empty body → {}) so the struct degrades to all-nil.
214
+ def token_node(response)
215
+ data = response.data
216
+ data.is_a?(Hash) ? (data[:token] || data) : data
217
+ end
218
+
219
+ # mint/burn wrap the action record in data.transaction (mint also carries a
220
+ # sibling data.tokenAccount). Guard the envelope to a Hash so a money-path
221
+ # response that comes back off-shape or empty degrades to an all-nil struct
222
+ # rather than raising a raw TypeError mid-reconcile.
223
+ def action_result(response)
224
+ data = response.data
225
+ data = {} unless data.is_a?(Hash)
226
+ TokenTransaction.from_hash(data[:transaction], token_account: data[:token_account])
227
+ end
228
+
229
+ def mint_payload(signing_wallet_id, destination, amount, memo)
230
+ mint = { destination: destination, amount: amount_string(amount) }
231
+ mint[:memo] = memo if memo
232
+ { signingWalletId: signing_wallet_id, mint: mint }
233
+ end
234
+
235
+ def burn_payload(signing_wallet_id, source, amount, memo)
236
+ burn = { source: source, amount: amount_string(amount) }
237
+ burn[:memo] = memo if memo
238
+ { signingWalletId: signing_wallet_id, burn: burn }
239
+ end
240
+
241
+ def deploy_path(token_id)
242
+ "/v1/issuance/tokens/#{encode_path_segment(token_id)}/deploy"
243
+ end
244
+
245
+ def mint_path(token_id)
246
+ "/v1/issuance/tokens/#{encode_path_segment(token_id)}/mint"
247
+ end
248
+
249
+ def burn_path(token_id)
250
+ "/v1/issuance/tokens/#{encode_path_segment(token_id)}/burn"
251
+ end
252
+ end
253
+ end
254
+ end
@@ -115,10 +115,6 @@ module Sdp
115
115
  payload[:memo] = memo if memo
116
116
  payload
117
117
  end
118
-
119
- def encode_path_segment(segment)
120
- URI.encode_uri_component(segment.to_s)
121
- end
122
118
  end
123
119
  end
124
120
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sdp
4
+ # Currency discovery for a ramp direction: which fiat/crypto SDP can move
5
+ # between and which providers serve each pair. Passed through close to how SDP
6
+ # reports it — pairs is an array of { source, dest, providers } hashes.
7
+ RampCurrencies = Struct.new(:sources, :destinations, :pairs, :support_hash, keyword_init: true) do
8
+ def self.from_hash(hash)
9
+ hash ||= {}
10
+ currencies = hash[:currencies] || {}
11
+ new(
12
+ sources: currencies[:sources] || [],
13
+ destinations: currencies[:destinations] || [],
14
+ pairs: hash[:pairs] || [],
15
+ support_hash: hash[:support_hash]
16
+ )
17
+ end
18
+ end
19
+
20
+ # A ramp quote: indicative pricing plus a hosted checkout URL for an on-ramp.
21
+ # SDP omits optional fields, so each member is nil when absent. The *_currency
22
+ # members are passed through as { code, decimals, name, symbol } hashes;
23
+ # amounts are numbers as SDP sends them.
24
+ RampQuote = Struct.new(:id, :provider, :status, :delivery_mode, :hosted_url, :payment_instructions,
25
+ :exchange_rate, :total_sending_amount, :sending_currency,
26
+ :total_receiving_amount, :receiving_currency, :fees_included, :fee_currency,
27
+ :expires_at, keyword_init: true) do
28
+ def self.from_hash(hash)
29
+ hash ||= {}
30
+ new(
31
+ id: hash[:id],
32
+ provider: hash[:provider],
33
+ status: hash[:status],
34
+ delivery_mode: hash[:delivery_mode],
35
+ hosted_url: hash[:hosted_url],
36
+ payment_instructions: hash[:payment_instructions],
37
+ exchange_rate: hash[:exchange_rate],
38
+ total_sending_amount: hash[:total_sending_amount],
39
+ sending_currency: hash[:sending_currency],
40
+ total_receiving_amount: hash[:total_receiving_amount],
41
+ receiving_currency: hash[:receiving_currency],
42
+ fees_included: hash[:fees_included],
43
+ fee_currency: hash[:fee_currency],
44
+ expires_at: hash[:expires_at]
45
+ )
46
+ end
47
+ end
48
+
49
+ # The result of executing a ramp: SDP's ramp record with the redirect/checkout
50
+ # URL and a provider reference for reconciliation.
51
+ RampExecution = Struct.new(:id, :provider, :status, :redirect_url, :payment_instructions, :reference,
52
+ keyword_init: true) do
53
+ def self.from_hash(hash)
54
+ hash ||= {}
55
+ new(
56
+ id: hash[:id],
57
+ provider: hash[:provider],
58
+ status: hash[:status],
59
+ redirect_url: hash[:redirect_url],
60
+ payment_instructions: hash[:payment_instructions],
61
+ reference: hash[:reference]
62
+ )
63
+ end
64
+ end
65
+
66
+ module Resources
67
+ # Fiat on/off-ramps (SDP payments/ramps).
68
+ #
69
+ # SANDBOX-ONLY at v0.2: wired against SDP's ramp surface and the sandbox
70
+ # simulate hook, NOT verified against live fiat rails. The execute calls are
71
+ # POSTs and follow the same never-retry-on-write posture as transfers (a ramp
72
+ # moves money). #simulate_ramp drives a sandbox ramp to a terminal state.
73
+ #
74
+ # The execute/quote requests carry a large provider KYC/compliance payload
75
+ # (SDP's bvnkCompliance). Rather than model that nested blob, the core fields
76
+ # are keyword args and the compliance object is passed through via compliance:.
77
+ module Ramps
78
+ # GET /v1/payments/ramps/onramp/currency → Sdp::RampCurrencies
79
+ # Filters (camelCased on the wire): source:, dest:, provider:.
80
+ def onramp_currencies(source: nil, dest: nil, provider: nil)
81
+ RampCurrencies.from_hash(ramp_currencies("onramp", source, dest, provider))
82
+ end
83
+
84
+ # GET /v1/payments/ramps/offramp/currency → Sdp::RampCurrencies
85
+ def offramp_currencies(source: nil, dest: nil, provider: nil)
86
+ RampCurrencies.from_hash(ramp_currencies("offramp", source, dest, provider))
87
+ end
88
+
89
+ # POST /v1/payments/ramps/onramp/quote → Sdp::RampQuote.
90
+ # Indicative pricing for a fiat→crypto on-ramp. Never retried (write).
91
+ def onramp_quote(provider:, counterparty_id:, destination_wallet:, crypto_token:,
92
+ fiat_currency:, fiat_amount:, redirect_url: nil, collected_data: nil)
93
+ payload = {
94
+ provider: provider, counterpartyId: counterparty_id, destinationWallet: destination_wallet,
95
+ cryptoToken: crypto_token, fiatCurrency: fiat_currency, fiatAmount: amount_string(fiat_amount),
96
+ redirectUrl: redirect_url, collectedData: collected_data
97
+ }.compact
98
+ RampQuote.from_hash(ramp_record(post("/v1/payments/ramps/onramp/quote", payload).data, :quote))
99
+ end
100
+
101
+ # POST /v1/payments/ramps/onramp/execute → Sdp::RampExecution.
102
+ # Custodial money movement; never retried. compliance: maps to SDP's
103
+ # bvnkCompliance payload.
104
+ def onramp_execute(provider:, counterparty_id:, destination_wallet:, crypto_token:,
105
+ fiat_currency:, fiat_amount:, kyc_reference: nil, redirect_url: nil, compliance: nil)
106
+ payload = {
107
+ provider: provider, counterpartyId: counterparty_id, destinationWallet: destination_wallet,
108
+ cryptoToken: crypto_token, fiatCurrency: fiat_currency, fiatAmount: amount_string(fiat_amount),
109
+ kycReference: kyc_reference, redirectUrl: redirect_url, bvnkCompliance: compliance
110
+ }.compact
111
+ RampExecution.from_hash(ramp_record(post("/v1/payments/ramps/onramp/execute", payload).data, :ramp))
112
+ end
113
+
114
+ # POST /v1/payments/ramps/offramp/execute → Sdp::RampExecution.
115
+ # Custodial money movement (crypto→fiat); never retried.
116
+ def offramp_execute(provider:, counterparty_id:, source_wallet:, crypto_token:,
117
+ fiat_currency:, crypto_amount:, kyc_reference: nil, redirect_url: nil, compliance: nil)
118
+ payload = {
119
+ provider: provider, counterpartyId: counterparty_id, sourceWallet: source_wallet,
120
+ cryptoToken: crypto_token, fiatCurrency: fiat_currency, cryptoAmount: amount_string(crypto_amount),
121
+ kycReference: kyc_reference, redirectUrl: redirect_url, bvnkCompliance: compliance
122
+ }.compact
123
+ RampExecution.from_hash(ramp_record(post("/v1/payments/ramps/offramp/execute", payload).data, :ramp))
124
+ end
125
+
126
+ # POST /v1/payments/ramps/sandbox/simulate → the simulated transaction (Hash passthrough, nil if absent).
127
+ # Sandbox-only test hook: advances a sandbox ramp to a terminal state.
128
+ # Keyword args are forwarded verbatim (no camelCase conversion) — SDP
129
+ # leaves the body provider-shaped, so pass the keys SDP expects.
130
+ def simulate_ramp(**payload)
131
+ data = post("/v1/payments/ramps/sandbox/simulate", payload).data
132
+ data.is_a?(Hash) ? data[:transaction] : nil
133
+ end
134
+
135
+ private
136
+
137
+ def ramp_currencies(direction, source, dest, provider)
138
+ query = { source: source, dest: dest, provider: provider }.compact
139
+ get("/v1/payments/ramps/#{direction}/currency", query: query).data
140
+ end
141
+
142
+ # quote/execute wrap the record in data.quote / data.ramp; stay tolerant of
143
+ # a bare record (or empty body → {}) so the struct degrades to all-nil.
144
+ def ramp_record(data, key)
145
+ data.is_a?(Hash) ? (data[key] || data) : {}
146
+ end
147
+ end
148
+ end
149
+ end
@@ -47,17 +47,22 @@ module Sdp
47
47
  module Wallets
48
48
  # POST /v1/wallets/initialize — one-time project custody setup.
49
49
  # Both fields are optional; the request is sent without a body when
50
- # neither is given. Returns the snake_cased data hash exactly as SDP
51
- # sent it (custody config + root wallet fields incl. :public_key)
52
- # kept tolerant because the shape varies by custody provider.
50
+ # neither is given. provider falls back to the client's configured
51
+ # custody_provider (SDP_CUSTODY_PROVIDER) when not passed. Returns the
52
+ # snake_cased data hash exactly as SDP sent it (custody config + root
53
+ # wallet fields incl. :public_key) — kept tolerant because the shape
54
+ # varies by custody provider.
53
55
  def initialize_custody(provider: nil, wallet_label: nil)
54
- payload = { provider: provider, walletLabel: wallet_label }.compact
56
+ payload = { provider: provider || custody_provider, walletLabel: wallet_label }.compact
55
57
  post("/v1/wallets/initialize", payload.empty? ? nil : payload).data
56
58
  end
57
59
 
58
60
  # POST /v1/wallets → Sdp::Wallet. Wallet#id is SDP's walletId.
61
+ # provider falls back to the client's configured custody_provider. A
62
+ # managed provider (e.g. privy) is required for Wallet-per-User — local
63
+ # custody holds a single root wallet and raises Sdp::ProviderCapabilityError.
59
64
  def create_wallet(label:, provider: nil)
60
- response = post("/v1/wallets", { label: label, provider: provider }.compact)
65
+ response = post("/v1/wallets", { label: label, provider: provider || custody_provider }.compact)
61
66
  data = response.data
62
67
  src = data.is_a?(Hash) ? (data[:wallet] || data) : data
63
68
  Wallet.from_hash(src)
@@ -65,11 +70,12 @@ module Sdp
65
70
 
66
71
  # GET /v1/wallets → [Sdp::Wallet, ...]
67
72
  # Filters (camelCased on the wire): provider:, project_id:,
68
- # include_balances:. Not paginated at v0.28 — the whole list comes back
73
+ # include_balances:. Not paginated at v0.31 — the whole list comes back
69
74
  # in one response — but routed through Pagination.enumerate so a
70
75
  # paginated upstream upgrade is a one-line change here.
71
76
  def list_wallets(provider: nil, project_id: nil, include_balances: nil)
72
- query = { provider: provider, projectId: project_id, includeBalances: include_balances }.compact
77
+ query = { provider: provider || custody_provider, projectId: project_id,
78
+ includeBalances: include_balances }.compact
73
79
  Pagination.enumerate(self, "/v1/wallets", query) do |response|
74
80
  rows =
75
81
  case response.data
@@ -89,12 +95,6 @@ module Sdp
89
95
  container = data.is_a?(Hash) ? (data[:wallet_balances] || data) : {}
90
96
  (container[:balances] || []).map { |balance| Balance.from_hash(balance) }
91
97
  end
92
-
93
- private
94
-
95
- def encode_path_segment(segment)
96
- URI.encode_uri_component(segment.to_s)
97
- end
98
98
  end
99
99
  end
100
100
  end
data/lib/sdp/version.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sdp
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  # The SDP release this gem version is tested against. SDP breaks its API
7
7
  # between minors, so every gem release names its verified upstream version.
8
- COMPATIBLE_SDP_VERSION = "0.28"
8
+ COMPATIBLE_SDP_VERSION = "0.31"
9
9
  end
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-sdp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Ferrer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-06-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: 'Zero-dependency Net::HTTP client for SDP''s wallets and payments API:
14
- typed errors, envelope unwrapping, and a safe retry posture.'
13
+ description: 'Zero-dependency Net::HTTP client for SDP''s wallets, payments, token-issuance,
14
+ and ramp APIs: typed errors, envelope unwrapping, and a safe retry posture.'
15
15
  email:
16
16
  - estoy@moviendo.me
17
17
  executables: []
@@ -26,7 +26,9 @@ files:
26
26
  - lib/sdp/drift.rb
27
27
  - lib/sdp/errors.rb
28
28
  - lib/sdp/pagination.rb
29
+ - lib/sdp/resources/issuance.rb
29
30
  - lib/sdp/resources/payments.rb
31
+ - lib/sdp/resources/ramps.rb
30
32
  - lib/sdp/resources/wallets.rb
31
33
  - lib/sdp/version.rb
32
34
  - lib/solana-sdp.rb