solana-ruby-kit 7.0.0 → 7.1.1
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/LICENSE +21 -0
- data/README.md +761 -0
- data/lib/solana/ruby/kit/errors.rb +24 -1
- data/lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb +53 -12
- data/lib/solana/ruby/kit/instruction_plans/transaction_plan_result.rb +33 -17
- data/lib/solana/ruby/kit/offchain_messages/codec.rb +15 -7
- data/lib/solana/ruby/kit/offchain_messages/message_v1.rb +115 -0
- data/lib/solana/ruby/kit/offchain_messages.rb +30 -3
- data/lib/solana/ruby/kit/rpc/api/get_inflation_reward.rb +4 -2
- data/lib/solana/ruby/kit/rpc/api/get_transactions_for_address.rb +208 -0
- data/lib/solana/ruby/kit/rpc/client.rb +2 -0
- data/lib/solana/ruby/kit/subscribable/bridge_store_to_async_iterable.rb +172 -0
- data/lib/solana/ruby/kit/subscribable.rb +1 -0
- data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +22 -5
- data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +5 -3
- data/lib/solana/ruby/kit/version.rb +1 -1
- data/solana-ruby-kit.gemspec +24 -12
- metadata +59 -48
|
@@ -115,6 +115,10 @@ module Solana::Ruby::Kit
|
|
|
115
115
|
OFFCHAIN_MESSAGES__NON_PRINTABLE_ASCII_CHARACTER = :SOLANA_ERROR__OFFCHAIN_MESSAGES__NON_PRINTABLE_ASCII_CHARACTER
|
|
116
116
|
OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG = :SOLANA_ERROR__OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG
|
|
117
117
|
OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN = :SOLANA_ERROR__OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN
|
|
118
|
+
# context: { actual_bytes:, expected_bytes: }
|
|
119
|
+
OFFCHAIN_MESSAGES__CONTENT_DOES_NOT_MATCH_EXPECTED = :SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED
|
|
120
|
+
# context: { actual_addresses:, expected_addresses: }
|
|
121
|
+
OFFCHAIN_MESSAGES__REQUIRED_SIGNATORIES_DO_NOT_MATCH_EXPECTED = :SOLANA_ERROR__OFFCHAIN_MESSAGE__REQUIRED_SIGNATORIES_DO_NOT_MATCH_EXPECTED
|
|
118
122
|
|
|
119
123
|
# ── Instruction plans ─────────────────────────────────────────────────────
|
|
120
124
|
# context: { num_bytes_required:, num_free_bytes: }
|
|
@@ -139,6 +143,7 @@ module Solana::Ruby::Kit
|
|
|
139
143
|
|
|
140
144
|
# ── Subscribable ──────────────────────────────────────────────────────────
|
|
141
145
|
SUBSCRIBABLE__RETRY_NOT_SUPPORTED = :SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED
|
|
146
|
+
SUBSCRIBABLE__STREAM_CLOSED_WITHOUT_ERROR = :SOLANA_ERROR__SUBSCRIBABLE__STREAM_CLOSED_WITHOUT_ERROR
|
|
142
147
|
|
|
143
148
|
# ── Fixed-points ──────────────────────────────────────────────────────────
|
|
144
149
|
FIXED_POINTS__STRICT_MODE_PRECISION_LOSS = :SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS
|
|
@@ -255,6 +260,12 @@ module Solana::Ruby::Kit
|
|
|
255
260
|
OFFCHAIN_MESSAGES__NON_PRINTABLE_ASCII_CHARACTER => 'Offchain message v0 contains non-printable ASCII character at index %{index}',
|
|
256
261
|
OFFCHAIN_MESSAGES__MESSAGE_TOO_LONG => 'Offchain message is too long (%{length} bytes, max %{max})',
|
|
257
262
|
OFFCHAIN_MESSAGES__LEADING_ZERO_IN_SIGNING_DOMAIN => 'Offchain message signing domain must not start with a null byte',
|
|
263
|
+
OFFCHAIN_MESSAGES__CONTENT_DOES_NOT_MATCH_EXPECTED => 'The content of the offchain message does not match the content that was expected. ' \
|
|
264
|
+
'Expected content with a byte-length of %{expected_bytes}; got content with a byte-length of %{actual_bytes}. ' \
|
|
265
|
+
'The signer may have signed different data than was requested; do not trust its signature.',
|
|
266
|
+
OFFCHAIN_MESSAGES__REQUIRED_SIGNATORIES_DO_NOT_MATCH_EXPECTED => 'The offchain message lists different required signatories than was expected. ' \
|
|
267
|
+
'Expected [%{expected_addresses}]. Got [%{actual_addresses}]. ' \
|
|
268
|
+
'The signer may have signed different data than was requested; do not trust its signature.',
|
|
258
269
|
|
|
259
270
|
# Instruction plans
|
|
260
271
|
INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN => 'Transaction message cannot accommodate the plan: requires %{num_bytes_required} bytes but only %{num_free_bytes} are available',
|
|
@@ -271,6 +282,7 @@ module Solana::Ruby::Kit
|
|
|
271
282
|
|
|
272
283
|
# Subscribable
|
|
273
284
|
SUBSCRIBABLE__RETRY_NOT_SUPPORTED => 'This reactive store does not support retry(); use create_reactive_store_from_data_publisher_factory for a retryable store',
|
|
285
|
+
SUBSCRIBABLE__STREAM_CLOSED_WITHOUT_ERROR => 'The stream store closed in an error state but did not report an error.',
|
|
274
286
|
|
|
275
287
|
# Fixed-points
|
|
276
288
|
FIXED_POINTS__STRICT_MODE_PRECISION_LOSS => 'Value has more than 9 fractional digits and cannot be represented exactly as a Sol amount',
|
|
@@ -300,8 +312,19 @@ module Solana::Ruby::Kit
|
|
|
300
312
|
@context = T.let(context, T::Hash[Symbol, T.untyped])
|
|
301
313
|
|
|
302
314
|
template = ERROR_MESSAGES[code] || code.to_s
|
|
303
|
-
message = context.empty? ? template : (template % context rescue "#{template} #{context}")
|
|
315
|
+
message = context.empty? ? template : (template % display_context(context) rescue "#{template} #{context}")
|
|
304
316
|
super(message)
|
|
305
317
|
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
# Values used to fill a message template. Array values — lists of addresses, say —
|
|
322
|
+
# interpolate as a comma-separated list rather than as Ruby's `inspect` output, so
|
|
323
|
+
# that `[%{expected_addresses}]` reads like the TypeScript original. `#context` is
|
|
324
|
+
# untouched and still hands back the array itself.
|
|
325
|
+
sig { params(context: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
326
|
+
def display_context(context)
|
|
327
|
+
context.transform_values { |value| value.is_a?(Array) ? value.join(', ') : value }
|
|
328
|
+
end
|
|
306
329
|
end
|
|
307
330
|
end
|
|
@@ -13,11 +13,22 @@ module Solana::Ruby::Kit
|
|
|
13
13
|
# executing each single transaction message and collecting results.
|
|
14
14
|
#
|
|
15
15
|
# Configuration:
|
|
16
|
-
# execute_transaction_message: ->(message) {
|
|
17
|
-
# Called
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
16
|
+
# execute_transaction_message: ->(context, message) { context_to_report }
|
|
17
|
+
# Called once per SingleTransactionPlan with a fresh, mutable +context+ Hash and
|
|
18
|
+
# the transaction message to execute. Store data on +context+ as execution
|
|
19
|
+
# progresses, and return the context a successful result should carry. The two
|
|
20
|
+
# serve different outcomes: what you *store* reaches a failed or canceled result,
|
|
21
|
+
# what you *return* reaches a successful one. On success the returned Hash is
|
|
22
|
+
# merged over the stored one, the returned value winning, so anything recorded
|
|
23
|
+
# but left out of the return value is still reported.
|
|
24
|
+
#
|
|
25
|
+
# Raise a SolanaError to signal failure; remaining plans will be canceled. The
|
|
26
|
+
# context accumulated up to the point of failure is preserved on the resulting
|
|
27
|
+
# failed result, which is useful for debugging or building recovery plans.
|
|
28
|
+
#
|
|
29
|
+
# A one-argument callable is still accepted for backwards compatibility with the
|
|
30
|
+
# shape this method used to require — `->(message) { { transaction:, context: } }`
|
|
31
|
+
# — and is adapted onto the context flow above.
|
|
21
32
|
#
|
|
22
33
|
# The returned executor is a lambda: executor.call(transaction_plan) -> TransactionPlanResult
|
|
23
34
|
#
|
|
@@ -77,26 +88,55 @@ module Solana::Ruby::Kit
|
|
|
77
88
|
end
|
|
78
89
|
|
|
79
90
|
def executor_traverse_single(plan, execute_fn, state)
|
|
80
|
-
|
|
91
|
+
# A fresh context per single transaction plan. Nothing is populated yet — filling
|
|
92
|
+
# it in is the callback's job, which is what lets a partial context survive an error.
|
|
93
|
+
context = {}
|
|
94
|
+
return canceled_single_transaction_plan_result(plan.message, context) if state[:canceled]
|
|
81
95
|
|
|
82
96
|
begin
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
returned = executor_invoke(execute_fn, context, plan.message)
|
|
98
|
+
# The callback told us what the successful result should carry, so take it as-is
|
|
99
|
+
# and derive nothing from it. Anything it stored but left out of the return value
|
|
100
|
+
# is kept, since dropping it would lose data it deliberately recorded.
|
|
101
|
+
successful_single_transaction_plan_result(plan.message, nil, context.merge(returned))
|
|
87
102
|
rescue SolanaError => e
|
|
88
103
|
state[:canceled] = true
|
|
89
|
-
failed_single_transaction_plan_result(plan.message, e)
|
|
104
|
+
failed_single_transaction_plan_result(plan.message, e, context)
|
|
90
105
|
rescue => e
|
|
91
106
|
state[:canceled] = true
|
|
92
107
|
wrapped = SolanaError.new(
|
|
93
108
|
SolanaError::INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN,
|
|
94
109
|
{ cause: e }
|
|
95
110
|
)
|
|
96
|
-
failed_single_transaction_plan_result(plan.message, wrapped)
|
|
111
|
+
failed_single_transaction_plan_result(plan.message, wrapped, context)
|
|
97
112
|
end
|
|
98
113
|
end
|
|
99
114
|
|
|
115
|
+
# Calls the configured callback, adapting the legacy one-argument shape onto the
|
|
116
|
+
# context flow. Returns the Hash the successful result should carry.
|
|
117
|
+
sig do
|
|
118
|
+
params(
|
|
119
|
+
execute_fn: T.untyped,
|
|
120
|
+
context: T::Hash[T.untyped, T.untyped],
|
|
121
|
+
message: TransactionMessages::TransactionMessage
|
|
122
|
+
).returns(T::Hash[T.untyped, T.untyped])
|
|
123
|
+
end
|
|
124
|
+
def executor_invoke(execute_fn, context, message)
|
|
125
|
+
unless execute_fn.arity == 1
|
|
126
|
+
returned = execute_fn.call(context, message)
|
|
127
|
+
return returned.is_a?(Hash) ? returned : {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Legacy shape: `->(message) { { transaction:, context: } }`. There is no mutable
|
|
131
|
+
# context to record into, so everything it reports arrives at once on the way out.
|
|
132
|
+
legacy = execute_fn.call(message)
|
|
133
|
+
legacy = {} unless legacy.is_a?(Hash)
|
|
134
|
+
returned = legacy.fetch(:context, {}).dup
|
|
135
|
+
transaction = legacy[:transaction]
|
|
136
|
+
returned[:transaction] = transaction if transaction
|
|
137
|
+
returned
|
|
138
|
+
end
|
|
139
|
+
|
|
100
140
|
def executor_find_error(result)
|
|
101
141
|
return result.status.error if result.kind == :single && result.status.kind == :failed
|
|
102
142
|
return nil if result.kind == :single
|
|
@@ -112,6 +152,7 @@ module Solana::Ruby::Kit
|
|
|
112
152
|
private_class_method :executor_traverse_sequential
|
|
113
153
|
private_class_method :executor_traverse_parallel
|
|
114
154
|
private_class_method :executor_traverse_single
|
|
155
|
+
private_class_method :executor_invoke
|
|
115
156
|
private_class_method :executor_find_error
|
|
116
157
|
end
|
|
117
158
|
end
|
|
@@ -13,23 +13,27 @@ module Solana::Ruby::Kit
|
|
|
13
13
|
#
|
|
14
14
|
# Each single transaction produces one of three statuses:
|
|
15
15
|
# successful: { kind: :successful, transaction:, context: }
|
|
16
|
-
# failed: { kind: :failed, error: }
|
|
17
|
-
# canceled: { kind: :canceled }
|
|
16
|
+
# failed: { kind: :failed, error:, context: }
|
|
17
|
+
# canceled: { kind: :canceled, context: }
|
|
18
18
|
#
|
|
19
|
-
#
|
|
19
|
+
# Every status carries a context, so whatever the executor callback recorded
|
|
20
|
+
# before it failed — or before an earlier failure canceled it — survives into
|
|
21
|
+
# the result. Mirrors TypeScript's TransactionPlanResultStatus union.
|
|
20
22
|
|
|
21
23
|
class SuccessfulStatus < T::Struct
|
|
22
|
-
const :transaction, Transactions::Transaction
|
|
24
|
+
const :transaction, T.nilable(Transactions::Transaction)
|
|
23
25
|
const :context, T::Hash[T.untyped, T.untyped]
|
|
24
26
|
def kind = :successful
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
class FailedStatus < T::Struct
|
|
28
|
-
const :error,
|
|
30
|
+
const :error, SolanaError
|
|
31
|
+
const :context, T::Hash[T.untyped, T.untyped], default: {}.freeze
|
|
29
32
|
def kind = :failed
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
class CanceledStatus < T::Struct
|
|
36
|
+
const :context, T::Hash[T.untyped, T.untyped], default: {}.freeze
|
|
33
37
|
def kind = :canceled
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -74,43 +78,55 @@ module Solana::Ruby::Kit
|
|
|
74
78
|
ParallelTransactionPlanResult.new(plans: plans)
|
|
75
79
|
end
|
|
76
80
|
|
|
77
|
-
# Mirrors `successfulSingleTransactionPlanResult(message,
|
|
81
|
+
# Mirrors `successfulSingleTransactionPlanResult(message, context)`.
|
|
82
|
+
#
|
|
83
|
+
# Upstream carries the transaction inside the context; Ruby keeps the separate
|
|
84
|
+
# +transaction+ reader it has always exposed. Pass it either way — as the positional
|
|
85
|
+
# argument, or under +:transaction+ in the context, where the reader picks it up.
|
|
86
|
+
# Nothing is written back into the context, which stays exactly what was passed.
|
|
78
87
|
sig do
|
|
79
88
|
params(
|
|
80
89
|
message: TransactionMessages::TransactionMessage,
|
|
81
|
-
transaction: Transactions::Transaction,
|
|
90
|
+
transaction: T.nilable(Transactions::Transaction),
|
|
82
91
|
context: T::Hash[T.untyped, T.untyped]
|
|
83
92
|
).returns(SingleTransactionPlanResult)
|
|
84
93
|
end
|
|
85
|
-
def successful_single_transaction_plan_result(message, transaction, context = {})
|
|
94
|
+
def successful_single_transaction_plan_result(message, transaction = nil, context = {})
|
|
95
|
+
from_context = context[:transaction]
|
|
96
|
+
transaction ||= from_context if from_context.is_a?(Transactions::Transaction)
|
|
97
|
+
|
|
86
98
|
SingleTransactionPlanResult.new(
|
|
87
99
|
message: message,
|
|
88
|
-
status: SuccessfulStatus.new(transaction: transaction, context: context)
|
|
100
|
+
status: SuccessfulStatus.new(transaction: transaction, context: context.dup.freeze)
|
|
89
101
|
)
|
|
90
102
|
end
|
|
91
103
|
|
|
92
|
-
# Mirrors `failedSingleTransactionPlanResult(message, error)`.
|
|
104
|
+
# Mirrors `failedSingleTransactionPlanResult(message, error, context)`.
|
|
93
105
|
sig do
|
|
94
106
|
params(
|
|
95
107
|
message: TransactionMessages::TransactionMessage,
|
|
96
|
-
error: SolanaError
|
|
108
|
+
error: SolanaError,
|
|
109
|
+
context: T::Hash[T.untyped, T.untyped]
|
|
97
110
|
).returns(SingleTransactionPlanResult)
|
|
98
111
|
end
|
|
99
|
-
def failed_single_transaction_plan_result(message, error)
|
|
112
|
+
def failed_single_transaction_plan_result(message, error, context = {})
|
|
100
113
|
SingleTransactionPlanResult.new(
|
|
101
114
|
message: message,
|
|
102
|
-
status: FailedStatus.new(error: error)
|
|
115
|
+
status: FailedStatus.new(error: error, context: context.dup.freeze)
|
|
103
116
|
)
|
|
104
117
|
end
|
|
105
118
|
|
|
106
|
-
# Mirrors `canceledSingleTransactionPlanResult(message)`.
|
|
119
|
+
# Mirrors `canceledSingleTransactionPlanResult(message, context)`.
|
|
107
120
|
sig do
|
|
108
|
-
params(
|
|
121
|
+
params(
|
|
122
|
+
message: TransactionMessages::TransactionMessage,
|
|
123
|
+
context: T::Hash[T.untyped, T.untyped]
|
|
124
|
+
).returns(SingleTransactionPlanResult)
|
|
109
125
|
end
|
|
110
|
-
def canceled_single_transaction_plan_result(message)
|
|
126
|
+
def canceled_single_transaction_plan_result(message, context = {})
|
|
111
127
|
SingleTransactionPlanResult.new(
|
|
112
128
|
message: message,
|
|
113
|
-
status: CanceledStatus.new
|
|
129
|
+
status: CanceledStatus.new(context: context.dup.freeze)
|
|
114
130
|
)
|
|
115
131
|
end
|
|
116
132
|
end
|
|
@@ -31,8 +31,12 @@ module Solana::Ruby::Kit
|
|
|
31
31
|
buf << domain_b
|
|
32
32
|
buf << [msg.version].pack('C')
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# The v1 application-domain block is always emitted, using a zero length when
|
|
35
|
+
# there is no application domain. The decoder reads this length unconditionally
|
|
36
|
+
# for v1, so omitting the block would desynchronise it and make it read the
|
|
37
|
+
# message length as an application-domain length.
|
|
38
|
+
if msg.version >= 1
|
|
39
|
+
app_b = msg.application_domain ? T.must(msg.application_domain).encode('ASCII').b : ''.b
|
|
36
40
|
Kernel.raise ArgumentError, 'Application domain exceeds 65535 bytes' if app_b.bytesize > 0xFFFF
|
|
37
41
|
|
|
38
42
|
buf << [app_b.bytesize].pack('v')
|
|
@@ -62,7 +66,9 @@ module Solana::Ruby::Kit
|
|
|
62
66
|
if version >= 1
|
|
63
67
|
app_len = b.byteslice(offset, 2)&.unpack1('v') || 0
|
|
64
68
|
offset += 2
|
|
65
|
-
|
|
69
|
+
# A zero length means there was no application domain, not an empty one, so
|
|
70
|
+
# that encode/decode round-trips a message that never had one.
|
|
71
|
+
application_domain = app_len.positive? ? b.byteslice(offset, app_len)&.force_encoding('ASCII') : nil
|
|
66
72
|
offset += app_len
|
|
67
73
|
end
|
|
68
74
|
|
|
@@ -94,12 +100,14 @@ module Solana::Ruby::Kit
|
|
|
94
100
|
).returns(T::Boolean)
|
|
95
101
|
end
|
|
96
102
|
def verify_offchain_message_signature(verify_key, signature, msg)
|
|
97
|
-
payload
|
|
98
|
-
vk
|
|
99
|
-
|
|
103
|
+
payload = encode_offchain_message(msg)
|
|
104
|
+
vk = RbNaCl::VerifyKey.new(verify_key)
|
|
105
|
+
# `Signature` holds a base58 string (that is what `sign_offchain_message` returns),
|
|
106
|
+
# so it has to be decoded back to the 64 raw bytes RbNaCl expects.
|
|
107
|
+
sig_bytes = Solana::Ruby::Kit::Encoding::Base58.decode(signature.to_s)
|
|
100
108
|
vk.verify(sig_bytes, payload)
|
|
101
109
|
true
|
|
102
|
-
rescue RbNaCl::BadSignatureError
|
|
110
|
+
rescue RbNaCl::BadSignatureError, RbNaCl::LengthError
|
|
103
111
|
false
|
|
104
112
|
end
|
|
105
113
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
require_relative '../addresses/address'
|
|
6
|
+
|
|
7
|
+
module Solana::Ruby::Kit
|
|
8
|
+
module OffchainMessages
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# An address that is required to sign an offchain message for it to be valid.
|
|
12
|
+
# Mirrors TypeScript's `OffchainMessageSignatory`.
|
|
13
|
+
class Signatory < T::Struct
|
|
14
|
+
const :address, Addresses::Address
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A version 1 offchain message.
|
|
18
|
+
#
|
|
19
|
+
# Mirrors TypeScript's `OffchainMessageV1` — the shape from the newer
|
|
20
|
+
# `@solana/offchain-messages` package, whose content is UTF-8 and whose preamble
|
|
21
|
+
# lists the addresses required to sign it. This is a different type from the
|
|
22
|
+
# legacy Message in message.rb, which models the older `@solana/signers`
|
|
23
|
+
# domain/application-domain form; the two are not interchangeable.
|
|
24
|
+
class MessageV1 < T::Struct
|
|
25
|
+
# UTF-8 message content.
|
|
26
|
+
const :content, String
|
|
27
|
+
|
|
28
|
+
# Addresses required to sign this message. The offchain message specification
|
|
29
|
+
# mandates that these be serialized in lexicographic order, so a decoded message
|
|
30
|
+
# always lists them that way; one you build yourself may list them in any order.
|
|
31
|
+
const :required_signatories, T::Array[Signatory], default: [].freeze
|
|
32
|
+
|
|
33
|
+
extend T::Sig
|
|
34
|
+
|
|
35
|
+
sig { returns(Integer) }
|
|
36
|
+
def version = 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Asserts that a version 1 offchain message received from an untrusted source is the
|
|
42
|
+
# message it was expected to be.
|
|
43
|
+
#
|
|
44
|
+
# A signer (a wallet, say) returns the message bytes it signed alongside its signature.
|
|
45
|
+
# Verifying that signature proves only that the signer produced it over *those* bytes;
|
|
46
|
+
# it says nothing about whether those bytes represent the message that was asked for.
|
|
47
|
+
# Use this to establish that they do, then verify the signature separately.
|
|
48
|
+
#
|
|
49
|
+
# Perform this assertion *before* verifying signatures. A signer that signed the wrong
|
|
50
|
+
# message would otherwise surface as a signature verification failure, misattributing
|
|
51
|
+
# the problem to the cryptography rather than to the content.
|
|
52
|
+
#
|
|
53
|
+
# Required signatories are compared without regard to order — both lists are sorted
|
|
54
|
+
# first, and reported sorted so they can be compared by eye. Order is the only thing
|
|
55
|
+
# ignored: the lists are otherwise compared element by element, so listing an address
|
|
56
|
+
# twice on one side and once on the other is a mismatch rather than a no-op.
|
|
57
|
+
#
|
|
58
|
+
# Message content is never placed in the error context, since it can carry data better
|
|
59
|
+
# kept out of logs and error reporting. Its length in UTF-8 bytes — the encoding in
|
|
60
|
+
# which version 1 content is serialized — is reported instead.
|
|
61
|
+
#
|
|
62
|
+
# Raises SolanaError OFFCHAIN_MESSAGES__CONTENT_DOES_NOT_MATCH_EXPECTED if the two
|
|
63
|
+
# messages' contents differ, or
|
|
64
|
+
# OFFCHAIN_MESSAGES__REQUIRED_SIGNATORIES_DO_NOT_MATCH_EXPECTED if they require
|
|
65
|
+
# signatures from different addresses. Content is checked first, so a message that
|
|
66
|
+
# differs in both reports the content mismatch.
|
|
67
|
+
#
|
|
68
|
+
# Mirrors `assertOffchainMessageV1Equal(receivedMessage, expectedMessage)`.
|
|
69
|
+
sig { params(received_message: MessageV1, expected_message: MessageV1).void }
|
|
70
|
+
def assert_offchain_message_v1_equal(received_message, expected_message)
|
|
71
|
+
if received_message.content != expected_message.content
|
|
72
|
+
Kernel.raise SolanaError.new(
|
|
73
|
+
SolanaError::OFFCHAIN_MESSAGES__CONTENT_DOES_NOT_MATCH_EXPECTED,
|
|
74
|
+
{
|
|
75
|
+
actual_bytes: utf8_byte_length(received_message.content),
|
|
76
|
+
expected_bytes: utf8_byte_length(expected_message.content)
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
actual_addresses = sorted_signatory_addresses(received_message)
|
|
82
|
+
expected_addresses = sorted_signatory_addresses(expected_message)
|
|
83
|
+
return if actual_addresses == expected_addresses
|
|
84
|
+
|
|
85
|
+
Kernel.raise SolanaError.new(
|
|
86
|
+
SolanaError::OFFCHAIN_MESSAGES__REQUIRED_SIGNATORIES_DO_NOT_MATCH_EXPECTED,
|
|
87
|
+
{
|
|
88
|
+
actual_addresses: actual_addresses.map(&:to_s),
|
|
89
|
+
expected_addresses: expected_addresses.map(&:to_s)
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ── Private helpers ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
sig { params(message: MessageV1).returns(T::Array[Addresses::Address]) }
|
|
97
|
+
def sorted_signatory_addresses(message)
|
|
98
|
+
message.required_signatories.map(&:address).sort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Length of a string in UTF-8 bytes, which is how version 1 content is serialized.
|
|
102
|
+
# Ruby strings carry their own encoding, so one that arrived as anything else is
|
|
103
|
+
# converted first rather than having its raw bytes counted.
|
|
104
|
+
#
|
|
105
|
+
# NOTE: `::Encoding` must be spelled with the leading `::`. Bare `Encoding` resolves
|
|
106
|
+
# to Solana::Ruby::Kit::Encoding — this gem's own base58 namespace — not Ruby's.
|
|
107
|
+
sig { params(content: String).returns(Integer) }
|
|
108
|
+
def utf8_byte_length(content)
|
|
109
|
+
content.encoding == ::Encoding::UTF_8 ? content.bytesize : content.encode(::Encoding::UTF_8).bytesize
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method :sorted_signatory_addresses
|
|
113
|
+
private_class_method :utf8_byte_length
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -3,14 +3,41 @@
|
|
|
3
3
|
|
|
4
4
|
require 'rbnacl'
|
|
5
5
|
|
|
6
|
-
# Mirrors @solana/signers off-chain message signing
|
|
6
|
+
# Mirrors @solana/signers off-chain message signing, plus the version 1 message
|
|
7
|
+
# comparison helper from @solana/offchain-messages.
|
|
7
8
|
require_relative 'offchain_messages/message'
|
|
9
|
+
require_relative 'offchain_messages/message_v1'
|
|
8
10
|
require_relative 'offchain_messages/codec'
|
|
9
11
|
|
|
10
12
|
module Solana::Ruby::Kit
|
|
11
13
|
module OffchainMessages
|
|
12
|
-
# Re-export codec helpers at module level for convenience.
|
|
13
14
|
extend T::Sig
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
# Re-export codec helpers at module level for convenience.
|
|
17
|
+
#
|
|
18
|
+
# NOTE: `extend Codec` does not work here. Codec declares its helpers with
|
|
19
|
+
# `module_function`, which makes the instance-method copies private, and `extend`
|
|
20
|
+
# only imports public instance methods — so it would silently import nothing.
|
|
21
|
+
# Delegate to Codec's module methods explicitly instead.
|
|
22
|
+
|
|
23
|
+
sig { params(msg: Message).returns(String) }
|
|
24
|
+
def self.encode_offchain_message(msg) = Codec.encode_offchain_message(msg)
|
|
25
|
+
|
|
26
|
+
sig { params(bytes: String).returns(Message) }
|
|
27
|
+
def self.decode_offchain_message(bytes) = Codec.decode_offchain_message(bytes)
|
|
28
|
+
|
|
29
|
+
sig { params(signer: Signers::KeyPairSigner, msg: Message).returns(Keys::Signature) }
|
|
30
|
+
def self.sign_offchain_message(signer, msg) = Codec.sign_offchain_message(signer, msg)
|
|
31
|
+
|
|
32
|
+
sig do
|
|
33
|
+
params(
|
|
34
|
+
verify_key: String,
|
|
35
|
+
signature: Keys::Signature,
|
|
36
|
+
msg: Message
|
|
37
|
+
).returns(T::Boolean)
|
|
38
|
+
end
|
|
39
|
+
def self.verify_offchain_message_signature(verify_key, signature, msg)
|
|
40
|
+
Codec.verify_offchain_message_signature(verify_key, signature, msg)
|
|
41
|
+
end
|
|
15
42
|
end
|
|
16
43
|
end
|
|
@@ -9,7 +9,9 @@ module Solana::Ruby::Kit
|
|
|
9
9
|
InflationReward = T.let(
|
|
10
10
|
Struct.new(
|
|
11
11
|
:amount, # Integer (Lamports) — reward credited
|
|
12
|
-
:commission, # Integer — vote account commission at reward time
|
|
12
|
+
:commission, # Integer, nilable — vote account commission at reward time;
|
|
13
|
+
# null once the vote account reports commission through
|
|
14
|
+
# getVoteAccounts' inflationRewardsCommissionBps instead
|
|
13
15
|
:effective_slot, # Integer — slot in which rewards were delivered
|
|
14
16
|
:epoch, # Integer — epoch for which reward occurred
|
|
15
17
|
:post_balance, # Integer (Lamports) — post-reward account balance
|
|
@@ -46,7 +48,7 @@ module Solana::Ruby::Kit
|
|
|
46
48
|
|
|
47
49
|
InflationReward.new(
|
|
48
50
|
amount: Kernel.Integer(entry['amount']),
|
|
49
|
-
commission: Kernel.Integer(entry['commission']),
|
|
51
|
+
commission: entry['commission'] ? Kernel.Integer(entry['commission']) : nil,
|
|
50
52
|
effective_slot: Kernel.Integer(entry['effectiveSlot']),
|
|
51
53
|
epoch: Kernel.Integer(entry['epoch']),
|
|
52
54
|
post_balance: Kernel.Integer(entry['postBalance'])
|