solrengine-sdp 0.1.0 → 0.3.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 +31 -1
- data/app/jobs/solrengine/sdp/burn_job.rb +31 -0
- data/app/jobs/solrengine/sdp/mint_job.rb +45 -0
- data/app/models/solrengine/sdp/token.rb +100 -0
- data/app/models/solrengine/sdp/token_burn.rb +84 -0
- data/app/models/solrengine/sdp/token_mint.rb +99 -0
- data/lib/generators/solrengine/sdp/install_generator.rb +7 -1
- data/lib/generators/solrengine/sdp/templates/create_solrengine_sdp_token_burns.rb +25 -0
- data/lib/generators/solrengine/sdp/templates/create_solrengine_sdp_token_mints.rb +25 -0
- data/lib/generators/solrengine/sdp/templates/create_solrengine_sdp_tokens.rb +20 -0
- data/lib/generators/solrengine/sdp/templates/initializer.rb +16 -0
- data/lib/solrengine/sdp/configuration.rb +8 -1
- data/lib/solrengine/sdp/ramps.rb +57 -0
- data/lib/solrengine/sdp/version.rb +2 -2
- data/lib/solrengine/sdp.rb +12 -1
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15ecef7e5579d87685fb1fe3191d9695a679beeba31fc6df82df0c577477b85e
|
|
4
|
+
data.tar.gz: 76996291b77994894df747da458b0f017df58163be6d80ec7c20ce670b584259
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7ec3f5c1f15b148fbb80a18fd46773a44f5632da32b0de1e17196e08a273bc1335e66c46f28916985a6fe0610ebc311296e933fcad6adcb664d13b58d924d59
|
|
7
|
+
data.tar.gz: 641edbdf755534bdf0d64b9ecc3ff72c04a979b471a1a84048b96d4880d96eb7ec0a68e0d60d91620fc7742f9234eb2c1eb28a587bb7ff748ff92a75cf3bd1ac
|
data/README.md
CHANGED
|
@@ -158,6 +158,36 @@ Rails' default `async` Action Cable adapter delivers broadcasts **in-process onl
|
|
|
158
158
|
|
|
159
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
160
|
|
|
161
|
+
## Issuance
|
|
162
|
+
|
|
163
|
+
`Solrengine::Sdp::Token` issues a fungible SPL token through SDP and records every supply action as an engine-owned audit row. Mint authority is a custodial signing wallet (`signing_wallet_id`).
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
token = Solrengine::Sdp::Token.register!( # off-chain record (create_token)
|
|
167
|
+
name: "Kudos Points", symbol: "KUDO", decimals: 0,
|
|
168
|
+
signing_wallet_id: treasury_wallet_id
|
|
169
|
+
)
|
|
170
|
+
token.deploy! # on-chain mint (deploy_token)
|
|
171
|
+
|
|
172
|
+
token.mint!(destination: user.wallet_address, amount: 10) # credit — async, MintJob
|
|
173
|
+
token.burn!(source: user.wallet_address, # debit — async, BurnJob
|
|
174
|
+
signing_wallet_id: user.sdp_wallet_id, amount: 10)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`amount` is a **decimal token amount** — SDP scales it by the token's `decimals`, so `10` on a 0-decimal token is 10 whole tokens (it is **not** base units). Whole amounts are sent without a trailing `.0` (which SDP rejects for a 0-decimal token as "too many decimal places").
|
|
178
|
+
|
|
179
|
+
Each `mint!`/`burn!` persists a `TokenMint`/`TokenBurn` row, then enqueues a single **never-retried** POST. The atomic claim (`minting → in_flight`) guarantees a mint/burn is never sent twice (SDP has no idempotency key); unlike a transfer there is no mint-transaction list to reconcile against, so a read-timeout lands `unknown` and is surfaced, never auto-re-sent.
|
|
180
|
+
|
|
181
|
+
| Mint / Burn status | Terminal? | Meaning |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `minting` / `burning` | No | Recorded, not yet sent. |
|
|
184
|
+
| `in_flight` | No | Claimed; the POST is in flight. A crash here is left for manual reconcile, never re-sent. |
|
|
185
|
+
| `minted` / `burned` | Yes | Confirmed on-chain. |
|
|
186
|
+
| `failed` | Yes | SDP rejected it, or it was never sent — reason on `sdp_error`. |
|
|
187
|
+
| `unknown` | No | POST read-timeout: outcome uncertain. Never re-sent; surfaced for review. |
|
|
188
|
+
|
|
189
|
+
App-initiated issuance is its own doorbell: on settle, `MintJob`/`BurnJob` ring the balance broadcast (the configured `broadcast_targets`) for the affected wallet, so an earn/redeem updates the UI live — including the first — without relying on the chain-WebSocket watcher, which sees a wallet's native account, not the token ATA a mint credits.
|
|
190
|
+
|
|
161
191
|
## Errors
|
|
162
192
|
|
|
163
193
|
Engine errors (all `< Solrengine::Sdp::Error < StandardError`):
|
|
@@ -172,7 +202,7 @@ Transport and API errors raised while talking to SDP come from the client gem
|
|
|
172
202
|
|
|
173
203
|
## SDP compatibility
|
|
174
204
|
|
|
175
|
-
Tested against SDP **v0.
|
|
205
|
+
Tested against SDP **v0.31** (`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
206
|
|
|
177
207
|
## Local development
|
|
178
208
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Sends a single burn to SDP, off the web request. The burn POST is NEVER
|
|
6
|
+
# retried; the atomic claim (burning → in_flight) guarantees a burn is
|
|
7
|
+
# never sent twice. Counterpart to MintJob — see it for the full rationale.
|
|
8
|
+
class BurnJob < ActiveJob::Base
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
discard_on ActiveJob::DeserializationError
|
|
12
|
+
|
|
13
|
+
def perform(burn)
|
|
14
|
+
return unless burn.claim! # false → already attempted; never re-send
|
|
15
|
+
|
|
16
|
+
burn.submit_to_sdp!
|
|
17
|
+
# See MintJob: the burn is its own doorbell, so ring the source
|
|
18
|
+
# wallet's balance broadcast directly when the burn settles.
|
|
19
|
+
broadcast_balance(burn.source) if burn.burned?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def broadcast_balance(wallet_address)
|
|
25
|
+
Broadcaster.call(wallet_address)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Rails.logger&.warn("[Solrengine::Sdp::BurnJob] post-burn broadcast failed: #{e.class}: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Sends a single mint to SDP, off the web request. The mint POST is NEVER
|
|
6
|
+
# retried — SDP has no idempotency key, so a blind re-send risks a
|
|
7
|
+
# double-mint. The atomic claim (minting → in_flight) means a crash +
|
|
8
|
+
# queue-retry, or two workers racing the same row, can never send twice:
|
|
9
|
+
# only the claim winner POSTs; a row already claimed is left as-is.
|
|
10
|
+
#
|
|
11
|
+
# Transport failures are handled INSIDE TokenMint#submit_to_sdp!
|
|
12
|
+
# (Timeout → unknown, Unavailable → failed), so this job never raises for
|
|
13
|
+
# an automatic retry to catch.
|
|
14
|
+
#
|
|
15
|
+
# Inherits ActiveJob::Base directly so the engine never depends on the
|
|
16
|
+
# host app's ApplicationJob (same posture as ProvisionWalletJob).
|
|
17
|
+
class MintJob < ActiveJob::Base
|
|
18
|
+
queue_as :default
|
|
19
|
+
|
|
20
|
+
# Row deleted between enqueue and perform: nothing to mint.
|
|
21
|
+
discard_on ActiveJob::DeserializationError
|
|
22
|
+
|
|
23
|
+
def perform(mint)
|
|
24
|
+
return unless mint.claim! # false → already attempted; never re-send
|
|
25
|
+
|
|
26
|
+
mint.submit_to_sdp!
|
|
27
|
+
# App-initiated issuance is its own doorbell. The chain-WebSocket
|
|
28
|
+
# watcher only sees a wallet's native account, not the token ATA a
|
|
29
|
+
# mint credits, so it never rings for an earn — but WE know the mint
|
|
30
|
+
# just landed. Ring the balance broadcast directly (every earn,
|
|
31
|
+
# including the first). A broadcast failure must never fail a settled
|
|
32
|
+
# mint: the money already moved.
|
|
33
|
+
broadcast_balance(mint.destination) if mint.minted?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def broadcast_balance(wallet_address)
|
|
39
|
+
Broadcaster.call(wallet_address)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Rails.logger&.warn("[Solrengine::Sdp::MintJob] post-mint broadcast failed: #{e.class}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Solrengine
|
|
7
|
+
module Sdp
|
|
8
|
+
# Engine-owned record of a token issued through SDP: registered off-chain
|
|
9
|
+
# (create_token), then deployed on-chain (deploy_token), then minted/burned
|
|
10
|
+
# via supply actions. Mirrors Transfer's "the row is the audit trail"
|
|
11
|
+
# posture. Mint authority is signing_wallet_id (a custodial treasury wallet).
|
|
12
|
+
class Token < ActiveRecord::Base
|
|
13
|
+
self.table_name = "solrengine_sdp_tokens"
|
|
14
|
+
|
|
15
|
+
STATUSES = %w[created deployed failed].freeze
|
|
16
|
+
|
|
17
|
+
has_many :mints, class_name: "Solrengine::Sdp::TokenMint", dependent: :destroy
|
|
18
|
+
has_many :burns, class_name: "Solrengine::Sdp::TokenBurn", dependent: :destroy
|
|
19
|
+
|
|
20
|
+
validates :name, :symbol, :signing_wallet_id, presence: true
|
|
21
|
+
validates :decimals, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
22
|
+
validates :status, inclusion: { in: STATUSES }
|
|
23
|
+
|
|
24
|
+
def deployed?
|
|
25
|
+
mint_address.present?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Registers the token with SDP (off-chain record) and persists it.
|
|
30
|
+
# Raises Sdp::Error if SDP rejects it (no row is created).
|
|
31
|
+
def register!(name:, symbol:, signing_wallet_id:, decimals: 0)
|
|
32
|
+
sdp = Solrengine::Sdp.client.create_token(
|
|
33
|
+
name: name, symbol: symbol, decimals: decimals, signing_wallet_id: signing_wallet_id
|
|
34
|
+
)
|
|
35
|
+
create!(
|
|
36
|
+
name: name, symbol: symbol, decimals: decimals,
|
|
37
|
+
signing_wallet_id: signing_wallet_id, sdp_token_id: sdp.id, status: "created"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Custodial sign-and-send deploy → on-chain mint. Records the mint
|
|
43
|
+
# address; on failure the row is marked failed and the error re-raised
|
|
44
|
+
# for the caller to render. Never retried (money-path).
|
|
45
|
+
def deploy!
|
|
46
|
+
sdp = Solrengine::Sdp.client.deploy_token(sdp_token_id)
|
|
47
|
+
update!(mint_address: sdp.mint_address, status: "deployed")
|
|
48
|
+
self
|
|
49
|
+
rescue ::Sdp::Error => e
|
|
50
|
+
update!(status: "failed", sdp_error: e.message)
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Records a mint and enqueues MintJob (the mint POST runs off the web
|
|
55
|
+
# request, never retried). amount is a DECIMAL token amount — SDP scales
|
|
56
|
+
# it by the token's decimals (whole numbers for a 0-decimal token).
|
|
57
|
+
# Returns the TokenMint row.
|
|
58
|
+
def mint!(destination:, amount:, memo: nil)
|
|
59
|
+
mint = mints.create!(
|
|
60
|
+
destination: destination,
|
|
61
|
+
amount: normalize_amount(amount),
|
|
62
|
+
memo: memo,
|
|
63
|
+
memo_token: "#{TokenMint::MEMO_TOKEN_PREFIX}#{SecureRandom.hex(8)}",
|
|
64
|
+
status: "minting",
|
|
65
|
+
submitted_at: Time.current
|
|
66
|
+
)
|
|
67
|
+
MintJob.perform_later(mint)
|
|
68
|
+
mint
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Records a burn and enqueues BurnJob (never retried). A burn is signed
|
|
72
|
+
# by the source wallet's owner, so signing_wallet_id is the holder's
|
|
73
|
+
# custodial wallet (the user), NOT the treasury. Returns the TokenBurn.
|
|
74
|
+
def burn!(source:, signing_wallet_id:, amount:, memo: nil)
|
|
75
|
+
burn = burns.create!(
|
|
76
|
+
source: source,
|
|
77
|
+
signing_wallet_id: signing_wallet_id,
|
|
78
|
+
amount: normalize_amount(amount),
|
|
79
|
+
memo: memo,
|
|
80
|
+
memo_token: "#{TokenBurn::MEMO_TOKEN_PREFIX}#{SecureRandom.hex(8)}",
|
|
81
|
+
status: "burning",
|
|
82
|
+
submitted_at: Time.current
|
|
83
|
+
)
|
|
84
|
+
BurnJob.perform_later(burn)
|
|
85
|
+
burn
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# BigDecimal#to_s("F") always renders a trailing ".0" — so a whole "10"
|
|
91
|
+
# becomes "10.0", which a 0-decimal token refuses ("Amount has too many
|
|
92
|
+
# decimal places"). Drop trailing fractional zeros (and the now-bare
|
|
93
|
+
# decimal point) so whole amounts go out as "10". SDP still enforces the
|
|
94
|
+
# token's decimals, so an over-precise amount surfaces as a failed write.
|
|
95
|
+
def normalize_amount(value)
|
|
96
|
+
BigDecimal(value.to_s).to_s("F").sub(/\.?0+\z/, "")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Engine-owned record of every burn attempted through SDP — the redeem-path
|
|
6
|
+
# counterpart to TokenMint, with the same never-double-* discipline.
|
|
7
|
+
#
|
|
8
|
+
# Status: burning → in_flight → burned | failed | unknown (see TokenMint
|
|
9
|
+
# for the rationale; a timed-out burn stays `unknown` — never re-sent, no
|
|
10
|
+
# listing to reconcile against).
|
|
11
|
+
#
|
|
12
|
+
# Unlike a mint (signed by the token's mint authority), a burn is signed by
|
|
13
|
+
# the SOURCE wallet's owner: signing_wallet_id is the user's custodial
|
|
14
|
+
# wallet, not the treasury.
|
|
15
|
+
class TokenBurn < ActiveRecord::Base
|
|
16
|
+
self.table_name = "solrengine_sdp_token_burns"
|
|
17
|
+
|
|
18
|
+
MEMO_TOKEN_PREFIX = "sdpburn-"
|
|
19
|
+
MEMO_SEPARATOR = " | "
|
|
20
|
+
|
|
21
|
+
STATUSES = %w[burning in_flight burned failed unknown].freeze
|
|
22
|
+
TERMINAL_STATUSES = %w[burned failed unknown].freeze
|
|
23
|
+
|
|
24
|
+
belongs_to :token, class_name: "Solrengine::Sdp::Token"
|
|
25
|
+
|
|
26
|
+
validates :source, :signing_wallet_id, :amount, :memo_token, presence: true
|
|
27
|
+
validates :status, inclusion: { in: STATUSES }
|
|
28
|
+
|
|
29
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
30
|
+
|
|
31
|
+
scope :unsettled, -> { where.not(status: TERMINAL_STATUSES) }
|
|
32
|
+
|
|
33
|
+
def pending?
|
|
34
|
+
%w[burning in_flight].include?(status)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Atomic claim: burning → in_flight in one UPDATE, so a crash + retry or
|
|
38
|
+
# two workers can never send the same burn twice.
|
|
39
|
+
def claim!
|
|
40
|
+
won = self.class.where(id: id, status: "burning").update_all(status: "in_flight", updated_at: Time.current)
|
|
41
|
+
reload if won == 1
|
|
42
|
+
won == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The single, never-retried burn POST.
|
|
46
|
+
def submit_to_sdp!
|
|
47
|
+
tx = Solrengine::Sdp.client.burn_token(
|
|
48
|
+
token.sdp_token_id,
|
|
49
|
+
signing_wallet_id: signing_wallet_id,
|
|
50
|
+
source: source,
|
|
51
|
+
amount: amount,
|
|
52
|
+
memo: composed_memo
|
|
53
|
+
)
|
|
54
|
+
update!(
|
|
55
|
+
status: terminal_for(tx.status),
|
|
56
|
+
signature: tx.signature,
|
|
57
|
+
sdp_transaction_id: tx.id,
|
|
58
|
+
sdp_error: tx.error,
|
|
59
|
+
settled_at: Time.current
|
|
60
|
+
)
|
|
61
|
+
rescue ::Sdp::Timeout
|
|
62
|
+
update!(status: "unknown")
|
|
63
|
+
rescue ::Sdp::Unavailable => e
|
|
64
|
+
update!(status: "failed", sdp_error: "unsent: #{e.message}", settled_at: Time.current)
|
|
65
|
+
rescue ::Sdp::Error => e
|
|
66
|
+
update!(status: "failed", sdp_error: e.message, settled_at: Time.current)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def composed_memo
|
|
70
|
+
[ memo, memo_token ].compact.join(MEMO_SEPARATOR)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def terminal_for(sdp_status)
|
|
76
|
+
case sdp_status.to_s
|
|
77
|
+
when "confirmed", "finalized" then "burned"
|
|
78
|
+
when "failed" then "failed"
|
|
79
|
+
else "unknown"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Engine-owned record of every mint attempted through SDP — the audit
|
|
6
|
+
# trail and the never-double-mint guard.
|
|
7
|
+
#
|
|
8
|
+
# Status machine:
|
|
9
|
+
# minting → just recorded, not yet sent (created before the POST)
|
|
10
|
+
# in_flight → claimed by MintJob; the POST is being sent (atomic claim
|
|
11
|
+
# so a crash + queue-retry never re-sends the same mint)
|
|
12
|
+
# minted → confirmed on-chain (terminal)
|
|
13
|
+
# failed → SDP rejected it, or it was never sent (terminal)
|
|
14
|
+
# unknown → the POST read-timed out: outcome uncertain. NEVER re-sent
|
|
15
|
+
# (double-mint risk), and SDP exposes no mint-transaction
|
|
16
|
+
# listing to reconcile against — surfaced for manual review.
|
|
17
|
+
#
|
|
18
|
+
# The mint POST is NEVER retried (SDP has no idempotency key). Unlike a
|
|
19
|
+
# Transfer there is no memo-token reconcile path (no list endpoint for
|
|
20
|
+
# mint transactions), so a timed-out mint stays `unknown` rather than
|
|
21
|
+
# auto-resolving.
|
|
22
|
+
class TokenMint < ActiveRecord::Base
|
|
23
|
+
self.table_name = "solrengine_sdp_token_mints"
|
|
24
|
+
|
|
25
|
+
MEMO_TOKEN_PREFIX = "sdpmint-"
|
|
26
|
+
MEMO_SEPARATOR = " | "
|
|
27
|
+
|
|
28
|
+
STATUSES = %w[minting in_flight minted failed unknown].freeze
|
|
29
|
+
TERMINAL_STATUSES = %w[minted failed unknown].freeze
|
|
30
|
+
|
|
31
|
+
belongs_to :token, class_name: "Solrengine::Sdp::Token"
|
|
32
|
+
|
|
33
|
+
validates :destination, :amount, :memo_token, presence: true
|
|
34
|
+
validates :status, inclusion: { in: STATUSES }
|
|
35
|
+
|
|
36
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
37
|
+
|
|
38
|
+
scope :unsettled, -> { where.not(status: TERMINAL_STATUSES) }
|
|
39
|
+
|
|
40
|
+
def pending?
|
|
41
|
+
%w[minting in_flight].include?(status)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Atomic claim: minting → in_flight in one UPDATE. Returns true only for
|
|
45
|
+
# the caller that won the claim, so a crash + queue-retry (or two workers)
|
|
46
|
+
# can never send the same mint twice. A row stuck in_flight is a
|
|
47
|
+
# crashed-mid-send and is left for manual reconcile, never auto-re-sent.
|
|
48
|
+
def claim!
|
|
49
|
+
won = self.class.where(id: id, status: "minting").update_all(status: "in_flight", updated_at: Time.current)
|
|
50
|
+
reload if won == 1
|
|
51
|
+
won == 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The single, never-retried mint POST. Every outcome lands on the row.
|
|
55
|
+
def submit_to_sdp!
|
|
56
|
+
tx = Solrengine::Sdp.client.mint_token(
|
|
57
|
+
token.sdp_token_id,
|
|
58
|
+
signing_wallet_id: token.signing_wallet_id,
|
|
59
|
+
destination: destination,
|
|
60
|
+
amount: amount,
|
|
61
|
+
memo: composed_memo
|
|
62
|
+
)
|
|
63
|
+
update!(
|
|
64
|
+
status: terminal_for(tx.status),
|
|
65
|
+
signature: tx.signature,
|
|
66
|
+
sdp_transaction_id: tx.id,
|
|
67
|
+
token_account: tx.token_account,
|
|
68
|
+
sdp_error: tx.error,
|
|
69
|
+
settled_at: Time.current
|
|
70
|
+
)
|
|
71
|
+
rescue ::Sdp::Timeout
|
|
72
|
+
# Outcome unknown — NEVER re-send. No mint-tx listing to reconcile.
|
|
73
|
+
update!(status: "unknown")
|
|
74
|
+
rescue ::Sdp::Unavailable => e
|
|
75
|
+
# Request never processed — safe to mark failed (no money moved).
|
|
76
|
+
update!(status: "failed", sdp_error: "unsent: #{e.message}", settled_at: Time.current)
|
|
77
|
+
rescue ::Sdp::Error => e
|
|
78
|
+
update!(status: "failed", sdp_error: e.message, settled_at: Time.current)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def composed_memo
|
|
82
|
+
[ memo, memo_token ].compact.join(MEMO_SEPARATOR)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# mint_token returns a confirmed TokenTransaction on success. A non-
|
|
88
|
+
# confirmed status can't be polled (no get-mint endpoint), so anything
|
|
89
|
+
# that isn't clearly confirmed/finalized or failed lands as unknown.
|
|
90
|
+
def terminal_for(sdp_status)
|
|
91
|
+
case sdp_status.to_s
|
|
92
|
+
when "confirmed", "finalized" then "minted"
|
|
93
|
+
when "failed" then "failed"
|
|
94
|
+
else "unknown"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -21,7 +21,13 @@ module Solrengine
|
|
|
21
21
|
class InstallGenerator < Rails::Generators::Base
|
|
22
22
|
source_root File.expand_path("templates", __dir__)
|
|
23
23
|
|
|
24
|
-
MIGRATIONS = %w[
|
|
24
|
+
MIGRATIONS = %w[
|
|
25
|
+
add_solrengine_sdp_to_users
|
|
26
|
+
create_solrengine_sdp_transfers
|
|
27
|
+
create_solrengine_sdp_tokens
|
|
28
|
+
create_solrengine_sdp_token_mints
|
|
29
|
+
create_solrengine_sdp_token_burns
|
|
30
|
+
].freeze
|
|
25
31
|
|
|
26
32
|
# The exact development block `rails new` emits — the only async config
|
|
27
33
|
# this generator rewrites mechanically. Anything else async-but-custom
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Engine-owned record of every burn attempted through SDP — the audit trail
|
|
2
|
+
# and the never-double-burn guard. See Solrengine::Sdp::TokenBurn.
|
|
3
|
+
class CreateSolrengineSdpTokenBurns < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :solrengine_sdp_token_burns do |t|
|
|
6
|
+
t.references :token, null: false, foreign_key: { to_table: :solrengine_sdp_tokens }
|
|
7
|
+
t.string :source, null: false # wallet (pubkey) the tokens are burned from
|
|
8
|
+
t.string :signing_wallet_id, null: false # SDP wallet that signs (the source's owner)
|
|
9
|
+
t.string :amount, null: false # decimal token amount — never a float
|
|
10
|
+
t.string :memo
|
|
11
|
+
t.string :memo_token, null: false # engine reconcile token
|
|
12
|
+
t.string :status, null: false, default: "burning" # burning -> burned | failed | unknown
|
|
13
|
+
t.string :signature
|
|
14
|
+
t.string :sdp_transaction_id
|
|
15
|
+
t.string :sdp_error
|
|
16
|
+
t.datetime :submitted_at
|
|
17
|
+
t.datetime :settled_at
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :solrengine_sdp_token_burns, :memo_token, unique: true
|
|
23
|
+
add_index :solrengine_sdp_token_burns, :status
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Engine-owned record of every mint attempted through SDP — the audit trail
|
|
2
|
+
# and the never-double-mint guard. See Solrengine::Sdp::TokenMint.
|
|
3
|
+
class CreateSolrengineSdpTokenMints < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :solrengine_sdp_token_mints do |t|
|
|
6
|
+
t.references :token, null: false, foreign_key: { to_table: :solrengine_sdp_tokens }
|
|
7
|
+
t.string :destination, null: false
|
|
8
|
+
t.string :amount, null: false # decimal token amount — never a float
|
|
9
|
+
t.string :memo # app memo (composes with memo_token)
|
|
10
|
+
t.string :memo_token, null: false # engine reconcile token
|
|
11
|
+
t.string :status, null: false, default: "minting" # minting -> minted | failed | unknown
|
|
12
|
+
t.string :signature
|
|
13
|
+
t.string :sdp_transaction_id
|
|
14
|
+
t.string :token_account
|
|
15
|
+
t.string :sdp_error
|
|
16
|
+
t.datetime :submitted_at
|
|
17
|
+
t.datetime :settled_at
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :solrengine_sdp_token_mints, :memo_token, unique: true
|
|
23
|
+
add_index :solrengine_sdp_token_mints, :status
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Engine-owned record of a token issued through SDP — created off-chain, then
|
|
2
|
+
# deployed on-chain. See Solrengine::Sdp::Token for the lifecycle.
|
|
3
|
+
class CreateSolrengineSdpTokens < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :solrengine_sdp_tokens do |t|
|
|
6
|
+
t.string :sdp_token_id # SDP token record id (set on create)
|
|
7
|
+
t.string :mint_address # on-chain mint (set on deploy)
|
|
8
|
+
t.string :name, null: false
|
|
9
|
+
t.string :symbol, null: false
|
|
10
|
+
t.integer :decimals, null: false, default: 0
|
|
11
|
+
t.string :signing_wallet_id, null: false # mint authority (treasury)
|
|
12
|
+
t.string :status, null: false, default: "created" # created -> deployed -> failed
|
|
13
|
+
t.string :sdp_error
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :solrengine_sdp_tokens, :sdp_token_id, unique: true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -19,6 +19,11 @@ Solrengine::Sdp.configure do |config|
|
|
|
19
19
|
# the Rails application name.
|
|
20
20
|
# config.label_namespace = "myapp"
|
|
21
21
|
|
|
22
|
+
# Default fiat ramp provider (e.g. "bvnk"). The ramps helper injects it so
|
|
23
|
+
# you don't repeat `provider:` on every call: Solrengine::Sdp.ramps.onramp_quote(...).
|
|
24
|
+
# Leave unset to pass `provider:` per call instead.
|
|
25
|
+
# config.ramp_provider = ENV["SDP_RAMP_PROVIDER"]
|
|
26
|
+
|
|
22
27
|
# The wallet-owner model (the one including Solrengine::Sdp::WalletOwner).
|
|
23
28
|
# Defaults to "User".
|
|
24
29
|
# config.user_class = "Account"
|
|
@@ -42,3 +47,14 @@ Solrengine::Sdp.configure do |config|
|
|
|
42
47
|
# } }
|
|
43
48
|
# ]
|
|
44
49
|
end
|
|
50
|
+
|
|
51
|
+
# Token issuance and fiat ramps (new in v0.2) need no extra config — reach them
|
|
52
|
+
# through the client and the ramps helper:
|
|
53
|
+
#
|
|
54
|
+
# Solrengine::Sdp.client.create_token(name: "...", symbol: "...", signing_wallet_id: "...")
|
|
55
|
+
# Solrengine::Sdp.client.mint_token(token_id, signing_wallet_id: "...", destination: "...", amount: "...")
|
|
56
|
+
# Solrengine::Sdp.ramps.onramp_quote(counterparty_id: "...", destination_wallet: "...",
|
|
57
|
+
# crypto_token: "SOL", fiat_currency: "USD", fiat_amount: "100")
|
|
58
|
+
#
|
|
59
|
+
# Ramps are SANDBOX-ONLY in v0.2 (preview). Mint/burn/deploy are money-path:
|
|
60
|
+
# like transfers they need FEE_PAYMENT_PROVIDER=kora on a self-hosted SDP.
|
|
@@ -15,7 +15,7 @@ module Solrengine
|
|
|
15
15
|
DEFAULT_TRANSFER_POLL_INTERVAL = 3 # seconds — confirmation is usually seconds away
|
|
16
16
|
DEFAULT_PROVISIONING_LEASE = 10 * 60 # seconds — see provisioning_lease below
|
|
17
17
|
|
|
18
|
-
attr_writer :api_key, :base_url, :custody_provider, :label_namespace, :logger
|
|
18
|
+
attr_writer :api_key, :base_url, :custody_provider, :ramp_provider, :label_namespace, :logger
|
|
19
19
|
# provisioning_lease (seconds): how long a wallet-owner row may sit in
|
|
20
20
|
# "provisioning" untouched before the claim is considered abandoned
|
|
21
21
|
# (worker died between claim and settle) and another job may take it
|
|
@@ -50,6 +50,13 @@ module Solrengine
|
|
|
50
50
|
@custody_provider || ENV["SDP_CUSTODY_PROVIDER"]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Default fiat ramp provider (e.g. "bvnk") for the ramps helper, so apps
|
|
54
|
+
# don't repeat `provider:` on every on/off-ramp call. nil is fine — the
|
|
55
|
+
# caller can pass `provider:` per call instead. Sandbox-only in v0.2.
|
|
56
|
+
def ramp_provider
|
|
57
|
+
@ramp_provider || ENV["SDP_RAMP_PROVIDER"]
|
|
58
|
+
end
|
|
59
|
+
|
|
53
60
|
# Prefix for SDP wallet labels ("#{label_namespace}-user-#{id}").
|
|
54
61
|
# Defaults to the Rails application name, else "app".
|
|
55
62
|
def label_namespace
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Solrengine
|
|
4
|
+
module Sdp
|
|
5
|
+
# Thin engine wrapper over solana-sdp's ramps surface (SDP fiat on/off-ramps).
|
|
6
|
+
# Its only job is to default `provider:` from config.ramp_provider so apps
|
|
7
|
+
# don't repeat it on every call — the same convenience custody_provider gives
|
|
8
|
+
# wallet calls. Everything else passes straight through to the client; there
|
|
9
|
+
# is no model and no extra state.
|
|
10
|
+
#
|
|
11
|
+
# SANDBOX-ONLY in v0.2: wired against SDP's ramp surface and verified against
|
|
12
|
+
# the sandbox, not live fiat rails. Treat as preview.
|
|
13
|
+
class Ramps
|
|
14
|
+
# provider may be nil (no configured default) — then callers must pass
|
|
15
|
+
# `provider:` per call, and the client validates it.
|
|
16
|
+
def initialize(client:, provider: nil)
|
|
17
|
+
@client = client
|
|
18
|
+
@provider = provider
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def onramp_currencies(**kwargs)
|
|
22
|
+
@client.onramp_currencies(**with_provider(kwargs))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def offramp_currencies(**kwargs)
|
|
26
|
+
@client.offramp_currencies(**with_provider(kwargs))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def onramp_quote(**kwargs)
|
|
30
|
+
@client.onramp_quote(**with_provider(kwargs))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def onramp_execute(**kwargs)
|
|
34
|
+
@client.onramp_execute(**with_provider(kwargs))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def offramp_execute(**kwargs)
|
|
38
|
+
@client.offramp_execute(**with_provider(kwargs))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The sandbox event hook — no provider involved; pure passthrough.
|
|
42
|
+
def simulate_ramp(**payload)
|
|
43
|
+
@client.simulate_ramp(**payload)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Inject the configured provider unless the caller named one explicitly
|
|
49
|
+
# (an explicit `provider: nil` is honored — it means "don't scope").
|
|
50
|
+
def with_provider(kwargs)
|
|
51
|
+
return kwargs if kwargs.key?(:provider) || @provider.nil?
|
|
52
|
+
|
|
53
|
+
kwargs.merge(provider: @provider)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Solrengine
|
|
4
4
|
module Sdp
|
|
5
|
-
VERSION = "0.
|
|
5
|
+
VERSION = "0.3.0"
|
|
6
6
|
|
|
7
7
|
# The SDP release this engine version is tested against. SDP breaks its
|
|
8
8
|
# API between minors; bump this (and re-verify) on every SDP upgrade.
|
|
9
|
-
COMPATIBLE_SDP_VERSION = "0.
|
|
9
|
+
COMPATIBLE_SDP_VERSION = "0.31"
|
|
10
10
|
end
|
|
11
11
|
end
|
data/lib/solrengine/sdp.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "sdp/configuration"
|
|
|
10
10
|
require_relative "sdp/wallet_owner"
|
|
11
11
|
require_relative "sdp/broadcaster"
|
|
12
12
|
require_relative "sdp/faucet"
|
|
13
|
+
require_relative "sdp/ramps"
|
|
13
14
|
require_relative "sdp/engine" if defined?(Rails::Engine)
|
|
14
15
|
|
|
15
16
|
module Solrengine
|
|
@@ -33,6 +34,7 @@ module Solrengine
|
|
|
33
34
|
def configure
|
|
34
35
|
yield(configuration)
|
|
35
36
|
@client = nil
|
|
37
|
+
@ramps = nil
|
|
36
38
|
configuration
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -41,13 +43,22 @@ module Solrengine
|
|
|
41
43
|
def client
|
|
42
44
|
@client ||= ::Sdp::Client.new(
|
|
43
45
|
api_key: configuration.validate!.api_key,
|
|
44
|
-
base_url: configuration.base_url
|
|
46
|
+
base_url: configuration.base_url,
|
|
47
|
+
custody_provider: configuration.custody_provider
|
|
45
48
|
)
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
# Thin ramps helper bound to the configured client + default ramp
|
|
52
|
+
# provider. Reset alongside the client whenever configure runs.
|
|
53
|
+
# Sandbox-only in v0.2 (see Solrengine::Sdp::Ramps).
|
|
54
|
+
def ramps
|
|
55
|
+
@ramps ||= Ramps.new(client: client, provider: configuration.ramp_provider)
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
def reset_configuration!
|
|
49
59
|
@configuration = nil
|
|
50
60
|
@client = nil
|
|
61
|
+
@ramps = nil
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
# Pure function over rake task names so the exemption logic is
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solrengine-sdp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.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-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -30,14 +30,14 @@ dependencies:
|
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0.
|
|
33
|
+
version: '0.2'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0.
|
|
40
|
+
version: '0.2'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: solrengine-realtime
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -63,12 +63,20 @@ extra_rdoc_files: []
|
|
|
63
63
|
files:
|
|
64
64
|
- LICENSE
|
|
65
65
|
- README.md
|
|
66
|
+
- app/jobs/solrengine/sdp/burn_job.rb
|
|
67
|
+
- app/jobs/solrengine/sdp/mint_job.rb
|
|
66
68
|
- app/jobs/solrengine/sdp/provision_wallet_job.rb
|
|
67
69
|
- app/jobs/solrengine/sdp/track_transfer_job.rb
|
|
70
|
+
- app/models/solrengine/sdp/token.rb
|
|
71
|
+
- app/models/solrengine/sdp/token_burn.rb
|
|
72
|
+
- app/models/solrengine/sdp/token_mint.rb
|
|
68
73
|
- app/models/solrengine/sdp/transfer.rb
|
|
69
74
|
- lib/generators/solrengine/sdp/install_generator.rb
|
|
70
75
|
- lib/generators/solrengine/sdp/templates/add_solrengine_sdp_to_users.rb
|
|
71
76
|
- lib/generators/solrengine/sdp/templates/cable.yml
|
|
77
|
+
- lib/generators/solrengine/sdp/templates/create_solrengine_sdp_token_burns.rb
|
|
78
|
+
- lib/generators/solrengine/sdp/templates/create_solrengine_sdp_token_mints.rb
|
|
79
|
+
- lib/generators/solrengine/sdp/templates/create_solrengine_sdp_tokens.rb
|
|
72
80
|
- lib/generators/solrengine/sdp/templates/create_solrengine_sdp_transfers.rb
|
|
73
81
|
- lib/generators/solrengine/sdp/templates/initializer.rb
|
|
74
82
|
- lib/generators/solrengine/sdp/templates/sdp_watcher
|
|
@@ -79,6 +87,7 @@ files:
|
|
|
79
87
|
- lib/solrengine/sdp/engine.rb
|
|
80
88
|
- lib/solrengine/sdp/errors.rb
|
|
81
89
|
- lib/solrengine/sdp/faucet.rb
|
|
90
|
+
- lib/solrengine/sdp/ramps.rb
|
|
82
91
|
- lib/solrengine/sdp/version.rb
|
|
83
92
|
- lib/solrengine/sdp/wallet_owner.rb
|
|
84
93
|
homepage: https://github.com/solrengine/sdp
|