solrengine-sdp 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/app/jobs/solrengine/sdp/provision_wallet_job.rb +125 -0
- data/app/jobs/solrengine/sdp/track_transfer_job.rb +161 -0
- data/app/models/solrengine/sdp/transfer.rb +229 -0
- data/lib/generators/solrengine/sdp/install_generator.rb +225 -0
- data/lib/generators/solrengine/sdp/templates/add_solrengine_sdp_to_users.rb +13 -0
- data/lib/generators/solrengine/sdp/templates/cable.yml +23 -0
- data/lib/generators/solrengine/sdp/templates/create_solrengine_sdp_transfers.rb +27 -0
- data/lib/generators/solrengine/sdp/templates/initializer.rb +44 -0
- data/lib/generators/solrengine/sdp/templates/sdp_watcher +134 -0
- data/lib/solrengine/sdp/broadcaster.rb +163 -0
- data/lib/solrengine/sdp/configuration.rb +107 -0
- data/lib/solrengine/sdp/engine.rb +21 -0
- data/lib/solrengine/sdp/errors.rb +20 -0
- data/lib/solrengine/sdp/faucet.rb +115 -0
- data/lib/solrengine/sdp/version.rb +11 -0
- data/lib/solrengine/sdp/wallet_owner.rb +114 -0
- data/lib/solrengine/sdp.rb +128 -0
- data/lib/solrengine-sdp.rb +3 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bb2f00e8f363ed1bacd78cf67bee25acbf50d1dd488b87fa00f8837512dd354b
|
|
4
|
+
data.tar.gz: 3ba656e022c2dfa6a866345d368e92767d085fa711b813cffa8035a3269c3c2b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2ea6942f560cf9bc6fe428fb8a86d60c2658ce01466ca48937454b48639de37477478d76662caae635f9b038176df739f850a9a78895f78513dce487228254b1
|
|
7
|
+
data.tar.gz: ee990fd2117b0acd96da2cd54953de35b215a7aa16459585e3b1e58b58f52ce3ecaae4417e075dc7195b18e36d607a05b982d67c05cf36ed726ca76b0195b6b8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Ferrer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# solrengine-sdp
|
|
2
|
+
|
|
3
|
+
Rails engine for **Wallet-per-User** custodial Solana wallets backed by the [Solana Developer Platform (SDP)](https://github.com/solana-foundation/solana-developer-platform). Your users sign up with an email — the engine provisions an SDP custody wallet for each of them, persists and tracks every transfer to a renderable terminal state, and pushes live balance updates to the browser when money moves on chain.
|
|
4
|
+
|
|
5
|
+
It composes the SolRengine family: the [solana-sdp](https://github.com/solrengine/solana-sdp) API client underneath, [solrengine-realtime](https://github.com/solrengine/realtime) for WebSocket account subscriptions, and (optionally) [solrengine-tokens](https://github.com/solrengine/tokens) as a USD price source. This is the "you hold wallets for your users" path; for "your users bring their own wallets", see the rest of the family at [solrengine.org](https://solrengine.org).
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
Honest list — SDP is pre-mainnet and devnet-oriented, and Wallet-per-User has real infrastructure requirements:
|
|
10
|
+
|
|
11
|
+
| You need | Why | Without it |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| A running SDP instance (self-hosted dev stack or managed) | The engine talks to SDP's wallets + payments API | Nothing works; boot check fails on a missing key |
|
|
14
|
+
| A **managed custody provider** (e.g. [Privy](https://privy.io)) configured in SDP | Per-user wallet provisioning | Local custody holds a **single root wallet** and rejects `POST /v1/wallets` (`Sdp::ProviderCapabilityError`) |
|
|
15
|
+
| **Kora** as SDP's fee-payment provider (`FEE_PAYMENT_PROVIDER=kora`) | Transfer execution | The native adapter can build and sign transfers but **cannot submit them** (`Sdp::TransferExecutionError`) |
|
|
16
|
+
| An SDP API key with `custody:admin`, `wallets:*`, and `payments:*` scopes | Custody init, provisioning, balances, transfers | 403 `Sdp::InsufficientPermissions` |
|
|
17
|
+
| A non-`async` Action Cable adapter in development | The watcher broadcasts from its own process | The install generator handles this — see [Cable adapter](#cable-adapter) |
|
|
18
|
+
| A Helius-class RPC endpoint (for SPL token balances) | Public devnet RPC lacks the indexing SDP uses for token balances | SOL still works; SPL balance rows may be missing |
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
From zero to a confirmed transfer updating the screen live:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
rails new mywallet
|
|
26
|
+
cd mywallet
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Add to the `Gemfile`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "solrengine-sdp"
|
|
33
|
+
gem "dotenv-rails", groups: [ :development, :test ] # or load .env your own way
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bundle install
|
|
40
|
+
bin/rails generate solrengine:sdp:install
|
|
41
|
+
bin/rails db:migrate
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The generator created migrations, `config/initializers/solrengine_sdp.rb`, `bin/sdp_watcher`, a `Procfile.dev` entry, `.env` keys, and switched development Action Cable to Solid Cable (follow its printed instructions for the `solid_cable_messages` table). Fill in `.env`:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
SDP_API_KEY=sk_... # custody:admin + wallets:* + payments:* scopes
|
|
48
|
+
SDP_API_BASE_URL=http://127.0.0.1:8787 # your SDP instance
|
|
49
|
+
SDP_CUSTODY_PROVIDER=privy # managed provider — see Prerequisites
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Opt in to provisioning on signup — uncomment in `app/models/user.rb`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
after_create_commit :provision_wallet!
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run everything (web + watcher):
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bin/dev
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Sign up a user — `provision_wallet!` drives `pending → provisioning → ready` and fills `wallet_address`. Fund it from the devnet faucet and move money:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
user = User.last
|
|
68
|
+
user.wallet_ready? # => true
|
|
69
|
+
|
|
70
|
+
# Devnet-only faucet (1 SOL). One attempt, never retried.
|
|
71
|
+
Solrengine::Sdp::Faucet.new.request_airdrop(user.wallet_address, 1_000_000_000)
|
|
72
|
+
|
|
73
|
+
# Persisted, tracked transfer — the returned row is what you render.
|
|
74
|
+
transfer = Solrengine::Sdp::Transfer.execute!(
|
|
75
|
+
source: user.sdp_wallet_id,
|
|
76
|
+
destination: "RecipientPublicKeyBase58...",
|
|
77
|
+
amount: "0.1",
|
|
78
|
+
memo: "first transfer"
|
|
79
|
+
)
|
|
80
|
+
transfer.status # "processing" → tracked to "confirmed" → "finalized"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
With `broadcast_targets` configured (see [Realtime](#realtime)) and a `turbo_stream_from` subscription on the page, the recipient's balance region updates live the moment the transfer lands — that is `bin/sdp_watcher` ringing the doorbell.
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
`config/initializers/solrengine_sdp.rb` (generated):
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Solrengine::Sdp.configure do |config|
|
|
91
|
+
config.api_key = ENV["SDP_API_KEY"]
|
|
92
|
+
# ...
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Attribute | Default | Purpose |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `api_key` | `ENV["SDP_API_KEY"]` | SDP API key. Missing key fails **at boot** (`ConfigurationError`), not at the first wallet call. |
|
|
99
|
+
| `base_url` | `ENV["SDP_API_BASE_URL"]`, else `http://127.0.0.1:8787` | SDP API base URL. |
|
|
100
|
+
| `custody_provider` | `ENV["SDP_CUSTODY_PROVIDER"]` | Custody provider passed on wallet creation. Must be a managed provider for Wallet-per-User. |
|
|
101
|
+
| `label_namespace` | Rails app name, else `"app"` | Prefix for SDP wallet labels (`"#{namespace}-user-#{id}"`); guards collisions when apps share an SDP project. |
|
|
102
|
+
| `user_class` | `"User"` | The wallet-owner model (the one including `Solrengine::Sdp::WalletOwner`). |
|
|
103
|
+
| `logger` | `Rails.logger` | Engine log sink. |
|
|
104
|
+
| `expired_transfer_deadline` | `900` (seconds) | Transfers stuck in `processing` past this settle as `expired`. |
|
|
105
|
+
| `transfer_poll_interval` | `3` (seconds) | `TrackTransferJob` re-poll cadence. |
|
|
106
|
+
| `broadcast_retries` | `3` | Attempts per doorbell ring (the notification never re-fires). |
|
|
107
|
+
| `broadcast_retry_delay` | `2` (seconds) | Sleep between broadcast attempts (zero it in tests). |
|
|
108
|
+
| `broadcast_targets` | `[]` | Ordered `{name:, fetch:, render:}` hashes — see [Realtime](#realtime). Empty means: log a hint, broadcast nothing. |
|
|
109
|
+
|
|
110
|
+
## Realtime
|
|
111
|
+
|
|
112
|
+
The WebSocket account subscription is a **doorbell, not a data feed**: the notification only signals *that* a wallet's account changed. `bin/sdp_watcher` (its own process, in `Procfile.dev`) holds one subscription per wallet-ready user; on any change `Solrengine::Sdp::Broadcaster` re-fetches everything displayed from the authoritative source (SDP) and pushes your configured Turbo Stream updates.
|
|
113
|
+
|
|
114
|
+
The engine owns the doorbell invariants:
|
|
115
|
+
|
|
116
|
+
- **All-or-nothing** — every target's `fetch` runs first; any failure (raise or `:unavailable`) means no renders this attempt, so screens never regress from good content to an error state. Last good content stays.
|
|
117
|
+
- **Consumed doorbells retry** — the whole cycle retries `broadcast_retries` times, because a WebSocket notification never re-fires.
|
|
118
|
+
- **Priority order** — renders run in configured order; put money-bearing regions first.
|
|
119
|
+
- **Request-context-free** — lambdas run in the watcher process: no `Current`, no session, partials need explicit locals.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
config.broadcast_targets = [
|
|
123
|
+
{ name: :balance,
|
|
124
|
+
fetch: ->(user) { Solrengine::Sdp.client.wallet_balances(user.sdp_wallet_id) },
|
|
125
|
+
render: ->(user, balances) {
|
|
126
|
+
Turbo::StreamsChannel.broadcast_update_to(
|
|
127
|
+
[ user, :wallet ],
|
|
128
|
+
target: "wallet_balance",
|
|
129
|
+
partial: "wallets/balance",
|
|
130
|
+
locals: { balances: balances }
|
|
131
|
+
)
|
|
132
|
+
} }
|
|
133
|
+
]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
USD enrichment inside fetch lambdas: `Solrengine::Sdp.usd_value_for(balance)` — SDP's own `usd_value` when present, Jupiter-derived when solrengine-tokens is installed, `nil` otherwise. Price failures never fail a fetch.
|
|
137
|
+
|
|
138
|
+
**SOL-only doorbell in v0.1**: the system-account subscription sees lamport changes on the wallet address itself. SPL deposits land in Associated Token Accounts this subscription does not see — token balances are correct on page load, they just don't ring the doorbell yet. ATA subscriptions are planned.
|
|
139
|
+
|
|
140
|
+
**Degradation contract**: if the watcher isn't running, screens are correct on load — they just don't update live.
|
|
141
|
+
|
|
142
|
+
### Cable adapter
|
|
143
|
+
|
|
144
|
+
Rails' default `async` Action Cable adapter delivers broadcasts **in-process only** — everything the watcher pushes from its own process is silently dropped: no error, no log, the browser just never updates. The install generator rewrites the development adapter to Solid Cable (or tells you exactly what to do when your cable.yml isn't the stock layout), and `bin/sdp_watcher` performs a boot-time broadcast self-check plus an explicit async-adapter warning so a broken cable backend dies loudly instead of broadcasting into the void.
|
|
145
|
+
|
|
146
|
+
## Transfers
|
|
147
|
+
|
|
148
|
+
`Solrengine::Sdp::Transfer` is the engine-owned audit row — created *before* the POST to SDP, so even a crash mid-request leaves evidence to reconcile against. The create POST is **never retried** (SDP has no idempotency key; a blind re-send risks a double-spend); timeouts are reconciled by a unique memo token instead.
|
|
149
|
+
|
|
150
|
+
| Engine status | From | Terminal? | Meaning |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| `processing` | SDP `pending`/`processing` (and unrecognized statuses) | No | Submitted; `TrackTransferJob` polls until a verdict. |
|
|
153
|
+
| `confirmed` | SDP `confirmed` | No | **User-facing success** — tracking continues to finalized. |
|
|
154
|
+
| `finalized` | SDP `finalized` | Yes | Done. |
|
|
155
|
+
| `failed` | SDP `failed`, SDP rejections, or unreachable-SDP (`sdp_error` prefixed `unsent:`) | Yes | Renderable reason on `sdp_error`. |
|
|
156
|
+
| `expired` | engine-local | Yes | Stuck in `processing` past `expired_transfer_deadline` — verdict, not limbo. |
|
|
157
|
+
| `unknown` | engine-local | No | POST read-timeout: outcome unknown. Reconciled via the memo token through SDP's transfer list — adopted if found, `failed` if provably absent. |
|
|
158
|
+
|
|
159
|
+
`Transfer.execute!` runs a SOL balance preflight (`amount + 0.000005` fee buffer) and raises `InsufficientBalance` before any row or POST when the wallet provably can't cover it; an unreadable balance never blocks — the POST is the authority.
|
|
160
|
+
|
|
161
|
+
## Errors
|
|
162
|
+
|
|
163
|
+
Engine errors (all `< Solrengine::Sdp::Error < StandardError`):
|
|
164
|
+
|
|
165
|
+
| Error | Raised |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `Solrengine::Sdp::ConfigurationError` | Boot/configure time: missing API key, malformed broadcast targets. |
|
|
168
|
+
| `Solrengine::Sdp::InsufficientBalance` | `Transfer.execute!` preflight — before any row or POST exists. |
|
|
169
|
+
| `Solrengine::Sdp::Faucet::RateLimited` / `TimedOut` / `Unavailable` | Devnet faucet outcomes — `TimedOut` means the airdrop *may* still land; don't double-fund. |
|
|
170
|
+
|
|
171
|
+
Transport and API errors raised while talking to SDP come from the client gem — `Sdp::Error` and its subclasses, including the two capability gates (`Sdp::ProviderCapabilityError` for local-custody provisioning, `Sdp::TransferExecutionError` for the native fee adapter). See the [solana-sdp error taxonomy](https://github.com/solrengine/solana-sdp#errors).
|
|
172
|
+
|
|
173
|
+
## SDP compatibility
|
|
174
|
+
|
|
175
|
+
Tested against SDP **v0.28** (`Solrengine::Sdp::COMPATIBLE_SDP_VERSION`). SDP is pre-1.0 and breaks its API between minors; the compatible version is bumped — and the suite re-verified — on every SDP upgrade rather than claiming an open-ended range.
|
|
176
|
+
|
|
177
|
+
## Local development
|
|
178
|
+
|
|
179
|
+
The Gemfile path-sources sibling checkouts: `../solana-sdp`, `../solrengine-realtime` (needs the `feat/subscriber-registry` branch for the 0.2 registry), `../solrengine-rpc`, and `../solrengine-tokens` (optional price source, dev-only — it is not a gemspec dependency). Clone them next to this repo, then:
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
bundle install
|
|
183
|
+
bundle exec rake test
|
|
184
|
+
bundle exec rubocop
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Note: until solana-sdp and the realtime 0.2 branch are pushed to GitHub, CI's sibling-clone steps will fail remotely; local development is unaffected.
|
|
188
|
+
|
|
189
|
+
## See also
|
|
190
|
+
|
|
191
|
+
- [solana-sdp](https://github.com/solrengine/solana-sdp) — the plain-Ruby SDP API client this engine builds on (usable without Rails).
|
|
192
|
+
- [solrengine](https://github.com/solrengine/solrengine) — the meta-gem for the connect-your-wallet path; this engine is deliberately not among its dependencies (custodial mode is opt-in).
|
|
193
|
+
- [solrengine.org](https://solrengine.org) — the SolRengine family: the connect-your-wallet stack, and how both custody models compose.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Provisions an SDP custody wallet for a WalletOwner row, driving the
|
|
6
|
+
# four-state machine: pending → provisioning → ready | failed.
|
|
7
|
+
#
|
|
8
|
+
# Idempotent three ways:
|
|
9
|
+
#
|
|
10
|
+
# 1. The row is already ready → return immediately.
|
|
11
|
+
# 2. Optimistic claim: only the job that flips the row pending/failed →
|
|
12
|
+
# provisioning proceeds; a duplicate concurrent job updates 0 rows
|
|
13
|
+
# and returns without touching the network. A provisioning row whose
|
|
14
|
+
# lease has lapsed (worker died between claim and settle) may be
|
|
15
|
+
# taken over — see #claim.
|
|
16
|
+
# 3. Label adoption: when SDP already has a wallet labeled
|
|
17
|
+
# "#{namespace}-user-#{id}" (a previous create succeeded but the
|
|
18
|
+
# response was lost, e.g. a read timeout), adopt it instead of
|
|
19
|
+
# creating a duplicate.
|
|
20
|
+
#
|
|
21
|
+
# Failure posture:
|
|
22
|
+
#
|
|
23
|
+
# - Transport errors (Unavailable/Timeout) retry with backoff; the claim
|
|
24
|
+
# stays held across retries (see #claim). Exhaustion lands the row in
|
|
25
|
+
# failed with the transport reason — never a silent dead-letter.
|
|
26
|
+
# - ProviderCapabilityError (the FL-10 gate: local custody cannot create
|
|
27
|
+
# per-user wallets) is terminal: failed immediately, with the
|
|
28
|
+
# actionable "use a managed provider" message renderable to the app.
|
|
29
|
+
# - Any other Sdp::Error is equally terminal — retrying auth/validation
|
|
30
|
+
# bugs changes nothing; the reason is stored, not buried in logs.
|
|
31
|
+
#
|
|
32
|
+
# Inherits ActiveJob::Base directly so the engine never depends on the
|
|
33
|
+
# host app's ApplicationJob being defined or compatible.
|
|
34
|
+
class ProvisionWalletJob < ActiveJob::Base
|
|
35
|
+
queue_as :default
|
|
36
|
+
|
|
37
|
+
# User deleted between enqueue and perform: nothing to provision.
|
|
38
|
+
discard_on ActiveJob::DeserializationError
|
|
39
|
+
|
|
40
|
+
# Block form fires on exhaustion (after the final attempt): settle the
|
|
41
|
+
# row in failed with the transport reason so the app can render it and
|
|
42
|
+
# offer retry_provisioning!.
|
|
43
|
+
retry_on ::Sdp::Unavailable, ::Sdp::Timeout,
|
|
44
|
+
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
45
|
+
job.mark_provisioning_failed(job.arguments.first, "Retries exhausted: #{error.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def perform(user)
|
|
49
|
+
return if user.wallet_ready?
|
|
50
|
+
return unless claim(user)
|
|
51
|
+
|
|
52
|
+
wallet = existing_wallet_for(user) || create_wallet_for(user)
|
|
53
|
+
user.update!(
|
|
54
|
+
sdp_wallet_id: wallet.id,
|
|
55
|
+
wallet_address: wallet.public_key,
|
|
56
|
+
sdp_provisioning_state: "ready",
|
|
57
|
+
sdp_provisioning_error: nil
|
|
58
|
+
)
|
|
59
|
+
rescue ::Sdp::Unavailable, ::Sdp::Timeout
|
|
60
|
+
raise # retry_on handles backoff; the claim stays held for the retry
|
|
61
|
+
rescue ::Sdp::Error => e
|
|
62
|
+
# Terminal: capability gates (AE1) and auth/validation errors fail the
|
|
63
|
+
# same way no matter how often they are retried. Reason is renderable.
|
|
64
|
+
mark_provisioning_failed(user, e.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Settles the row in failed — but only when this job holds the claim
|
|
68
|
+
# (row is in provisioning), so a stale job can never clobber a row
|
|
69
|
+
# another job has since taken to ready. Public because the retry_on
|
|
70
|
+
# exhaustion block runs outside the instance's private context.
|
|
71
|
+
def mark_provisioning_failed(user, reason)
|
|
72
|
+
user.class
|
|
73
|
+
.where(id: user.id, sdp_provisioning_state: "provisioning")
|
|
74
|
+
.update_all(sdp_provisioning_state: "failed", sdp_provisioning_error: reason)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Optimistic claim: flip pending/failed → provisioning guarded by the
|
|
80
|
+
# current state; 1 row updated means this job owns the row, 0 means a
|
|
81
|
+
# concurrent job does — return without any network traffic. Retry
|
|
82
|
+
# executions (executions > 1) may resume from provisioning, because the
|
|
83
|
+
# claim was kept across the transient failure that triggered the retry.
|
|
84
|
+
#
|
|
85
|
+
# Stale-claim takeover: ANY execution (fresh jobs included, not just
|
|
86
|
+
# retries) may also take over a provisioning row whose updated_at is
|
|
87
|
+
# older than Configuration#provisioning_lease — a worker that died
|
|
88
|
+
# between claim and settle would otherwise strand the row forever.
|
|
89
|
+
# Takeover is safe because label adoption makes re-running safe: a
|
|
90
|
+
# completed-but-unrecorded create is adopted by label, so takeover
|
|
91
|
+
# cannot double-provision. And the lease prevents takeover of a LIVE
|
|
92
|
+
# job — every claim renews updated_at, and any live job's retries and
|
|
93
|
+
# settles touch updated_at well within the lease.
|
|
94
|
+
def claim(user)
|
|
95
|
+
claimable = %w[pending failed]
|
|
96
|
+
claimable += [ "provisioning" ] if executions > 1
|
|
97
|
+
|
|
98
|
+
lease_cutoff = Time.current - Solrengine::Sdp.configuration.provisioning_lease
|
|
99
|
+
|
|
100
|
+
user.class
|
|
101
|
+
.where(id: user.id, sdp_provisioning_state: claimable)
|
|
102
|
+
.or(
|
|
103
|
+
user.class.where(id: user.id, sdp_provisioning_state: "provisioning")
|
|
104
|
+
.where(updated_at: ..lease_cutoff)
|
|
105
|
+
)
|
|
106
|
+
.update_all(sdp_provisioning_state: "provisioning", updated_at: Time.current) == 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# GET /v1/wallets is not paginated at SDP v0.28 — the full list comes
|
|
110
|
+
# back in one response. list_wallets already routes through the client
|
|
111
|
+
# gem's paginating enumerator, so if SDP adds transfers-style pagination
|
|
112
|
+
# to wallets this scan keeps working unchanged.
|
|
113
|
+
def existing_wallet_for(user)
|
|
114
|
+
Solrengine::Sdp.client.list_wallets.find { |wallet| wallet.label == user.sdp_wallet_label }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def create_wallet_for(user)
|
|
118
|
+
Solrengine::Sdp.client.create_wallet(
|
|
119
|
+
label: user.sdp_wallet_label,
|
|
120
|
+
provider: Solrengine::Sdp.configuration.custody_provider
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Settles every Transfer row into a terminal state (R6). Two modes,
|
|
6
|
+
# keyed on the row's status:
|
|
7
|
+
#
|
|
8
|
+
# processing/confirmed → poll get_transfer, map per the status table,
|
|
9
|
+
# re-enqueue until terminal. A row stuck in processing past
|
|
10
|
+
# Configuration#expired_transfer_deadline settles as engine-local
|
|
11
|
+
# "expired" (SDP has no such status). confirmed keeps tracking —
|
|
12
|
+
# it is user-facing success, but finalized is the terminal verdict.
|
|
13
|
+
#
|
|
14
|
+
# unknown (the create POST read-timed out, the row never got an SDP id,
|
|
15
|
+
# or SDP 404ed the id we had) → reconcile: scan the source wallet's
|
|
16
|
+
# transfers for the engine memo token. Found → adopt the SDP row and
|
|
17
|
+
# keep tracking; not found → re-enqueue and scan again until the
|
|
18
|
+
# deadline settles it as failed "unsent (reconcile exhausted)".
|
|
19
|
+
#
|
|
20
|
+
# Deadline exhaustion is checked at the top of perform, BEFORE any SDP
|
|
21
|
+
# I/O, so an SDP outage can never keep a row unsettled past its deadline
|
|
22
|
+
# (an in-poll check would only fire after a successful GET).
|
|
23
|
+
#
|
|
24
|
+
# API errors never orphan a row (mirrors ProvisionWalletJob's posture):
|
|
25
|
+
# Unavailable/Timeout propagate to retry_on; NotFound flips the row to
|
|
26
|
+
# "unknown" so the memo token — not a 404 poll loop — decides; any other
|
|
27
|
+
# Sdp::Error re-enqueues within the deadline and settles the row failed
|
|
28
|
+
# (message renderable) past it.
|
|
29
|
+
#
|
|
30
|
+
# Backoff: a simple fixed wait (Configuration#transfer_poll_interval,
|
|
31
|
+
# default 3s) rather than exponential — Solana confirmation latency is
|
|
32
|
+
# bounded at seconds, and expired_transfer_deadline caps total tracking,
|
|
33
|
+
# so growing waits would only delay the verdict.
|
|
34
|
+
#
|
|
35
|
+
# Inherits ActiveJob::Base directly so the engine never depends on the
|
|
36
|
+
# host app's ApplicationJob (same posture as ProvisionWalletJob).
|
|
37
|
+
class TrackTransferJob < ActiveJob::Base
|
|
38
|
+
queue_as :default
|
|
39
|
+
|
|
40
|
+
# Transfer row deleted between enqueue and perform: nothing to track.
|
|
41
|
+
discard_on ActiveJob::DeserializationError
|
|
42
|
+
|
|
43
|
+
# Everything this job sends is a GET (get_transfer / list_transfers),
|
|
44
|
+
# so transport failures are always safe to retry — unlike the create
|
|
45
|
+
# POST, which is never retried. On exhaustion, hand off to a fresh job
|
|
46
|
+
# after the normal poll interval instead of orphaning the row: polling
|
|
47
|
+
# resumes when SDP comes back, and the expired deadline still bounds
|
|
48
|
+
# how long a processing row can stay unsettled.
|
|
49
|
+
retry_on ::Sdp::Unavailable, ::Sdp::Timeout,
|
|
50
|
+
wait: :polynomially_longer, attempts: 5 do |job, _error|
|
|
51
|
+
job.class.set(wait: job.class.poll_interval).perform_later(job.arguments.first)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.poll_interval
|
|
55
|
+
Solrengine::Sdp.configuration.transfer_poll_interval.seconds
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform(transfer)
|
|
59
|
+
return if transfer.terminal?
|
|
60
|
+
return if settle_past_deadline(transfer)
|
|
61
|
+
|
|
62
|
+
if transfer.unknown?
|
|
63
|
+
reconcile(transfer)
|
|
64
|
+
else
|
|
65
|
+
poll(transfer)
|
|
66
|
+
end
|
|
67
|
+
rescue ::Sdp::Unavailable, ::Sdp::Timeout
|
|
68
|
+
raise # transport: retry_on owns backoff and the exhaustion handoff
|
|
69
|
+
rescue ::Sdp::NotFound
|
|
70
|
+
# The id provably doesn't exist at SDP — polling it would 404
|
|
71
|
+
# forever. Reconcile by memo token instead: it positively identifies
|
|
72
|
+
# OUR attempt (found → adopt; never found → the deadline settles
|
|
73
|
+
# the row as unsent).
|
|
74
|
+
transfer.update!(status: "unknown")
|
|
75
|
+
reenqueue(transfer)
|
|
76
|
+
rescue ::Sdp::Error => e
|
|
77
|
+
# Auth/rate-limit/validation errors must never strand the row in a
|
|
78
|
+
# dead-letter: within the deadline they read as an API hiccup —
|
|
79
|
+
# re-enqueue and try again; past it the row settles failed with the
|
|
80
|
+
# renderable reason.
|
|
81
|
+
if past_deadline?(transfer)
|
|
82
|
+
transfer.settle!("failed", sdp_error: e.message)
|
|
83
|
+
else
|
|
84
|
+
reenqueue(transfer)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Deadline exhaustion, decided BEFORE any SDP I/O so the verdict lands
|
|
91
|
+
# even while SDP is down. Only processing rows expire — a confirmed row
|
|
92
|
+
# past the deadline is user-facing success already; expiring it would
|
|
93
|
+
# retract money the user saw move, so it keeps polling for finalized.
|
|
94
|
+
# Returns true when the row was settled.
|
|
95
|
+
def settle_past_deadline(transfer)
|
|
96
|
+
return false unless past_deadline?(transfer)
|
|
97
|
+
|
|
98
|
+
if transfer.processing?
|
|
99
|
+
transfer.settle!("expired")
|
|
100
|
+
elsif transfer.unknown?
|
|
101
|
+
# Reuses expired_transfer_deadline as the reconcile deadline: if
|
|
102
|
+
# the transfer existed, the scan would have found the memo token
|
|
103
|
+
# by now.
|
|
104
|
+
transfer.settle!("failed", sdp_error: "unsent (reconcile exhausted)")
|
|
105
|
+
else
|
|
106
|
+
return false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def poll(transfer)
|
|
113
|
+
# A SigningPending 202 whose details carried no id leaves a
|
|
114
|
+
# processing row with no sdp_transfer_id — there is nothing to GET
|
|
115
|
+
# (get_transfer(nil) is a malformed URL). Flip to "unknown" and
|
|
116
|
+
# reconcile by memo token, exactly like a timed-out create.
|
|
117
|
+
if transfer.sdp_transfer_id.nil?
|
|
118
|
+
transfer.update!(status: "unknown")
|
|
119
|
+
return reconcile(transfer)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
transfer.adopt!(Solrengine::Sdp.client.get_transfer(transfer.sdp_transfer_id))
|
|
123
|
+
reenqueue(transfer) unless transfer.terminal?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def reconcile(transfer)
|
|
127
|
+
match = find_by_memo_token(transfer)
|
|
128
|
+
|
|
129
|
+
if match
|
|
130
|
+
transfer.adopt!(match)
|
|
131
|
+
reenqueue(transfer) unless transfer.terminal?
|
|
132
|
+
else
|
|
133
|
+
# Deadline exhaustion settles in perform, before any I/O; within
|
|
134
|
+
# the deadline, scan again next interval.
|
|
135
|
+
reenqueue(transfer)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Scans ALL pages of the source wallet's transfers through the lazy
|
|
140
|
+
# enumerator. Simplest correct option: the match is normally on the
|
|
141
|
+
# first page (the attempt just happened), SDP exposes no created-at
|
|
142
|
+
# filter to cut the scan off at submitted_at, and the wallet-scoped
|
|
143
|
+
# list is small for any one user. The memo token positively identifies
|
|
144
|
+
# OUR attempt — no amount/recipient/time heuristics, so a concurrent
|
|
145
|
+
# identical transfer can never be claimed as this one.
|
|
146
|
+
def find_by_memo_token(transfer)
|
|
147
|
+
Solrengine::Sdp.client
|
|
148
|
+
.list_transfers(wallet: transfer.source_wallet_id)
|
|
149
|
+
.find { |row| row.memo.to_s.include?(transfer.memo_token) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def past_deadline?(transfer)
|
|
153
|
+
Time.current - transfer.submitted_at > Solrengine::Sdp.configuration.expired_transfer_deadline
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def reenqueue(transfer)
|
|
157
|
+
self.class.set(wait: self.class.poll_interval).perform_later(transfer)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|