solana-ruby-kit 8.3.0 → 8.5.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 +92 -2
- data/lib/solana/ruby/kit/codecs/codec.rb +20 -4
- data/lib/solana/ruby/kit/codecs/data_structures.rb +235 -64
- data/lib/solana/ruby/kit/codecs/numbers.rb +47 -11
- data/lib/solana/ruby/kit/codecs/strings.rb +11 -2
- data/lib/solana/ruby/kit/errors.rb +17 -1
- data/lib/solana/ruby/kit/instruction_plans/instruction_plan.rb +30 -25
- data/lib/solana/ruby/kit/instruction_plans/message_packer_errors.rb +131 -0
- data/lib/solana/ruby/kit/instruction_plans/transaction_planner.rb +9 -12
- data/lib/solana/ruby/kit/instruction_plans.rb +1 -1
- data/lib/solana/ruby/kit/keys/signatures.rb +13 -0
- data/lib/solana/ruby/kit/keys/small_order.rb +46 -0
- data/lib/solana/ruby/kit/offchain_messages/codec.rb +3 -3
- data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +7 -6
- data/lib/solana/ruby/kit/version.rb +1 -1
- data/lib/solana/ruby/kit/wallet_standard.rb +7 -6
- data/solana-ruby-kit.gemspec +1 -1
- metadata +7 -6
- data/lib/solana/ruby/kit/instruction_plans/max_instructions.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3067fc0036448f0aef241f14f4e0b95f0faf6d6cedd4ed8aae5206d6e3169e28
|
|
4
|
+
data.tar.gz: a689c349c2e41d519b6e06814319d9760b2816bf59f48109d8d839790c4e6312
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a02f7ce5743af32d1d6401f2023fc7293e33abfe1c0f495ec91f00a7ca4399e6fd654c7baa3f3c966ed34d6495514607432315323fb3425f294e8bf1713035ae
|
|
7
|
+
data.tar.gz: bfd06cf91f1fc1652cb5a578b822595c5f976537561fddbad22e04e6dc5567f1284b211b4e41be6472426005f3f4b0197c486fe5d87a046e2dac8989e30acb61
|
data/README.md
CHANGED
|
@@ -133,6 +133,9 @@ kp = Keys.create_key_pair_from_private_key_bytes(seed_bytes)
|
|
|
133
133
|
# Sign / verify
|
|
134
134
|
sig = Keys.sign_bytes(kp.signing_key, data)
|
|
135
135
|
ok = Keys.verify_signature(kp.verify_key, sig, data)
|
|
136
|
+
# verify_signature is strict: a small-order public key or signature R
|
|
137
|
+
# component returns false (as the Solana runtime does), and passing anything
|
|
138
|
+
# other than an RbNaCl::VerifyKey raises KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY.
|
|
136
139
|
|
|
137
140
|
# Base58-encode a signature
|
|
138
141
|
Keys.encode_signature(sig) # => Signature string
|
|
@@ -379,12 +382,17 @@ u16.decode("\xe8\x03") # => [1000, 2] # [value, bytes consumed]
|
|
|
379
382
|
# Strings
|
|
380
383
|
utf8 = Codecs.utf8_codec
|
|
381
384
|
bytes = Codecs.bytes_codec(32)
|
|
385
|
+
# A borsh-style string: a u32 byte length, then that many UTF-8 bytes.
|
|
386
|
+
name = Codecs.add_codec_size_prefix(utf8, u32)
|
|
382
387
|
|
|
383
388
|
# Data structures
|
|
384
389
|
struct_codec = Codecs.struct_codec([
|
|
385
390
|
['amount', u64],
|
|
391
|
+
['name', name],
|
|
386
392
|
['mint', bytes]
|
|
387
393
|
])
|
|
394
|
+
struct_codec.decode(struct_codec.encode({ amount: 5, name: 'gold', mint: "\x01".b * 32 }))
|
|
395
|
+
# => [{ amount: 5, name: "gold", mint: "\x01\x01..." }, 48]
|
|
388
396
|
```
|
|
389
397
|
|
|
390
398
|
#### UTF-8 options
|
|
@@ -412,6 +420,25 @@ Codecs.remove_null_characters("a\x00b") # => "ab"
|
|
|
412
420
|
Note that `ignore_bom` follows `TextDecoder`'s confusing spelling: the default,
|
|
413
421
|
`false`, *strips* the mark; `true` keeps it.
|
|
414
422
|
|
|
423
|
+
#### Collection sizes
|
|
424
|
+
|
|
425
|
+
`array_codec`, `map_codec` and `set_codec` take a `size:` that sets how the item
|
|
426
|
+
count is stored:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
Codecs.array_codec(u8).encode([1, 2, 3]) # => "\x03\x00\x00\x00\x01\x02\x03" (u32 count)
|
|
430
|
+
Codecs.array_codec(u8, size: u8).encode([1, 2, 3]) # => "\x03\x01\x02\x03" (any number codec as the count)
|
|
431
|
+
Codecs.array_codec(u8, size: Codecs.compact_u16_codec).encode([1]) # => "\x01\x01" (Solana's shortU16)
|
|
432
|
+
Codecs.array_codec(u8, size: 3).encode([1, 2, 3]) # => "\x01\x02\x03" (fixed count, no prefix)
|
|
433
|
+
Codecs.array_codec(u8, size: 3).encode([1, 2]) # raises SolanaError (wrong number of items)
|
|
434
|
+
Codecs.array_codec(u8, size: :remainder).encode([1, 2, 3]) # => "\x01\x02\x03" (no prefix...)
|
|
435
|
+
Codecs.array_codec(u8, size: :remainder).decode("\x01\x02\x03".b) # => [[1, 2, 3], 3] (...reads to the end)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
A `:remainder` array must be the last thing in the buffer. Upstream spells it
|
|
439
|
+
`'remainder'`; Ruby uses the Symbol. The fifth option, a `Codecs::SentinelSize`,
|
|
440
|
+
is described below.
|
|
441
|
+
|
|
415
442
|
#### Requiring a size prefix
|
|
416
443
|
|
|
417
444
|
A prefixed collection decodes an exhausted buffer to an empty collection rather
|
|
@@ -424,8 +451,39 @@ Codecs.array_codec(u8).decode(''.b) # => [[], 0]
|
|
|
424
451
|
Codecs.array_codec(u8, require_size_prefix: true).decode(''.b) # raises SolanaError
|
|
425
452
|
```
|
|
426
453
|
|
|
427
|
-
`map_codec` and `set_codec` take the same option
|
|
428
|
-
|
|
454
|
+
`map_codec` and `set_codec` take the same option, and it applies to a custom
|
|
455
|
+
count codec too. It has no effect on fixed-count, `:remainder` or
|
|
456
|
+
sentinel-terminated collections, none of which carries a prefix.
|
|
457
|
+
|
|
458
|
+
#### Sentinel-terminated collections
|
|
459
|
+
|
|
460
|
+
Instead of a length prefix, `size:` can be a `Codecs::SentinelSize`: the
|
|
461
|
+
collection ends where the bytes at the next item position match the sentinel.
|
|
462
|
+
It is compared at item boundaries only, so its bytes may appear *inside* an item.
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
zero = Codecs::SentinelSize.new(sentinel: "\x00".b)
|
|
466
|
+
list = Codecs.array_codec(u8, size: zero)
|
|
467
|
+
list.encode([1, 2, 3]) # => "\x01\x02\x03\x00"
|
|
468
|
+
list.decode("\x01\x02\x03\x00".b) # => [[1, 2, 3], 4]
|
|
469
|
+
list.decode("\x01\x02".b) # raises SolanaError (sentinel missing)
|
|
470
|
+
|
|
471
|
+
# :optional still writes the sentinel but tolerates its absence on decode;
|
|
472
|
+
# :omitted never writes it. Both stop at the end of the bytes.
|
|
473
|
+
lenient = Codecs.array_codec(u8, size: Codecs::SentinelSize.new(sentinel: "\x00".b, strategy: :optional))
|
|
474
|
+
lenient.decode("\x01\x02".b) # => [[1, 2], 2]
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`map_codec` and `set_codec` accept the same `size:`. The codec does **not**
|
|
478
|
+
check two invariants, so it is up to you to make sure they hold. First, no item
|
|
479
|
+
may *begin* with the sentinel's bytes, or decoding stops early at that item.
|
|
480
|
+
Second, under `:optional` / `:omitted` the sentinel must be no wider than the
|
|
481
|
+
smallest item, or a short trailing item is silently dropped.
|
|
482
|
+
|
|
483
|
+
An item codec with a fixed size of zero (e.g. `struct_codec([])`) can never
|
|
484
|
+
reach a sentinel or the end of the bytes, so pairing one with a `SentinelSize`
|
|
485
|
+
or `:remainder` raises `CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY`
|
|
486
|
+
when the codec is built. Explicit counts and prefixes still allow it.
|
|
429
487
|
|
|
430
488
|
#### Tap combinators
|
|
431
489
|
|
|
@@ -745,6 +803,38 @@ Plans.get_linear_message_packer_instruction_plan(
|
|
|
745
803
|
total_length: data.bytesize,
|
|
746
804
|
get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
|
|
747
805
|
)
|
|
806
|
+
|
|
807
|
+
# ── 5. Custom message packers ────────────────────────────────────────────────
|
|
808
|
+
# A custom packer enforces the same limits the built-in ones do, and can refuse a
|
|
809
|
+
# message for a reason of its own with MESSAGE_REJECTED_BY_PACKER. The planner
|
|
810
|
+
# treats all three errors as "try another message"
|
|
811
|
+
# (Plans.message_packer_error_that_requires_new_candidate?).
|
|
812
|
+
one_per_message = Plans::MessagePackerInstructionPlan.new(
|
|
813
|
+
get_message_packer: -> {
|
|
814
|
+
remaining = [ix1, ix2]
|
|
815
|
+
Plans::MessagePacker.new(
|
|
816
|
+
done_proc: -> { remaining.empty? },
|
|
817
|
+
pack_proc: ->(message, max_instructions) {
|
|
818
|
+
max = Plans.resolve_max_instructions_per_transaction(max_instructions)
|
|
819
|
+
Plans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
|
|
820
|
+
unless message.instructions.empty?
|
|
821
|
+
raise Kit::SolanaError.new(
|
|
822
|
+
Kit::SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER,
|
|
823
|
+
{ reason: 'these instructions must each have a transaction to themselves' }
|
|
824
|
+
)
|
|
825
|
+
end
|
|
826
|
+
next_message = Kit::TransactionMessages.append_instructions(message, [remaining.first])
|
|
827
|
+
Plans.assert_message_can_accommodate_size(
|
|
828
|
+
current_size: Kit::Transactions.get_transaction_message_size(message),
|
|
829
|
+
next_size: Kit::Transactions.get_transaction_message_size(next_message),
|
|
830
|
+
size_limit: Kit::Transactions::TRANSACTION_SIZE_LIMIT
|
|
831
|
+
)
|
|
832
|
+
remaining.shift
|
|
833
|
+
next_message
|
|
834
|
+
}
|
|
835
|
+
)
|
|
836
|
+
}
|
|
837
|
+
)
|
|
748
838
|
```
|
|
749
839
|
|
|
750
840
|
### `Solana::Ruby::Kit::WalletStandard` — `@solana/wallet-standard`
|
|
@@ -88,17 +88,33 @@ module Solana::Ruby::Kit
|
|
|
88
88
|
|
|
89
89
|
# Prefix encoded data with its byte length using +prefix_codec+
|
|
90
90
|
# (typically a u32 little-endian codec).
|
|
91
|
+
#
|
|
92
|
+
# On decode the inner codec sees exactly the prefixed number of bytes, as
|
|
93
|
+
# upstream's `addDecoderSizePrefix` does - so a variable-size inner codec
|
|
94
|
+
# such as +utf8_codec+ stops at its own end instead of reading the rest of
|
|
95
|
+
# the buffer. Fewer bytes than the prefix promises raise
|
|
96
|
+
# +CODECS__INVALID_BYTE_LENGTH+.
|
|
91
97
|
sig { params(codec: Codec, prefix_codec: Codec).returns(Codec) }
|
|
92
98
|
def add_codec_size_prefix(codec, prefix_codec)
|
|
93
|
-
|
|
99
|
+
inner_size = codec.fixed_size
|
|
100
|
+
fixed = inner_size && prefix_codec.fixed_size && (inner_size + T.must(prefix_codec.fixed_size))
|
|
101
|
+
enc = Encoder.new(fixed_size: fixed) do |value|
|
|
94
102
|
data = codec.encode(value)
|
|
95
103
|
prefix = prefix_codec.encode(data.bytesize)
|
|
96
104
|
prefix + data
|
|
97
105
|
end
|
|
98
|
-
dec = Decoder.new do |bytes, offset|
|
|
106
|
+
dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
|
|
99
107
|
len, prefix_size = prefix_codec.decode(bytes, offset: offset)
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
len = Kernel.Integer(len)
|
|
109
|
+
data = bytes.byteslice(offset + prefix_size, len) || ''.b
|
|
110
|
+
if data.bytesize < len
|
|
111
|
+
Kernel.raise SolanaError.new(
|
|
112
|
+
SolanaError::CODECS__INVALID_BYTE_LENGTH,
|
|
113
|
+
{ codec_description: 'addDecoderSizePrefix', expected: len, actual: data.bytesize }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
value, = codec.decode(data)
|
|
117
|
+
[value, prefix_size + len]
|
|
102
118
|
end
|
|
103
119
|
Codec.new(enc, dec)
|
|
104
120
|
end
|
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
|
|
4
4
|
module Solana::Ruby::Kit
|
|
5
5
|
module Codecs
|
|
6
|
+
# A size strategy for +array_codec+ / +map_codec+ / +set_codec+ where the
|
|
7
|
+
# collection ends when the bytes at the next item position match a constant
|
|
8
|
+
# +sentinel+. Mirrors upstream's `ArrayLikeCodecSentinelSize`
|
|
9
|
+
# (`{ __kind: 'sentinel', sentinel, strategy? }`), itself a mirror of
|
|
10
|
+
# Codama's `sentinelCountNode`.
|
|
11
|
+
#
|
|
12
|
+
# Unlike a sentinel searched for in the byte stream, this one is compared at
|
|
13
|
+
# item boundaries only, so its bytes may occur *inside* an item without
|
|
14
|
+
# terminating the collection.
|
|
15
|
+
#
|
|
16
|
+
# +strategy+ controls whether the sentinel is written and required:
|
|
17
|
+
# - +:required+ (default) - written after the last item; decoding raises
|
|
18
|
+
# +CODECS__SENTINEL_MISSING_AT_END_OF_BYTES+ if the bytes run out first.
|
|
19
|
+
# - +:optional+ - written, but decoding also stops at the end of the bytes,
|
|
20
|
+
# to tolerate tightly sized or legacy data that lacks it.
|
|
21
|
+
# - +:omitted+ - never written; decoding stops at the end of the bytes, and
|
|
22
|
+
# consumes the sentinel if one happens to be there.
|
|
23
|
+
#
|
|
24
|
+
# Two invariants must hold for a round trip, and - as upstream - the codec
|
|
25
|
+
# does NOT enforce them:
|
|
26
|
+
# 1. No item may *begin* with the sentinel's bytes; decoding cannot tell
|
|
27
|
+
# such an item from the terminator and stops early.
|
|
28
|
+
# 2. Under +:optional+ / +:omitted+ the sentinel must be no wider than the
|
|
29
|
+
# smallest possible item, or a short trailing item is silently dropped:
|
|
30
|
+
# decoding stops once fewer bytes than the sentinel remain.
|
|
31
|
+
class SentinelSize < T::Struct
|
|
32
|
+
STRATEGIES = T.let(%i[required optional omitted].freeze, T::Array[Symbol])
|
|
33
|
+
|
|
34
|
+
const :sentinel, String
|
|
35
|
+
const :strategy, Symbol, default: :required
|
|
36
|
+
end
|
|
37
|
+
|
|
6
38
|
# Data-structure codecs — mirrors @solana/codecs-data-structures.
|
|
7
39
|
module DataStructures
|
|
8
40
|
extend T::Sig
|
|
@@ -15,7 +47,8 @@ module Solana::Ruby::Kit
|
|
|
15
47
|
|
|
16
48
|
# Encode/decode a fixed ordered list of named fields.
|
|
17
49
|
# +fields+ is an Array of [name, codec] pairs (name can be String or Symbol).
|
|
18
|
-
#
|
|
50
|
+
# Decodes to a Hash with Symbol keys. Encode takes a Hash keyed by either
|
|
51
|
+
# form, whichever way the field was named, so a decoded value re-encodes.
|
|
19
52
|
sig { params(fields: T::Array[[T.any(String, Symbol), Codec]]).returns(Codec) }
|
|
20
53
|
def struct_codec(fields)
|
|
21
54
|
fixed = fields.all? { |_, c| c.fixed_size }
|
|
@@ -23,7 +56,11 @@ module Solana::Ruby::Kit
|
|
|
23
56
|
|
|
24
57
|
enc = Encoder.new(fixed_size: total) do |value|
|
|
25
58
|
h = T.cast(value, T::Hash[T.untyped, T.untyped])
|
|
26
|
-
fields.map
|
|
59
|
+
fields.map do |name, codec|
|
|
60
|
+
# key? rather than `h[name] || ...`, which would skip a false value.
|
|
61
|
+
key = [name, name.to_sym, name.to_s].find { |k| h.key?(k) }
|
|
62
|
+
codec.encode(key.nil? ? nil : h[key])
|
|
63
|
+
end.join.b
|
|
27
64
|
end
|
|
28
65
|
dec = Decoder.new(fixed_size: total) do |bytes, offset|
|
|
29
66
|
result = {}
|
|
@@ -61,85 +98,64 @@ module Solana::Ruby::Kit
|
|
|
61
98
|
Codec.new(enc, dec)
|
|
62
99
|
end
|
|
63
100
|
|
|
64
|
-
# Encode/decode
|
|
65
|
-
#
|
|
101
|
+
# Encode/decode an array, one +element_codec+ value per item. +size+
|
|
102
|
+
# picks how the item count is stored - mirrors upstream's
|
|
103
|
+
# `ArrayLikeCodecSize`:
|
|
66
104
|
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
105
|
+
# - +nil+ (default): a u32 little-endian count prefix.
|
|
106
|
+
# - a Codec, e.g. +u16_codec+ or +compact_u16_codec+: the count prefix is
|
|
107
|
+
# written and read with that codec instead.
|
|
108
|
+
# - an Integer: a fixed item count with no prefix. Encoding an array of
|
|
109
|
+
# any other length raises +CODECS__INVALID_NUMBER_OF_ITEMS+.
|
|
110
|
+
# - +:remainder+: no prefix; decoding reads items until the bytes run out,
|
|
111
|
+
# so the array must be the last thing in the buffer.
|
|
112
|
+
# - a SentinelSize: no prefix; the array ends at a sentinel. See
|
|
113
|
+
# SentinelSize for the strategies and the invariants you must uphold.
|
|
114
|
+
#
|
|
115
|
+
# When the count is a prefix and there are not enough bytes left to read
|
|
116
|
+
# it, the decoder yields an empty array rather than failing. That is
|
|
117
|
+
# deliberate: it lets a program append a collection to an existing
|
|
70
118
|
# account layout and still decode accounts written before the change.
|
|
71
119
|
# Formats that cannot accept that leniency - borsh, for one, requires the
|
|
72
120
|
# prefix to be present - can pass +require_size_prefix: true+ to make a
|
|
73
|
-
# truncated buffer raise instead. The option has no effect
|
|
74
|
-
#
|
|
121
|
+
# truncated buffer raise instead. The option has no effect on the other
|
|
122
|
+
# strategies, none of which carries a prefix.
|
|
123
|
+
#
|
|
124
|
+
# +description+ names the codec in errors; it defaults to +'array'+.
|
|
75
125
|
sig do
|
|
76
126
|
params(
|
|
77
127
|
element_codec: Codec,
|
|
78
|
-
size: T.nilable(Integer),
|
|
79
|
-
require_size_prefix: T::Boolean
|
|
128
|
+
size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
|
|
129
|
+
require_size_prefix: T::Boolean,
|
|
130
|
+
description: T.nilable(String)
|
|
80
131
|
).returns(Codec)
|
|
81
132
|
end
|
|
82
|
-
def array_codec(element_codec, size: nil, require_size_prefix: false)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
size.times do
|
|
92
|
-
val, n = element_codec.decode(bytes, offset: offset + consumed)
|
|
93
|
-
result << val
|
|
94
|
-
consumed += n
|
|
95
|
-
end
|
|
96
|
-
[result, consumed]
|
|
97
|
-
end
|
|
98
|
-
Codec.new(enc, dec)
|
|
133
|
+
def array_codec(element_codec, size: nil, require_size_prefix: false, description: nil)
|
|
134
|
+
description ||= 'array'
|
|
135
|
+
ArrayLikeSize.assert_item_codec_supports_size_strategy(description, size, element_codec.fixed_size)
|
|
136
|
+
case size
|
|
137
|
+
when nil then ArrayLikeSize.prefixed(element_codec, Numbers.u32_codec, require_size_prefix)
|
|
138
|
+
when Codec then ArrayLikeSize.prefixed(element_codec, size, require_size_prefix)
|
|
139
|
+
when Integer then ArrayLikeSize.fixed_count(element_codec, size, description)
|
|
140
|
+
when SentinelSize then ArrayLikeSize.sentinel(element_codec, size, description)
|
|
141
|
+
when :remainder then ArrayLikeSize.remainder(element_codec)
|
|
99
142
|
else
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
header = prefix.encode(arr.length)
|
|
104
|
-
body = arr.map { |v| element_codec.encode(v) }.join.b
|
|
105
|
-
header + body
|
|
106
|
-
end
|
|
107
|
-
dec = Decoder.new do |bytes, offset|
|
|
108
|
-
prefix_size = T.must(prefix.fixed_size)
|
|
109
|
-
remaining = [bytes.b.bytesize - offset, 0].max
|
|
110
|
-
if remaining < prefix_size
|
|
111
|
-
# The prefix is missing or truncated. By default that decodes to
|
|
112
|
-
# an empty collection having consumed nothing; under
|
|
113
|
-
# +require_size_prefix+ it is an error.
|
|
114
|
-
Kernel.raise SolanaError.new(
|
|
115
|
-
SolanaError::CODECS__INVALID_BYTE_LENGTH,
|
|
116
|
-
{ expected: prefix_size, actual: remaining }
|
|
117
|
-
) if require_size_prefix
|
|
118
|
-
|
|
119
|
-
next [[], 0]
|
|
120
|
-
end
|
|
121
|
-
len, prefix_bytes = prefix.decode(bytes, offset: offset)
|
|
122
|
-
result = []
|
|
123
|
-
consumed = prefix_bytes
|
|
124
|
-
len.times do
|
|
125
|
-
val, n = element_codec.decode(bytes, offset: offset + consumed)
|
|
126
|
-
result << val
|
|
127
|
-
consumed += n
|
|
128
|
-
end
|
|
129
|
-
[result, consumed]
|
|
130
|
-
end
|
|
131
|
-
Codec.new(enc, dec)
|
|
143
|
+
Kernel.raise ArgumentError,
|
|
144
|
+
"Unknown array size #{size.inspect}; expected nil, a number Codec, an Integer, " \
|
|
145
|
+
':remainder or a SentinelSize'
|
|
132
146
|
end
|
|
133
147
|
end
|
|
134
148
|
|
|
135
149
|
# Encode/decode a Hash.
|
|
136
150
|
# Encoded as: [length prefix] + [key, value, key, value, ...]
|
|
137
|
-
# See +array_codec+ for what +require_size_prefix+
|
|
151
|
+
# See +array_codec+ for what +size+ and +require_size_prefix+ do; +size+
|
|
152
|
+
# counts entries, and a SentinelSize is compared at entry (key)
|
|
153
|
+
# boundaries.
|
|
138
154
|
sig do
|
|
139
155
|
params(
|
|
140
156
|
key_codec: Codec,
|
|
141
157
|
value_codec: Codec,
|
|
142
|
-
size: T.nilable(Integer),
|
|
158
|
+
size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
|
|
143
159
|
require_size_prefix: T::Boolean
|
|
144
160
|
).returns(Codec)
|
|
145
161
|
end
|
|
@@ -153,11 +169,11 @@ module Solana::Ruby::Kit
|
|
|
153
169
|
end
|
|
154
170
|
|
|
155
171
|
# Encode/decode a Set (stored as an array of unique elements).
|
|
156
|
-
# See +array_codec+ for what +require_size_prefix+
|
|
172
|
+
# See +array_codec+ for what +size+ and +require_size_prefix+ do.
|
|
157
173
|
sig do
|
|
158
174
|
params(
|
|
159
175
|
element_codec: Codec,
|
|
160
|
-
size: T.nilable(Integer),
|
|
176
|
+
size: T.nilable(T.any(Integer, Symbol, Codec, SentinelSize)),
|
|
161
177
|
require_size_prefix: T::Boolean
|
|
162
178
|
).returns(Codec)
|
|
163
179
|
end
|
|
@@ -220,5 +236,160 @@ module Solana::Ruby::Kit
|
|
|
220
236
|
Codec.new(enc, dec)
|
|
221
237
|
end
|
|
222
238
|
end
|
|
239
|
+
|
|
240
|
+
# The per-strategy builders behind +array_codec+ (and so +map_codec+ and
|
|
241
|
+
# +set_codec+). Call those instead. Kept out of DataStructures on purpose:
|
|
242
|
+
# everything there is re-exported as a public +Codecs.*+ helper, and these
|
|
243
|
+
# are an implementation detail.
|
|
244
|
+
module ArrayLikeSize
|
|
245
|
+
extend T::Sig
|
|
246
|
+
extend self
|
|
247
|
+
|
|
248
|
+
# Raises when a zero-byte item codec is paired with a strategy that relies
|
|
249
|
+
# on items consuming bytes to make progress: a sentinel could never be
|
|
250
|
+
# reached, nor could the end of the bytes under +:remainder+.
|
|
251
|
+
sig { params(description: String, size: T.untyped, item_fixed_size: T.nilable(Integer)).void }
|
|
252
|
+
def assert_item_codec_supports_size_strategy(description, size, item_fixed_size)
|
|
253
|
+
return unless item_fixed_size&.zero?
|
|
254
|
+
return unless size == :remainder || size.is_a?(SentinelSize)
|
|
255
|
+
|
|
256
|
+
Kernel.raise SolanaError.new(
|
|
257
|
+
SolanaError::CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY,
|
|
258
|
+
{ codec_description: description, size_strategy: size == :remainder ? 'remainder' : 'sentinel' }
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A count prefix written and read with +prefix+.
|
|
263
|
+
sig { params(element_codec: Codec, prefix: Codec, require_size_prefix: T::Boolean).returns(Codec) }
|
|
264
|
+
def prefixed(element_codec, prefix, require_size_prefix)
|
|
265
|
+
enc = Encoder.new do |values|
|
|
266
|
+
arr = T.cast(values, T::Array[T.untyped])
|
|
267
|
+
prefix.encode(arr.length) + arr.map { |v| element_codec.encode(v) }.join.b
|
|
268
|
+
end
|
|
269
|
+
dec = Decoder.new do |bytes, offset|
|
|
270
|
+
# A fixed-size prefix needs all its bytes; a variable-size one (e.g.
|
|
271
|
+
# compact_u16) needs at least one, and raises for itself if the rest
|
|
272
|
+
# are missing.
|
|
273
|
+
prefix_size = prefix.fixed_size || 1
|
|
274
|
+
remaining = [bytes.bytesize - offset, 0].max
|
|
275
|
+
if remaining < prefix_size
|
|
276
|
+
# The prefix is missing or truncated. By default that decodes to an
|
|
277
|
+
# empty collection having consumed nothing; under
|
|
278
|
+
# +require_size_prefix+ it is an error.
|
|
279
|
+
Kernel.raise SolanaError.new(
|
|
280
|
+
SolanaError::CODECS__INVALID_BYTE_LENGTH,
|
|
281
|
+
{ expected: prefix_size, actual: remaining }
|
|
282
|
+
) if require_size_prefix
|
|
283
|
+
|
|
284
|
+
next [[], 0]
|
|
285
|
+
end
|
|
286
|
+
count, consumed = prefix.decode(bytes, offset: offset)
|
|
287
|
+
result = Array.new(Kernel.Integer(count)) do
|
|
288
|
+
val, n = element_codec.decode(bytes, offset: offset + consumed)
|
|
289
|
+
consumed += n
|
|
290
|
+
val
|
|
291
|
+
end
|
|
292
|
+
[result, consumed]
|
|
293
|
+
end
|
|
294
|
+
Codec.new(enc, dec)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Exactly +count+ items and no prefix. Fixed-size when the items are - and
|
|
298
|
+
# always for a count of zero, whatever the item.
|
|
299
|
+
sig { params(element_codec: Codec, count: Integer, description: String).returns(Codec) }
|
|
300
|
+
def fixed_count(element_codec, count, description)
|
|
301
|
+
item_size = element_codec.fixed_size
|
|
302
|
+
fixed = count.zero? ? 0 : (item_size && count * item_size)
|
|
303
|
+
enc = Encoder.new(fixed_size: fixed) do |values|
|
|
304
|
+
arr = T.cast(values, T::Array[T.untyped])
|
|
305
|
+
unless arr.length == count
|
|
306
|
+
Kernel.raise SolanaError.new(
|
|
307
|
+
SolanaError::CODECS__INVALID_NUMBER_OF_ITEMS,
|
|
308
|
+
{ codec_description: description, expected: count, actual: arr.length }
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
arr.map { |v| element_codec.encode(v) }.join.b
|
|
312
|
+
end
|
|
313
|
+
dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
|
|
314
|
+
consumed = 0
|
|
315
|
+
result = Array.new(count) do
|
|
316
|
+
val, n = element_codec.decode(bytes, offset: offset + consumed)
|
|
317
|
+
consumed += n
|
|
318
|
+
val
|
|
319
|
+
end
|
|
320
|
+
[result, consumed]
|
|
321
|
+
end
|
|
322
|
+
Codec.new(enc, dec)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# No prefix; decoding reads items until the bytes run out.
|
|
326
|
+
sig { params(element_codec: Codec).returns(Codec) }
|
|
327
|
+
def remainder(element_codec)
|
|
328
|
+
enc = Encoder.new do |values|
|
|
329
|
+
T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
|
|
330
|
+
end
|
|
331
|
+
dec = Decoder.new do |bytes, offset|
|
|
332
|
+
result = []
|
|
333
|
+
consumed = 0
|
|
334
|
+
while offset + consumed < bytes.bytesize
|
|
335
|
+
val, n = element_codec.decode(bytes, offset: offset + consumed)
|
|
336
|
+
# An item that consumes nothing would never reach the end.
|
|
337
|
+
Kernel.raise ArgumentError, 'a :remainder array item decoded zero bytes' if n.zero?
|
|
338
|
+
|
|
339
|
+
result << val
|
|
340
|
+
consumed += n
|
|
341
|
+
end
|
|
342
|
+
[result, consumed]
|
|
343
|
+
end
|
|
344
|
+
Codec.new(enc, dec)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Ends at a sentinel instead of carrying a count. Always variable-size:
|
|
348
|
+
# the item count is only known by reading up to the sentinel.
|
|
349
|
+
sig { params(element_codec: Codec, size: SentinelSize, description: String).returns(Codec) }
|
|
350
|
+
def sentinel(element_codec, size, description)
|
|
351
|
+
sentinel = size.sentinel.b
|
|
352
|
+
strategy = size.strategy
|
|
353
|
+
# Raised at construction, as upstream does for both the encoder and the
|
|
354
|
+
# decoder: an empty sentinel matches everywhere and delimits nothing.
|
|
355
|
+
Kernel.raise SolanaError.new(SolanaError::CODECS__SENTINEL_MUST_NOT_BE_EMPTY) if sentinel.empty?
|
|
356
|
+
unless SentinelSize::STRATEGIES.include?(strategy)
|
|
357
|
+
Kernel.raise ArgumentError,
|
|
358
|
+
"Unknown sentinel strategy #{strategy.inspect}; expected one of #{SentinelSize::STRATEGIES.inspect}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
enc = Encoder.new do |values|
|
|
362
|
+
body = T.cast(values, T::Array[T.untyped]).map { |v| element_codec.encode(v) }.join.b
|
|
363
|
+
strategy == :omitted ? body : body + sentinel
|
|
364
|
+
end
|
|
365
|
+
dec = Decoder.new do |bytes, offset|
|
|
366
|
+
result = []
|
|
367
|
+
consumed = 0
|
|
368
|
+
Kernel.loop do
|
|
369
|
+
position = offset + consumed
|
|
370
|
+
if position + sentinel.bytesize > bytes.bytesize
|
|
371
|
+
# Not enough bytes remain to hold the sentinel.
|
|
372
|
+
if strategy == :required
|
|
373
|
+
Kernel.raise SolanaError.new(
|
|
374
|
+
SolanaError::CODECS__SENTINEL_MISSING_AT_END_OF_BYTES,
|
|
375
|
+
{ codec_description: description, hex_sentinel: sentinel.unpack1('H*'), sentinel: sentinel }
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
break
|
|
379
|
+
end
|
|
380
|
+
if Bytes.contains_bytes?(bytes, sentinel, offset: position)
|
|
381
|
+
# The sentinel is present; consume it and stop.
|
|
382
|
+
consumed += sentinel.bytesize
|
|
383
|
+
break
|
|
384
|
+
end
|
|
385
|
+
val, n = element_codec.decode(bytes, offset: position)
|
|
386
|
+
result << val
|
|
387
|
+
consumed += n
|
|
388
|
+
end
|
|
389
|
+
[result, consumed]
|
|
390
|
+
end
|
|
391
|
+
Codec.new(enc, dec)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
223
394
|
end
|
|
224
395
|
end
|
|
@@ -200,21 +200,57 @@ module Solana::Ruby::Kit
|
|
|
200
200
|
bytes.pack('C*')
|
|
201
201
|
end
|
|
202
202
|
dec = Decoder.new do |bytes, offset|
|
|
203
|
-
b
|
|
204
|
-
n
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
b = bytes.b
|
|
204
|
+
n = 0
|
|
205
|
+
decoded = (1..3).each do |byte_count|
|
|
206
|
+
# The chain must terminate within the bytes that remain; a buffer
|
|
207
|
+
# that ends mid-chain is truncated, not an implicit zero byte.
|
|
208
|
+
remaining = b.bytesize - offset
|
|
209
|
+
if remaining < byte_count
|
|
210
|
+
Kernel.raise SolanaError.new(
|
|
211
|
+
SolanaError::CODECS__INVALID_BYTE_LENGTH,
|
|
212
|
+
{ codec_description: 'shortU16', expected: byte_count, actual: [remaining, 0].max }
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
byte = T.must(b.getbyte(offset + byte_count - 1))
|
|
217
|
+
n |= (byte & 0x7F) << ((byte_count - 1) * 7)
|
|
218
|
+
next unless (byte & 0x80).zero?
|
|
219
|
+
|
|
220
|
+
Numbers.assert_short_u16_in_range(n)
|
|
221
|
+
break [n, byte_count]
|
|
213
222
|
end
|
|
214
|
-
|
|
223
|
+
# `each` only returns its range when no byte terminated the chain.
|
|
224
|
+
next decoded if decoded.is_a?(Array)
|
|
225
|
+
|
|
226
|
+
Numbers.raise_short_u16_too_long
|
|
215
227
|
end
|
|
216
228
|
Codec.new(enc, dec)
|
|
217
229
|
end
|
|
230
|
+
|
|
231
|
+
# Three terminated shortU16 bytes can hold up to 2^21 - 1, but only the
|
|
232
|
+
# u16 domain is valid. Shared with the wire-format readers in
|
|
233
|
+
# WalletStandard and TransactionIntrospection, which decode shortU16
|
|
234
|
+
# inline so they can raise their own truncation errors.
|
|
235
|
+
sig { params(value: Integer).void }
|
|
236
|
+
def assert_short_u16_in_range(value)
|
|
237
|
+
return if value <= 0xFFFF
|
|
238
|
+
|
|
239
|
+
Kernel.raise SolanaError.new(
|
|
240
|
+
SolanaError::CODECS__NUMBER_OUT_OF_RANGE,
|
|
241
|
+
{ codec_description: 'shortU16', min: 0, max: 0xFFFF, value: value }
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Raised when all three shortU16 bytes carry a continuation bit: the
|
|
246
|
+
# encoding would need a fourth byte, which the format does not allow.
|
|
247
|
+
sig { returns(T.noreturn) }
|
|
248
|
+
def raise_short_u16_too_long
|
|
249
|
+
Kernel.raise SolanaError.new(
|
|
250
|
+
SolanaError::CODECS__INVALID_BYTE_LENGTH,
|
|
251
|
+
{ codec_description: 'shortU16', expected: 3, actual: 4 }
|
|
252
|
+
)
|
|
253
|
+
end
|
|
218
254
|
end
|
|
219
255
|
end
|
|
220
256
|
end
|
|
@@ -191,11 +191,20 @@ module Solana::Ruby::Kit
|
|
|
191
191
|
Codec.new(enc, dec)
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
# Hex codec — lower-case hex string ↔ binary bytes.
|
|
194
|
+
# Hex codec — lower-case hex string ↔ binary bytes. Spaces are ignored on
|
|
195
|
+
# encode; any other non-hex character raises. An odd-length string keeps
|
|
196
|
+
# its last digit as a final low-nibble byte (+'abc'+ → +0xab 0x0c+), as
|
|
197
|
+
# upstream's base16 encoder does — +pack('H*')+ alone would pad it into
|
|
198
|
+
# the high nibble (+0xc0+).
|
|
195
199
|
sig { returns(Codec) }
|
|
196
200
|
def hex_codec
|
|
197
201
|
enc = Encoder.new do |v|
|
|
198
|
-
|
|
202
|
+
value = v.to_s.tr(' ', '')
|
|
203
|
+
unless value.match?(/\A\h*\z/)
|
|
204
|
+
Kernel.raise SolanaError.new(SolanaError::CODECS__INVALID_STRING_FOR_HEX_CODEC, { value: v.to_s })
|
|
205
|
+
end
|
|
206
|
+
value = "#{value[0...-1]}0#{value[-1]}" if value.length.odd?
|
|
207
|
+
[value].pack('H*')
|
|
199
208
|
end
|
|
200
209
|
dec = Decoder.new do |bytes, offset|
|
|
201
210
|
remaining = bytes.b.byteslice(offset..) || ''.b
|
|
@@ -36,6 +36,7 @@ module Solana::Ruby::Kit
|
|
|
36
36
|
KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY = :SOLANA_ERROR__KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY
|
|
37
37
|
KEYS__INVALID_SIGNATURE_BYTE_LENGTH = :SOLANA_ERROR__KEYS__INVALID_SIGNATURE_BYTE_LENGTH
|
|
38
38
|
KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE = :SOLANA_ERROR__KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE
|
|
39
|
+
KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY = :SOLANA_ERROR__KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY
|
|
39
40
|
|
|
40
41
|
# ── Instructions ──────────────────────────────────────────────────────────
|
|
41
42
|
INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS = :SOLANA_ERROR__INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS
|
|
@@ -99,6 +100,13 @@ module Solana::Ruby::Kit
|
|
|
99
100
|
CODECS__INVALID_UTF8_BYTES = :SOLANA_ERROR__CODECS__INVALID_UTF8_BYTES
|
|
100
101
|
# context: { index:, value: }
|
|
101
102
|
CODECS__INVALID_UTF8_STRING = :SOLANA_ERROR__CODECS__INVALID_UTF8_STRING
|
|
103
|
+
# context: { codec_description:, min:, max:, value: }
|
|
104
|
+
CODECS__NUMBER_OUT_OF_RANGE = :SOLANA_ERROR__CODECS__NUMBER_OUT_OF_RANGE
|
|
105
|
+
# context: { codec_description:, hex_sentinel:, sentinel: }
|
|
106
|
+
CODECS__SENTINEL_MISSING_AT_END_OF_BYTES = :SOLANA_ERROR__CODECS__SENTINEL_MISSING_AT_END_OF_BYTES
|
|
107
|
+
CODECS__SENTINEL_MUST_NOT_BE_EMPTY = :SOLANA_ERROR__CODECS__SENTINEL_MUST_NOT_BE_EMPTY
|
|
108
|
+
# context: { codec_description:, size_strategy: }
|
|
109
|
+
CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY = :SOLANA_ERROR__CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY
|
|
102
110
|
|
|
103
111
|
# ── RPC / JSON-RPC ────────────────────────────────────────────────────────
|
|
104
112
|
RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER = :SOLANA_ERROR__RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER
|
|
@@ -138,6 +146,8 @@ module Solana::Ruby::Kit
|
|
|
138
146
|
INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION = :SOLANA_ERROR__INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION
|
|
139
147
|
# context: { max_instructions:, num_instructions: }
|
|
140
148
|
INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED = :SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
|
|
149
|
+
# context: { reason: }
|
|
150
|
+
INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER = :SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER
|
|
141
151
|
|
|
142
152
|
# ── Transaction introspection ────────────────────────────────────────────
|
|
143
153
|
TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION = :SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION
|
|
@@ -193,6 +203,7 @@ module Solana::Ruby::Kit
|
|
|
193
203
|
KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY => 'The public key does not match the private key',
|
|
194
204
|
KEYS__INVALID_SIGNATURE_BYTE_LENGTH => 'Signature must be 64 bytes, got %{actual_length}',
|
|
195
205
|
KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE => 'Signature string length out of range (64–88 chars), got %{actual_length}',
|
|
206
|
+
KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY => 'Signatures can only be verified using a public key (an RbNaCl::VerifyKey).',
|
|
196
207
|
|
|
197
208
|
# Instructions
|
|
198
209
|
INSTRUCTIONS__EXPECTED_TO_HAVE_ACCOUNTS => 'Expected instruction to have accounts',
|
|
@@ -237,7 +248,7 @@ module Solana::Ruby::Kit
|
|
|
237
248
|
CODECS__EXPECTED_POSITIVE_BYTE_LENGTH => 'Expected a positive byte length, got %{byte_length}',
|
|
238
249
|
CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH => 'Encoder fixed size (%{encoder_size}) does not match decoder fixed size (%{decoder_size})',
|
|
239
250
|
CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH => 'Encoder max size (%{encoder_max}) does not match decoder max size (%{decoder_max})',
|
|
240
|
-
CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected %{expected} items
|
|
251
|
+
CODECS__INVALID_NUMBER_OF_ITEMS => 'Expected [%{codec_description}] to have %{expected} items, got %{actual}.',
|
|
241
252
|
CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE => 'Enum discriminator %{discriminator} is out of range [0, %{max}]',
|
|
242
253
|
CODECS__UNION_VARIANT_OUT_OF_RANGE => 'Union variant index %{index} is out of range',
|
|
243
254
|
CODECS__OFFSET_OUT_OF_RANGE => 'Codec offset %{offset} is out of range for byte array of length %{byte_length}',
|
|
@@ -250,6 +261,10 @@ module Solana::Ruby::Kit
|
|
|
250
261
|
CODECS__FIXED_NULLABLE_CANNOT_WRAP_VARIABLE_SIZE_CODEC => 'A fixed-size nullable codec cannot wrap a variable-size codec',
|
|
251
262
|
CODECS__INVALID_UTF8_BYTES => 'Invalid UTF-8 byte sequence at offset %{offset}',
|
|
252
263
|
CODECS__INVALID_UTF8_STRING => 'Invalid UTF-8 string at index %{index}',
|
|
264
|
+
CODECS__NUMBER_OUT_OF_RANGE => 'Codec [%{codec_description}] expected number to be in the range [%{min}, %{max}], got %{value}.',
|
|
265
|
+
CODECS__SENTINEL_MISSING_AT_END_OF_BYTES => 'Codec [%{codec_description}] expected sentinel [%{hex_sentinel}] to terminate the collection, but reached the end of the byte array without it.',
|
|
266
|
+
CODECS__SENTINEL_MUST_NOT_BE_EMPTY => 'The sentinel must not be empty.',
|
|
267
|
+
CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY => 'Codec [%{codec_description}] cannot use the [%{size_strategy}] size strategy: the item codec has a fixed size of zero and consumes no bytes.',
|
|
253
268
|
|
|
254
269
|
# RPC
|
|
255
270
|
RPC__INTEGER_OVERFLOW_WHILE_SERIALIZING_LARGE_INTEGER => 'Integer overflow while serializing large integer %{value}',
|
|
@@ -287,6 +302,7 @@ module Solana::Ruby::Kit
|
|
|
287
302
|
INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
|
|
288
303
|
INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION => 'The configured maximum of %{max_instructions} instructions per transaction is invalid. It must be a positive integer no greater than the transaction format limit of %{transaction_instruction_limit} instructions per transaction.',
|
|
289
304
|
INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED => 'Planning this transaction message would require %{num_instructions} instructions, which exceeds the configured maximum of %{max_instructions} instructions per transaction.',
|
|
305
|
+
INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER => 'The message packer rejected the provided transaction message: %{reason}.',
|
|
290
306
|
|
|
291
307
|
# Transaction introspection
|
|
292
308
|
TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION => "Cannot decode a 'jsonParsed'-encoded getTransaction response; its instructions are pre-parsed and lack raw bytes. Fetch with encoding: 'json' or 'base64' instead.",
|
|
@@ -5,7 +5,7 @@ require_relative '../errors'
|
|
|
5
5
|
require_relative '../instructions/instruction'
|
|
6
6
|
require_relative '../transaction_messages/transaction_message'
|
|
7
7
|
require_relative '../transactions/compiler'
|
|
8
|
-
require_relative '
|
|
8
|
+
require_relative 'message_packer_errors'
|
|
9
9
|
|
|
10
10
|
module Solana::Ruby::Kit
|
|
11
11
|
module InstructionPlans
|
|
@@ -84,6 +84,14 @@ module Solana::Ruby::Kit
|
|
|
84
84
|
#
|
|
85
85
|
# +max_instructions+ caps the number of top-level instructions allowed in the
|
|
86
86
|
# returned message (defaults to 16; must be a positive integer no greater than 64).
|
|
87
|
+
#
|
|
88
|
+
# A custom packer can enforce those limits with
|
|
89
|
+
# InstructionPlans.resolve_max_instructions_per_transaction,
|
|
90
|
+
# .assert_max_instructions_per_transaction and .assert_message_can_accommodate_size,
|
|
91
|
+
# and may raise INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER (with a +reason:+) to
|
|
92
|
+
# refuse a message for any other reason - e.g. a constraint specific to the
|
|
93
|
+
# instructions being packed. The planner then tries another message; see
|
|
94
|
+
# InstructionPlans.message_packer_error_that_requires_new_candidate?.
|
|
87
95
|
sig do
|
|
88
96
|
params(
|
|
89
97
|
message: TransactionMessages::TransactionMessage,
|
|
@@ -148,8 +156,7 @@ module Solana::Ruby::Kit
|
|
|
148
156
|
Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
|
|
149
157
|
end
|
|
150
158
|
|
|
151
|
-
InstructionPlans.
|
|
152
|
-
resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
|
|
159
|
+
resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
|
|
153
160
|
InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
|
|
154
161
|
|
|
155
162
|
base_ix = get_instruction.call(offset, 0)
|
|
@@ -194,8 +201,7 @@ module Solana::Ruby::Kit
|
|
|
194
201
|
Kernel.raise SolanaError.new(SolanaError::INSTRUCTION_PLANS__MESSAGE_PACKER_ALREADY_COMPLETE)
|
|
195
202
|
end
|
|
196
203
|
|
|
197
|
-
InstructionPlans.
|
|
198
|
-
resolved_max = InstructionPlans.resolve_max_instructions(max_instructions)
|
|
204
|
+
resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
|
|
199
205
|
InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, resolved_max)
|
|
200
206
|
|
|
201
207
|
original_size = Transactions.get_transaction_message_size(message)
|
|
@@ -212,18 +218,16 @@ module Solana::Ruby::Kit
|
|
|
212
218
|
end
|
|
213
219
|
|
|
214
220
|
next_packed = TransactionMessages.append_instructions(packed, [T.must(instructions[i])])
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
end
|
|
221
|
+
next_size = Transactions.get_transaction_message_size(next_packed)
|
|
222
|
+
size_limit = Transactions::TRANSACTION_SIZE_LIMIT
|
|
223
|
+
|
|
224
|
+
if i == start_idx
|
|
225
|
+
# The count was already asserted above, so the first instruction can
|
|
226
|
+
# only fail to fit because of the transaction size limit.
|
|
227
|
+
InstructionPlans.assert_message_can_accommodate_size(
|
|
228
|
+
current_size: original_size, next_size: next_size, size_limit: size_limit
|
|
229
|
+
)
|
|
230
|
+
elsif next_size > size_limit
|
|
227
231
|
idx = i
|
|
228
232
|
return packed
|
|
229
233
|
end
|
|
@@ -240,7 +244,9 @@ module Solana::Ruby::Kit
|
|
|
240
244
|
end
|
|
241
245
|
|
|
242
246
|
# Creates a MessagePackerInstructionPlan that splits +total_size+ bytes into
|
|
243
|
-
# chunks of REALLOC_LIMIT (10,240) bytes
|
|
247
|
+
# chunks of at most REALLOC_LIMIT (10,240) bytes and creates one instruction
|
|
248
|
+
# per chunk, calling +get_instruction.(size)+. A +total_size+ that is an exact
|
|
249
|
+
# multiple of the limit yields only full-sized chunks, and zero yields none.
|
|
244
250
|
# Mirrors `getReallocMessagePackerInstructionPlan({ getInstruction, totalSize })`.
|
|
245
251
|
sig do
|
|
246
252
|
params(
|
|
@@ -249,13 +255,12 @@ module Solana::Ruby::Kit
|
|
|
249
255
|
).returns(MessagePackerInstructionPlan)
|
|
250
256
|
end
|
|
251
257
|
def get_realloc_message_packer_instruction_plan(total_size:, get_instruction:)
|
|
252
|
-
realloc_limit
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
get_instruction.call(chunk)
|
|
258
|
+
realloc_limit = 10_240
|
|
259
|
+
instructions = []
|
|
260
|
+
remaining = total_size
|
|
261
|
+
while remaining.positive?
|
|
262
|
+
instructions << get_instruction.call([realloc_limit, remaining].min)
|
|
263
|
+
remaining -= realloc_limit
|
|
259
264
|
end
|
|
260
265
|
|
|
261
266
|
get_message_packer_instruction_plan_from_instructions(instructions)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
|
|
6
|
+
module Solana::Ruby::Kit
|
|
7
|
+
module InstructionPlans
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
# The default maximum number of top-level instructions per planned transaction message.
|
|
11
|
+
#
|
|
12
|
+
# Intentionally lower than the transaction format's instruction limit to leave headroom
|
|
13
|
+
# for inner instructions (CPIs), which are not visible at planning time.
|
|
14
|
+
DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION = T.let(16, Integer)
|
|
15
|
+
|
|
16
|
+
# The hard maximum number of top-level instructions the transaction format can encode.
|
|
17
|
+
TRANSACTION_INSTRUCTION_LIMIT = T.let(64, Integer)
|
|
18
|
+
|
|
19
|
+
# The error codes the transaction planner treats as "try another message": the message
|
|
20
|
+
# holds too many instructions, would be too large, or a message packer refused it for a
|
|
21
|
+
# custom reason. Upstream's list also has four `TRANSACTION__TOO_MANY_*` compile errors;
|
|
22
|
+
# Ruby's compiler raises none of them, so they are not listed.
|
|
23
|
+
MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE = T.let(
|
|
24
|
+
[
|
|
25
|
+
SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
|
|
26
|
+
SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
|
|
27
|
+
SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER
|
|
28
|
+
].freeze,
|
|
29
|
+
T::Array[Symbol]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
extend self
|
|
33
|
+
|
|
34
|
+
# Resolves the maximum number of instructions a message packer may put in a transaction
|
|
35
|
+
# message: the default of 16 when +max_instructions+ is nil, otherwise the given value,
|
|
36
|
+
# which must be a positive integer no greater than TRANSACTION_INSTRUCTION_LIMIT.
|
|
37
|
+
#
|
|
38
|
+
# This is typically the first thing a custom MessagePacker does with the
|
|
39
|
+
# +max_instructions+ it receives in +pack_message_to_capacity+:
|
|
40
|
+
#
|
|
41
|
+
# pack_proc: ->(message, max_instructions) {
|
|
42
|
+
# max = InstructionPlans.resolve_max_instructions_per_transaction(max_instructions)
|
|
43
|
+
# InstructionPlans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
|
|
44
|
+
# # ...
|
|
45
|
+
# }
|
|
46
|
+
#
|
|
47
|
+
# Raises INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION for an out-of-range value.
|
|
48
|
+
# Mirrors `resolveMaxInstructionsPerTransaction(maxInstructions)`.
|
|
49
|
+
sig { params(max_instructions: T.nilable(Integer)).returns(Integer) }
|
|
50
|
+
def resolve_max_instructions_per_transaction(max_instructions = nil)
|
|
51
|
+
return DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION if max_instructions.nil?
|
|
52
|
+
|
|
53
|
+
if max_instructions <= 0 || max_instructions > TRANSACTION_INSTRUCTION_LIMIT
|
|
54
|
+
Kernel.raise SolanaError.new(
|
|
55
|
+
SolanaError::INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION,
|
|
56
|
+
{
|
|
57
|
+
max_instructions: max_instructions,
|
|
58
|
+
transaction_instruction_limit: TRANSACTION_INSTRUCTION_LIMIT
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
max_instructions
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Raises if a message holding +num_instructions+ instructions would exceed
|
|
67
|
+
# +max_instructions+. Use it in a custom MessagePacker before appending an instruction -
|
|
68
|
+
# passing the count the message would have after the append - so the planner knows to
|
|
69
|
+
# pack that instruction into another message.
|
|
70
|
+
#
|
|
71
|
+
# Raises INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED.
|
|
72
|
+
# Mirrors `assertMaxInstructionsPerTransaction(numInstructions, maxInstructions)`.
|
|
73
|
+
sig { params(num_instructions: Integer, max_instructions: Integer).void }
|
|
74
|
+
def assert_max_instructions_per_transaction(num_instructions, max_instructions)
|
|
75
|
+
return if num_instructions <= max_instructions
|
|
76
|
+
|
|
77
|
+
Kernel.raise SolanaError.new(
|
|
78
|
+
SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
|
|
79
|
+
{ max_instructions: max_instructions, num_instructions: num_instructions }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Raises if a message cannot grow from +current_size+ to +next_size+ bytes without
|
|
84
|
+
# exceeding +size_limit+. Use it in a custom MessagePacker after appending the next
|
|
85
|
+
# instruction(s), so the planner knows to pack them into another message. It takes sizes
|
|
86
|
+
# rather than messages so callers who already computed them do not pay for it twice:
|
|
87
|
+
#
|
|
88
|
+
# next_message = TransactionMessages.append_instructions(message, [ix])
|
|
89
|
+
# InstructionPlans.assert_message_can_accommodate_size(
|
|
90
|
+
# current_size: Transactions.get_transaction_message_size(message),
|
|
91
|
+
# next_size: Transactions.get_transaction_message_size(next_message),
|
|
92
|
+
# size_limit: Transactions::TRANSACTION_SIZE_LIMIT
|
|
93
|
+
# )
|
|
94
|
+
#
|
|
95
|
+
# Raises INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN, reporting how many bytes
|
|
96
|
+
# were required and how many were free.
|
|
97
|
+
# Mirrors `assertMessageCanAccommodateSize({ currentSize, nextSize, sizeLimit })`.
|
|
98
|
+
sig { params(current_size: Integer, next_size: Integer, size_limit: Integer).void }
|
|
99
|
+
def assert_message_can_accommodate_size(current_size:, next_size:, size_limit:)
|
|
100
|
+
return if next_size <= size_limit
|
|
101
|
+
|
|
102
|
+
Kernel.raise SolanaError.new(
|
|
103
|
+
SolanaError::INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN,
|
|
104
|
+
{ num_bytes_required: next_size - current_size, num_free_bytes: size_limit - current_size }
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether +error+, raised while packing instructions into a message, means the message
|
|
109
|
+
# cannot take them and a new candidate message is required (see
|
|
110
|
+
# MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE). Any other error is unexpected and
|
|
111
|
+
# should propagate:
|
|
112
|
+
#
|
|
113
|
+
# begin
|
|
114
|
+
# message = packer.pack_message_to_capacity(message)
|
|
115
|
+
# rescue => e
|
|
116
|
+
# raise unless InstructionPlans.message_packer_error_that_requires_new_candidate?(e)
|
|
117
|
+
# message = packer.pack_message_to_capacity(create_new_message)
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
120
|
+
# A custom packer refuses a message for any other reason by raising
|
|
121
|
+
# INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER with a +reason:+.
|
|
122
|
+
# Mirrors `isMessagePackerErrorThatRequiresNewCandidate(error)`.
|
|
123
|
+
sig { params(error: T.anything).returns(T::Boolean) }
|
|
124
|
+
def message_packer_error_that_requires_new_candidate?(error)
|
|
125
|
+
case error
|
|
126
|
+
when SolanaError then MESSAGE_PACKER_ERRORS_THAT_REQUIRE_NEW_CANDIDATE.include?(error.code)
|
|
127
|
+
else false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -54,11 +54,11 @@ module Solana::Ruby::Kit
|
|
|
54
54
|
config_max_instructions_per_transaction = max_instructions_per_transaction
|
|
55
55
|
|
|
56
56
|
->(instruction_plan, max_instructions_per_transaction: nil) {
|
|
57
|
-
resolved_max = max_instructions_per_transaction || config_max_instructions_per_transaction
|
|
58
|
-
|
|
59
57
|
# Reject up front any configured maximum the transaction format could never satisfy,
|
|
60
58
|
# rather than discovering it mid-plan when a message fails to compile.
|
|
61
|
-
InstructionPlans.
|
|
59
|
+
resolved_max = InstructionPlans.resolve_max_instructions_per_transaction(
|
|
60
|
+
max_instructions_per_transaction || config_max_instructions_per_transaction
|
|
61
|
+
)
|
|
62
62
|
|
|
63
63
|
mutable = planner_traverse(
|
|
64
64
|
instruction_plan,
|
|
@@ -194,17 +194,14 @@ module Solana::Ruby::Kit
|
|
|
194
194
|
if Transactions.get_transaction_message_size(updated) <= Transactions::TRANSACTION_SIZE_LIMIT
|
|
195
195
|
InstructionPlans.assert_max_instructions_per_transaction(
|
|
196
196
|
updated.instructions.length,
|
|
197
|
-
|
|
197
|
+
ctx[:max_instructions_per_transaction]
|
|
198
198
|
)
|
|
199
199
|
candidate[:message] = updated
|
|
200
200
|
return candidate
|
|
201
201
|
end
|
|
202
202
|
rescue SolanaError => e
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED
|
|
206
|
-
].include?(e.code)
|
|
207
|
-
Kernel.raise
|
|
203
|
+
Kernel.raise unless InstructionPlans.message_packer_error_that_requires_new_candidate?(e)
|
|
204
|
+
# Try the next candidate.
|
|
208
205
|
end
|
|
209
206
|
end
|
|
210
207
|
nil
|
|
@@ -228,7 +225,7 @@ module Solana::Ruby::Kit
|
|
|
228
225
|
|
|
229
226
|
InstructionPlans.assert_max_instructions_per_transaction(
|
|
230
227
|
updated_msg.instructions.length,
|
|
231
|
-
|
|
228
|
+
ctx[:max_instructions_per_transaction]
|
|
232
229
|
)
|
|
233
230
|
|
|
234
231
|
updated_msg
|
|
@@ -253,7 +250,7 @@ module Solana::Ruby::Kit
|
|
|
253
250
|
end
|
|
254
251
|
InstructionPlans.assert_max_instructions_per_transaction(
|
|
255
252
|
updated.instructions.length,
|
|
256
|
-
|
|
253
|
+
ctx[:max_instructions_per_transaction]
|
|
257
254
|
)
|
|
258
255
|
updated
|
|
259
256
|
when :message_packer
|
|
@@ -263,7 +260,7 @@ module Solana::Ruby::Kit
|
|
|
263
260
|
msg = packer.pack_message_to_capacity(msg, max_instructions: ctx[:max_instructions_per_transaction])
|
|
264
261
|
InstructionPlans.assert_max_instructions_per_transaction(
|
|
265
262
|
msg.instructions.length,
|
|
266
|
-
|
|
263
|
+
ctx[:max_instructions_per_transaction]
|
|
267
264
|
)
|
|
268
265
|
end
|
|
269
266
|
msg
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# Mirrors @solana/instruction-plans.
|
|
5
5
|
# An InstructionPlan describes operations that may span multiple transactions.
|
|
6
|
-
require_relative 'instruction_plans/
|
|
6
|
+
require_relative 'instruction_plans/message_packer_errors'
|
|
7
7
|
require_relative 'instruction_plans/instruction_plan'
|
|
8
8
|
require_relative 'instruction_plans/transaction_plan'
|
|
9
9
|
require_relative 'instruction_plans/transaction_plan_result'
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require 'rbnacl'
|
|
5
5
|
require_relative '../errors'
|
|
6
6
|
require_relative '../encoding/base58'
|
|
7
|
+
require_relative 'small_order'
|
|
7
8
|
|
|
8
9
|
module Solana::Ruby::Kit
|
|
9
10
|
module Keys
|
|
@@ -144,11 +145,23 @@ module Solana::Ruby::Kit
|
|
|
144
145
|
# Mirrors `verifySignature(key, signature, data)` in TypeScript.
|
|
145
146
|
# Returns false rather than raising on mismatch, matching the TS return type.
|
|
146
147
|
#
|
|
148
|
+
# Verification is strict: a public key or a signature `R` component that is
|
|
149
|
+
# a point of small order is rejected without consulting RbNaCl, matching the
|
|
150
|
+
# Solana runtime whatever the linked libsodium does itself. Upstream raises
|
|
151
|
+
# for a non-extractable or non-public CryptoKey; an RbNaCl::VerifyKey always
|
|
152
|
+
# exposes its bytes, so here only a non-VerifyKey (e.g. a SigningKey) raises.
|
|
153
|
+
#
|
|
147
154
|
# @param verify_key [RbNaCl::VerifyKey]
|
|
148
155
|
# @param sig_bytes [SignatureBytes]
|
|
149
156
|
# @param data [String] binary string that was signed
|
|
150
157
|
sig { params(verify_key: T.untyped, sig_bytes: SignatureBytes, data: String).returns(T::Boolean) }
|
|
151
158
|
def verify_signature(verify_key, sig_bytes, data)
|
|
159
|
+
unless verify_key.is_a?(RbNaCl::VerifyKey)
|
|
160
|
+
Kernel.raise SolanaError.new(SolanaError::KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY)
|
|
161
|
+
end
|
|
162
|
+
return false if SmallOrder.small_order_point_encoding?(verify_key.to_bytes)
|
|
163
|
+
return false if SmallOrder.small_order_point_encoding?(sig_bytes.value.byteslice(0, 32).to_s)
|
|
164
|
+
|
|
152
165
|
verify_key.verify(sig_bytes.value, data.b)
|
|
153
166
|
true
|
|
154
167
|
rescue RbNaCl::BadSignatureError
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Solana::Ruby::Kit
|
|
5
|
+
module Keys
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
# Mirrors the private `isSmallOrderPointEncoding` in @solana/keys
|
|
9
|
+
# (`small-order.ts`). An Ed25519 public key or signature `R` that is a point
|
|
10
|
+
# of small order lets trivially forged signatures verify against any data;
|
|
11
|
+
# the Solana runtime rejects them, so `verify_signature` does too.
|
|
12
|
+
module SmallOrder
|
|
13
|
+
extend T::Sig
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
P = T.let((1 << 255) - 19, Integer)
|
|
17
|
+
|
|
18
|
+
# The y-coordinates of the eight small-order points (the x sign bit picks
|
|
19
|
+
# between each pair, so it is masked off before the lookup). The two large
|
|
20
|
+
# ones are parsed from Strings because Sorbet rejects bignum literals.
|
|
21
|
+
SMALL_ORDER_Y_COORDINATES = T.let(
|
|
22
|
+
[
|
|
23
|
+
0,
|
|
24
|
+
1,
|
|
25
|
+
P - 1,
|
|
26
|
+
Integer('2707385501144840649318225287225658788936804267575313519463743609750303402022'),
|
|
27
|
+
Integer('55188659117513257062467267217118295137698188065244968500265048394206261417927')
|
|
28
|
+
].freeze,
|
|
29
|
+
T::Array[Integer]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# True when +bytes+ (a 32-byte binary String) encodes a point of small
|
|
33
|
+
# order, canonically or not (a y-coordinate >= p is reduced mod p first).
|
|
34
|
+
# Any other length returns false.
|
|
35
|
+
sig { params(bytes: String).returns(T::Boolean) }
|
|
36
|
+
def small_order_point_encoding?(bytes)
|
|
37
|
+
bytes = bytes.b
|
|
38
|
+
return false unless bytes.bytesize == 32
|
|
39
|
+
|
|
40
|
+
last = bytes.getbyte(31).to_i & 0x7f
|
|
41
|
+
y = (bytes.byteslice(0, 31).to_s + last.chr).reverse.unpack1('H*').to_i(16)
|
|
42
|
+
SMALL_ORDER_Y_COORDINATES.include?(y % P)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -105,9 +105,9 @@ module Solana::Ruby::Kit
|
|
|
105
105
|
# `Signature` holds a base58 string (that is what `sign_offchain_message` returns),
|
|
106
106
|
# so it has to be decoded back to the 64 raw bytes RbNaCl expects.
|
|
107
107
|
sig_bytes = Solana::Ruby::Kit::Encoding::Base58.decode(signature.to_s)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
rescue RbNaCl::
|
|
108
|
+
# Through Keys.verify_signature for its small-order key/`R` rejection.
|
|
109
|
+
Keys.verify_signature(vk, Keys.signature_bytes(sig_bytes), payload)
|
|
110
|
+
rescue RbNaCl::LengthError, SolanaError
|
|
111
111
|
false
|
|
112
112
|
end
|
|
113
113
|
end
|
|
@@ -154,14 +154,15 @@ module Solana::Ruby::Kit
|
|
|
154
154
|
sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
|
|
155
155
|
def decode_compact_u16(bytes, offset)
|
|
156
156
|
value = 0
|
|
157
|
-
|
|
158
|
-
Kernel.loop do
|
|
157
|
+
(0...3).each do |i|
|
|
159
158
|
byte, offset = read_byte(bytes, offset)
|
|
160
|
-
value |= (byte & 0x7f) <<
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
value |= (byte & 0x7f) << (i * 7)
|
|
160
|
+
next unless (byte & 0x80).zero?
|
|
161
|
+
|
|
162
|
+
Codecs::Numbers.assert_short_u16_in_range(value)
|
|
163
|
+
return [value, offset]
|
|
163
164
|
end
|
|
164
|
-
|
|
165
|
+
Codecs::Numbers.raise_short_u16_too_long
|
|
165
166
|
end
|
|
166
167
|
private_class_method :decode_compact_u16
|
|
167
168
|
end
|
|
@@ -233,8 +233,7 @@ module Solana::Ruby::Kit
|
|
|
233
233
|
sig { params(bytes: String, offset: Integer).returns([Integer, Integer]) }
|
|
234
234
|
def decode_compact_u16(bytes, offset)
|
|
235
235
|
value = 0
|
|
236
|
-
|
|
237
|
-
Kernel.loop do
|
|
236
|
+
(0...3).each do |i|
|
|
238
237
|
byte = bytes.getbyte(offset)
|
|
239
238
|
if byte.nil?
|
|
240
239
|
Kernel.raise SolanaError.new(
|
|
@@ -243,11 +242,13 @@ module Solana::Ruby::Kit
|
|
|
243
242
|
)
|
|
244
243
|
end
|
|
245
244
|
offset += 1
|
|
246
|
-
value |= (byte & 0x7f) <<
|
|
247
|
-
|
|
248
|
-
|
|
245
|
+
value |= (byte & 0x7f) << (i * 7)
|
|
246
|
+
next unless (byte & 0x80).zero?
|
|
247
|
+
|
|
248
|
+
Codecs::Numbers.assert_short_u16_in_range(value)
|
|
249
|
+
return [value, offset]
|
|
249
250
|
end
|
|
250
|
-
|
|
251
|
+
Codecs::Numbers.raise_short_u16_too_long
|
|
251
252
|
end
|
|
252
253
|
private_class_method :decode_compact_u16
|
|
253
254
|
end
|
data/solana-ruby-kit.gemspec
CHANGED
|
@@ -7,7 +7,7 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.version = Solana::Ruby::Kit::VERSION
|
|
8
8
|
spec.authors = ['Paul Zupan, Idhra Inc.']
|
|
9
9
|
spec.summary = 'Ruby port of the Anza TypeScript SDK (@anza-xyz/kit)'
|
|
10
|
-
spec.homepage = 'https://github.com/
|
|
10
|
+
spec.homepage = 'https://github.com/idhra/solana-ruby-kit'
|
|
11
11
|
spec.license = 'MIT'
|
|
12
12
|
spec.required_ruby_version = '>= 3.2.0'
|
|
13
13
|
spec.require_paths = ['lib']
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solana-ruby-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 8.
|
|
4
|
+
version: 8.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Zupan, Idhra Inc.
|
|
@@ -231,7 +231,7 @@ files:
|
|
|
231
231
|
- lib/solana/ruby/kit/functional.rb
|
|
232
232
|
- lib/solana/ruby/kit/instruction_plans.rb
|
|
233
233
|
- lib/solana/ruby/kit/instruction_plans/instruction_plan.rb
|
|
234
|
-
- lib/solana/ruby/kit/instruction_plans/
|
|
234
|
+
- lib/solana/ruby/kit/instruction_plans/message_packer_errors.rb
|
|
235
235
|
- lib/solana/ruby/kit/instruction_plans/plans.rb
|
|
236
236
|
- lib/solana/ruby/kit/instruction_plans/transaction_plan.rb
|
|
237
237
|
- lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb
|
|
@@ -246,6 +246,7 @@ files:
|
|
|
246
246
|
- lib/solana/ruby/kit/keys/private_key.rb
|
|
247
247
|
- lib/solana/ruby/kit/keys/public_key.rb
|
|
248
248
|
- lib/solana/ruby/kit/keys/signatures.rb
|
|
249
|
+
- lib/solana/ruby/kit/keys/small_order.rb
|
|
249
250
|
- lib/solana/ruby/kit/offchain_messages.rb
|
|
250
251
|
- lib/solana/ruby/kit/offchain_messages/codec.rb
|
|
251
252
|
- lib/solana/ruby/kit/offchain_messages/message.rb
|
|
@@ -346,13 +347,13 @@ files:
|
|
|
346
347
|
- lib/solana/ruby/kit/version.rb
|
|
347
348
|
- lib/solana/ruby/kit/wallet_standard.rb
|
|
348
349
|
- solana-ruby-kit.gemspec
|
|
349
|
-
homepage: https://github.com/
|
|
350
|
+
homepage: https://github.com/idhra/solana-ruby-kit
|
|
350
351
|
licenses:
|
|
351
352
|
- MIT
|
|
352
353
|
metadata:
|
|
353
|
-
source_code_uri: https://github.com/
|
|
354
|
-
documentation_uri: https://github.com/
|
|
355
|
-
bug_tracker_uri: https://github.com/
|
|
354
|
+
source_code_uri: https://github.com/idhra/solana-ruby-kit/tree/main
|
|
355
|
+
documentation_uri: https://github.com/idhra/solana-ruby-kit/wiki
|
|
356
|
+
bug_tracker_uri: https://github.com/idhra/solana-ruby-kit/issues
|
|
356
357
|
rdoc_options: []
|
|
357
358
|
require_paths:
|
|
358
359
|
- lib
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# typed: strict
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require_relative '../errors'
|
|
5
|
-
|
|
6
|
-
module Solana::Ruby::Kit
|
|
7
|
-
module InstructionPlans
|
|
8
|
-
extend T::Sig
|
|
9
|
-
|
|
10
|
-
# The default maximum number of top-level instructions per planned transaction message.
|
|
11
|
-
#
|
|
12
|
-
# Intentionally lower than the transaction format's instruction limit to leave headroom
|
|
13
|
-
# for inner instructions (CPIs), which are not visible at planning time.
|
|
14
|
-
DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION = T.let(16, Integer)
|
|
15
|
-
|
|
16
|
-
# The hard maximum number of top-level instructions the transaction format can encode.
|
|
17
|
-
TRANSACTION_INSTRUCTION_LIMIT = T.let(64, Integer)
|
|
18
|
-
|
|
19
|
-
extend self
|
|
20
|
-
|
|
21
|
-
# Resolves the effective maximum number of instructions allowed in a transaction message,
|
|
22
|
-
# falling back to the default when no value is provided.
|
|
23
|
-
# Mirrors `resolveMaxInstructions(maxInstructions)`.
|
|
24
|
-
sig { params(max_instructions: T.nilable(Integer)).returns(Integer) }
|
|
25
|
-
def resolve_max_instructions(max_instructions)
|
|
26
|
-
max_instructions || DEFAULT_MAX_INSTRUCTIONS_PER_TRANSACTION
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Asserts that a configured maximum number of instructions per transaction is valid.
|
|
30
|
-
# +nil+ is allowed and falls back to the default.
|
|
31
|
-
# Mirrors `assertValidMaxInstructionsPerTransaction(maxInstructions)`.
|
|
32
|
-
sig { params(max_instructions: T.nilable(Integer)).void }
|
|
33
|
-
def assert_valid_max_instructions_per_transaction(max_instructions)
|
|
34
|
-
return if max_instructions.nil?
|
|
35
|
-
|
|
36
|
-
if max_instructions <= 0 || max_instructions > TRANSACTION_INSTRUCTION_LIMIT
|
|
37
|
-
Kernel.raise SolanaError.new(
|
|
38
|
-
SolanaError::INSTRUCTION_PLANS__INVALID_MAX_INSTRUCTIONS_PER_TRANSACTION,
|
|
39
|
-
{
|
|
40
|
-
max_instructions: max_instructions,
|
|
41
|
-
transaction_instruction_limit: TRANSACTION_INSTRUCTION_LIMIT
|
|
42
|
-
}
|
|
43
|
-
)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Raises if +num_instructions+ exceeds +max_instructions+.
|
|
48
|
-
# Mirrors `assertMaxInstructionsPerTransaction(numInstructions, maxInstructions)`.
|
|
49
|
-
sig { params(num_instructions: Integer, max_instructions: Integer).void }
|
|
50
|
-
def assert_max_instructions_per_transaction(num_instructions, max_instructions)
|
|
51
|
-
return if num_instructions <= max_instructions
|
|
52
|
-
|
|
53
|
-
Kernel.raise SolanaError.new(
|
|
54
|
-
SolanaError::INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
|
|
55
|
-
{ max_instructions: max_instructions, num_instructions: num_instructions }
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|