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 +4 -4
- data/README.md +38 -8
- data/lib/sdp/client.rb +34 -2
- data/lib/sdp/coverage.rb +72 -4
- data/lib/sdp/errors.rb +2 -2
- data/lib/sdp/pagination.rb +2 -2
- data/lib/sdp/resources/issuance.rb +254 -0
- data/lib/sdp/resources/payments.rb +0 -4
- data/lib/sdp/resources/ramps.rb +149 -0
- data/lib/sdp/resources/wallets.rb +13 -13
- data/lib/sdp/version.rb +2 -2
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40f9c383ab53b7693e6e15e12e335c0208d8633cfa8a750ec7d6a3084a4756c0
|
|
4
|
+
data.tar.gz: 9ecfe4071cf77cf3b8325e1bd3706dd268cc6066b28133609bf93eb855968a32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
data/lib/sdp/pagination.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
@@ -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.
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-06-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
|
-
description: 'Zero-dependency Net::HTTP client for SDP''s wallets
|
|
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
|