Class: Solrengine::Sdp::Transfer
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Solrengine::Sdp::Transfer
- Defined in:
- app/models/solrengine/sdp/transfer.rb
Overview
Engine-owned record of every transfer the app attempts through SDP —the row IS the audit trail and the thing the app renders (R6: every transfer settles into a renderable terminal state).
Status mapping (the HTD table, one-to-one):
SDP pending/processing → "processing" (non-terminal, polled)
SDP confirmed → "confirmed" (user-facing success; tracking
CONTINUES to finalized)
SDP finalized → "finalized" (terminal)
SDP failed → "failed" (terminal; SDP error captured)
engine-local → "expired" (terminal; stuck in processing
past expired_transfer_deadline)
engine-local → "unknown" (POST read-timeout: outcome
unknown; reconciled by memo
token via list_transfers)
The create POST is NEVER retried — SDP has no idempotency key, so a blind re-send risks a double-spend (demo TransfersController / RecurringExecution discipline). The row is created BEFORE the POST so even a crash mid-request leaves evidence to reconcile against.
Constant Summary collapse
- FEE_BUFFER =
~5000 lamports: one signature’s network fee, held back from the spendable balance so a SOL send can pay for its own transaction.
BigDecimal("0.000005")
- MEMO_TOKEN_PREFIX =
App memo and engine token compose rather than replace each other: “rent | sdp-1a2b3c4d5e6f7a8b”. The token half is what timeout reconciliation scans for; the app half survives round-trip to chain.
"sdp-"- MEMO_SEPARATOR =
" | "- STATUSES =
%w[processing confirmed finalized failed expired unknown].freeze
- TERMINAL_STATUSES =
confirmed is NOT terminal: it is user-facing success, but tracking continues until SDP reports finalized.
%w[finalized failed expired].freeze
- SDP_STATUS_MAP =
{ "pending" => "processing", "processing" => "processing", "confirmed" => "confirmed", "finalized" => "finalized", "failed" => "failed" }.freeze
Class Method Summary collapse
- .engine_status_for(sdp_status) ⇒ Object
-
.execute!(source:, destination:, amount:, token: "SOL", memo: nil) ⇒ Object
Creates the row, runs the balance preflight, POSTs the transfer to SDP exactly once, and enqueues confirmation tracking.
-
.resume_tracking! ⇒ Object
Recovery entry point: re-enqueues TrackTransferJob for unsettled rows nothing is tracking anymore.
Instance Method Summary collapse
-
#adopt!(sdp_transfer) ⇒ Object
Maps an Sdp::Transfer struct onto this row per the status table.
-
#composed_memo ⇒ Object
The memo actually sent to SDP: app memo (when given) + engine token.
-
#settle!(terminal_status, sdp_error: nil) ⇒ Object
Lands the row in a terminal state and timestamps the verdict.
-
#submit_to_sdp! ⇒ Object
The single, never-retried POST.
- #terminal? ⇒ Boolean
Class Method Details
.engine_status_for(sdp_status) ⇒ Object
112 113 114 115 116 117 |
# File 'app/models/solrengine/sdp/transfer.rb', line 112 def engine_status_for(sdp_status) # Unrecognized SDP statuses map to "processing": non-terminal, keep # polling — safer than inventing a verdict for a status SDP adds in # a later version. SDP_STATUS_MAP.fetch(sdp_status.to_s, "processing") end |
.execute!(source:, destination:, amount:, token: "SOL", memo: nil) ⇒ Object
Creates the row, runs the balance preflight, POSTs the transfer to SDP exactly once, and enqueues confirmation tracking. Returns the row in whatever state the POST left it (the app renders from it).
Raises InsufficientBalance — before any row or POST — when the preflight shows the SOL balance can’t cover amount + FEE_BUFFER.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'app/models/solrengine/sdp/transfer.rb', line 71 def execute!(source:, destination:, amount:, token: "SOL", memo: nil) # Normalize to a plain decimal string ("1.5", never "0.15e1"): # stored and sent as a string so no float drift ever touches money. amount = BigDecimal(amount.to_s).to_s("F") # Preflight is SOL-only in v0.1 (SPL would need the mint row plus a # separate SOL fee check); for SPL tokens the POST is the authority. ensure_balance_covers!(source, amount) if token == "SOL" transfer = create!( source_wallet_id: source, destination: destination, token: token, amount: amount, memo: memo, memo_token: "#{MEMO_TOKEN_PREFIX}#{SecureRandom.hex(8)}", status: "processing", submitted_at: Time.current ) transfer.submit_to_sdp! TrackTransferJob.perform_later(transfer) unless transfer.terminal? transfer end |
.resume_tracking! ⇒ Object
Recovery entry point: re-enqueues TrackTransferJob for unsettled rows nothing is tracking anymore. Closes the crash window between create!/submit_to_sdp! and the tracking enqueue, and adopts orphans after queue data loss. The updated_at guard (two poll intervals) avoids double-enqueueing rows under ACTIVE tracking — every poll and settle touches the row, so a row untouched for two intervals has no live tracker. Returns the count enqueued.
102 103 104 105 106 107 108 109 110 |
# File 'app/models/solrengine/sdp/transfer.rb', line 102 def resume_tracking! cutoff = Time.current - (Solrengine::Sdp.configuration.transfer_poll_interval * 2) count = 0 unsettled.where(updated_at: ..cutoff).find_each do |transfer| TrackTransferJob.perform_later(transfer) count += 1 end count end |
Instance Method Details
#adopt!(sdp_transfer) ⇒ Object
Maps an Sdp::Transfer struct onto this row per the status table. Tolerates omitted optional fields (struct members are nil): existing id/signature are never clobbered with nil.
206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'app/models/solrengine/sdp/transfer.rb', line 206 def adopt!(sdp_transfer) mapped = self.class.engine_status_for(sdp_transfer.status) attributes = { sdp_transfer_id: sdp_transfer.id || sdp_transfer_id, sdp_status: sdp_transfer.status, status: mapped, signature: sdp_transfer.signature || signature } attributes[:sdp_error] = sdp_transfer.error if sdp_transfer.error attributes[:settled_at] = Time.current if TERMINAL_STATUSES.include?(mapped) && settled_at.nil? update!(attributes) end |
#composed_memo ⇒ Object
The memo actually sent to SDP: app memo (when given) + engine token.
158 159 160 |
# File 'app/models/solrengine/sdp/transfer.rb', line 158 def composed_memo [ memo, memo_token ].compact.join(MEMO_SEPARATOR) end |
#settle!(terminal_status, sdp_error: nil) ⇒ Object
Lands the row in a terminal state and timestamps the verdict.
220 221 222 223 224 225 226 |
# File 'app/models/solrengine/sdp/transfer.rb', line 220 def settle!(terminal_status, sdp_error: nil) update!( status: terminal_status, sdp_error: sdp_error || self[:sdp_error], settled_at: Time.current ) end |
#submit_to_sdp! ⇒ Object
The single, never-retried POST. Every outcome lands on the row:
success → adopt SDP's id/status/signature (mapped status)
SigningPending → stay processing; 202 details noted on sdp_error
Timeout → "unknown" (outcome unknown — reconcile by memo token)
Unavailable → "failed", "unsent:" prefix (request never processed)
other Sdp::Error (TransferExecutionError/TransactionFailed/rejections)
→ "failed" with the renderable message (AE2)
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'app/models/solrengine/sdp/transfer.rb', line 170 def submit_to_sdp! adopt!( Solrengine::Sdp.client.create_transfer( source: source_wallet_id, destination: destination, amount: amount, token: token, memo: composed_memo ) ) rescue ::Sdp::SigningPending => e # HTTP 202: accepted, awaiting additional signatures. Non-terminal — # tracked like any processing transfer once SDP exposes the id. update!( status: "processing", sdp_transfer_id: e.details.is_a?(Hash) ? (e.details[:transfer_id] || e.details[:id]) : nil, sdp_error: "signing_pending: #{e.}" ) rescue ::Sdp::Timeout # Read timeout on the POST: SDP may or may not have executed it. # NEVER re-POST; TrackTransferJob reconciles via the memo token. update!(status: "unknown") rescue ::Sdp::Unavailable => e # Connection refused/reset or a 5xx without SDP's error shape: the # request was never processed, so no money moved. The "unsent:" # prefix distinguishes this from an on-chain failure. settle!("failed", sdp_error: "unsent: #{e.}") rescue ::Sdp::Error => e # TransferExecutionError (the Kora/FL-11 gate, AE2), TransactionFailed, # and every other SDP rejection: terminal, message renderable as-is. settle!("failed", sdp_error: e.) end |
#terminal? ⇒ Boolean
153 154 155 |
# File 'app/models/solrengine/sdp/transfer.rb', line 153 def terminal? TERMINAL_STATUSES.include?(status) end |