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 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