solana-ruby-kit 0.1.0 → 0.1.5

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.
@@ -0,0 +1,211 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'transaction'
5
+ require_relative '../addresses/address'
6
+ require_relative '../transaction_messages/transaction_message'
7
+ require_relative '../instructions/roles'
8
+ require_relative '../errors'
9
+
10
+ module Solana::Ruby::Kit
11
+ module Transactions
12
+ # Maximum wire-encoded transaction size in bytes (Solana protocol limit).
13
+ TRANSACTION_SIZE_LIMIT = T.let(1232, Integer)
14
+
15
+ module_function
16
+
17
+ # Returns the compiled byte-size of a transaction message.
18
+ # Mirrors `getTransactionMessageSize()` from @solana/transactions.
19
+ # The message must have a fee payer and a blockhash lifetime set.
20
+ sig { params(message: TransactionMessages::TransactionMessage).returns(Integer) }
21
+ def get_transaction_message_size(message)
22
+ compile_transaction_message(message).message_bytes.bytesize
23
+ end
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # compile_transaction_message
27
+ # ---------------------------------------------------------------------------
28
+ # Compiles a TransactionMessage into a Transaction ready for signing.
29
+ #
30
+ # Only legacy (version: :legacy) transactions are supported; the serialised
31
+ # format follows the Solana on-wire message layout:
32
+ #
33
+ # [header: 3 bytes]
34
+ # [compact-u16 account count] [32-byte addresses …]
35
+ # [recent blockhash: 32 bytes]
36
+ # [compact-u16 instruction count]
37
+ # [for each instruction:
38
+ # [program_id_index: u8]
39
+ # [compact-u16 account count] [account indices: u8 …]
40
+ # [compact-u16 data length] [data bytes]
41
+ # ]
42
+ #
43
+ # The returned Transaction contains:
44
+ # - message_bytes — the serialised message (the bytes that are signed)
45
+ # - signatures — an ordered hash of signer_address → nil (unfilled)
46
+ #
47
+ # Use wire_encode_transaction to prepend the signatures section before
48
+ # submitting to the RPC node via sendTransaction.
49
+ sig { params(message: TransactionMessages::TransactionMessage).returns(Transaction) }
50
+ def compile_transaction_message(message)
51
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING) if message.fee_payer.nil?
52
+ fee_payer = T.must(message.fee_payer)
53
+
54
+ # ── 1. Collect accounts and merge roles ────────────────────────────────
55
+ # Insertion-ordered hash: address_str → merged AccountRole integer.
56
+ account_roles = T.let({}, T::Hash[String, Integer])
57
+
58
+ # Fee payer is always the first writable signer.
59
+ account_roles[fee_payer.value] = Instructions::AccountRole::WRITABLE_SIGNER
60
+
61
+ message.instructions.each do |ix|
62
+ # The instruction's program address is a readonly non-signer participant.
63
+ prog = ix.program_address.value
64
+ account_roles[prog] ||= Instructions::AccountRole::READONLY
65
+
66
+ (ix.accounts || []).each do |meta|
67
+ addr = meta.address.value
68
+ existing = account_roles[addr] || Instructions::AccountRole::READONLY
69
+ account_roles[addr] = Instructions::AccountRole.merge(existing, meta.role)
70
+ end
71
+ end
72
+
73
+ # ── 2. Partition into the four groups and sort within each ─────────────
74
+ fp = fee_payer.value
75
+
76
+ writable_signers = T.let([], T::Array[String])
77
+ readonly_signers = T.let([], T::Array[String])
78
+ writable_non_signers = T.let([], T::Array[String])
79
+ readonly_non_signers = T.let([], T::Array[String])
80
+
81
+ account_roles.each do |addr, role|
82
+ next if addr == fp # fee payer is handled separately
83
+
84
+ is_signer = Instructions::AccountRole.signer_role?(role)
85
+ is_writable = Instructions::AccountRole.writable_role?(role)
86
+
87
+ if is_signer && is_writable
88
+ writable_signers << addr
89
+ elsif is_signer
90
+ readonly_signers << addr
91
+ elsif is_writable
92
+ writable_non_signers << addr
93
+ else
94
+ readonly_non_signers << addr
95
+ end
96
+ end
97
+
98
+ writable_signers.sort!
99
+ readonly_signers.sort!
100
+ writable_non_signers.sort!
101
+ readonly_non_signers.sort!
102
+
103
+ # Fee payer first, then writable signers, readonly signers, non-signers.
104
+ ordered = [fp] + writable_signers + readonly_signers +
105
+ writable_non_signers + readonly_non_signers
106
+
107
+ # ── 3. Build index lookup ──────────────────────────────────────────────
108
+ account_index = T.let({}, T::Hash[String, Integer])
109
+ ordered.each_with_index { |addr, i| account_index[addr] = i }
110
+
111
+ # ── 4. Message header (3 bytes) ────────────────────────────────────────
112
+ num_required_sigs = 1 + writable_signers.size + readonly_signers.size
113
+ num_readonly_signed = readonly_signers.size
114
+ num_readonly_unsigned = readonly_non_signers.size
115
+
116
+ header = [num_required_sigs, num_readonly_signed, num_readonly_unsigned].pack('CCC').b
117
+
118
+ # ── 5. Account addresses section ───────────────────────────────────────
119
+ accounts_section = encode_compact_u16(ordered.size)
120
+ ordered.each do |addr_str|
121
+ accounts_section = accounts_section + Addresses.decode_address(Addresses::Address.new(addr_str))
122
+ end
123
+
124
+ # ── 6. Recent blockhash (32 bytes) ─────────────────────────────────────
125
+ # Mirrors upstream compile-transaction.ts #581:
126
+ # - BlockhashLifetime → use the blockhash bytes
127
+ # - DurableNonceLifetime → use the nonce value bytes (stored in the same field)
128
+ # - No lifetime → 32 zero bytes (placeholder; must be replaced before signing)
129
+ constraint = message.lifetime_constraint
130
+ blockhash_bytes =
131
+ if constraint.is_a?(TransactionMessages::BlockhashLifetimeConstraint)
132
+ Addresses.decode_address(Addresses::Address.new(constraint.blockhash))
133
+ elsif constraint.is_a?(TransactionMessages::DurableNonceLifetimeConstraint)
134
+ Addresses.decode_address(Addresses::Address.new(constraint.nonce))
135
+ else
136
+ ("\x00" * 32).b
137
+ end
138
+
139
+ # ── 7. Instructions section ────────────────────────────────────────────
140
+ ixs_section = encode_compact_u16(message.instructions.size)
141
+
142
+ message.instructions.each do |ix|
143
+ prog_idx = T.must(account_index[ix.program_address.value])
144
+ ix_accounts = ix.accounts || []
145
+ ix_indices = ix_accounts.map { |m| T.must(account_index[m.address.value]) }
146
+ data_bytes = (ix.data || '').b
147
+
148
+ ixs_section = ixs_section +
149
+ [prog_idx].pack('C').b +
150
+ encode_compact_u16(ix_indices.size) +
151
+ ix_indices.pack('C*').b +
152
+ encode_compact_u16(data_bytes.bytesize) +
153
+ data_bytes
154
+ end
155
+
156
+ message_bytes = (header + accounts_section + blockhash_bytes + ixs_section).b
157
+
158
+ # ── 8. Signatures map (one nil slot per required signer) ───────────────
159
+ signer_addresses = [fp] + writable_signers + readonly_signers
160
+ signatures = T.let({}, T::Hash[String, T.nilable(String)])
161
+ signer_addresses.each { |addr| signatures[addr] = nil }
162
+
163
+ Transaction.new(message_bytes: message_bytes, signatures: signatures)
164
+ end
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # wire_encode_transaction
168
+ # ---------------------------------------------------------------------------
169
+ # Encodes a Transaction (signed or partially signed) into the full Solana
170
+ # on-wire format that the RPC node's sendTransaction method expects:
171
+ #
172
+ # [compact-u16 signature count]
173
+ # [64-byte signature or 64 zero bytes if nil] × count
174
+ # [message bytes]
175
+ #
176
+ # The result is a binary String; base64-encode it before sending via HTTP.
177
+ sig { params(transaction: Transaction).returns(String) }
178
+ def wire_encode_transaction(transaction)
179
+ sigs = transaction.signatures
180
+ header = encode_compact_u16(sigs.size)
181
+ sig_bytes = sigs.values.map { |s| (s || ("\x00" * 64)).b }.join
182
+
183
+ (header + sig_bytes + transaction.message_bytes).b
184
+ end
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Private helpers
188
+ # ---------------------------------------------------------------------------
189
+
190
+ # Encodes a non-negative integer using Solana's compact-u16 format.
191
+ # 0–127 → 1 byte
192
+ # 128–16383 → 2 bytes
193
+ # 16384+ → 3 bytes (max value 0x7fff used in practice)
194
+ sig { params(value: Integer).returns(String) }
195
+ def encode_compact_u16(value)
196
+ result = T.let([], T::Array[Integer])
197
+ remaining = value
198
+
199
+ Kernel.loop do
200
+ byte = remaining & 0x7f
201
+ remaining = remaining >> 7
202
+ byte |= 0x80 if remaining > 0
203
+ result << byte
204
+ break if remaining == 0
205
+ end
206
+
207
+ result.pack('C*').b
208
+ end
209
+ private_class_method :encode_compact_u16
210
+ end
211
+ end
@@ -67,6 +67,42 @@ module Solana::Ruby::Kit
67
67
  )
68
68
  end
69
69
 
70
+ # Returns true if the wire-encoded transaction fits within TRANSACTION_SIZE_LIMIT.
71
+ # Mirrors `isTransactionWithinSizeLimit(transaction)`.
72
+ sig { params(transaction: Transaction).returns(T::Boolean) }
73
+ def within_size_limit?(transaction)
74
+ wire_encode_transaction(transaction).bytesize <= TRANSACTION_SIZE_LIMIT
75
+ end
76
+
77
+ # Raises SolanaError if the wire-encoded transaction exceeds TRANSACTION_SIZE_LIMIT.
78
+ # Mirrors `assertIsTransactionWithinSizeLimit(transaction)`.
79
+ sig { params(transaction: Transaction).void }
80
+ def assert_within_size_limit!(transaction)
81
+ actual = wire_encode_transaction(transaction).bytesize
82
+ return if actual <= TRANSACTION_SIZE_LIMIT
83
+
84
+ Kernel.raise SolanaError.new(
85
+ SolanaError::TRANSACTIONS__EXCEEDS_SIZE_LIMIT,
86
+ { actual_size: actual, limit: TRANSACTION_SIZE_LIMIT }
87
+ )
88
+ end
89
+
90
+ # Returns true if the transaction is both fully signed and within the size limit.
91
+ # Mirrors `isSendableTransaction(transaction)`.
92
+ # sendableTransaction = FullySignedTransaction & TransactionWithinSizeLimit
93
+ sig { params(transaction: Transaction).returns(T::Boolean) }
94
+ def sendable_transaction?(transaction)
95
+ fully_signed_transaction?(transaction) && within_size_limit?(transaction)
96
+ end
97
+
98
+ # Raises SolanaError unless the transaction is fully signed and within size limit.
99
+ # Mirrors `assertIsSendableTransaction(transaction)`.
100
+ sig { params(transaction: Transaction).void }
101
+ def assert_sendable_transaction!(transaction)
102
+ assert_fully_signed_transaction!(transaction)
103
+ assert_within_size_limit!(transaction)
104
+ end
105
+
70
106
  # Signs a transaction with one or more RbNaCl::SigningKey objects.
71
107
  # Only keys whose address appears in `transaction.signatures` are applied.
72
108
  # Raises SolanaError if a key is not expected to sign this transaction.
@@ -3,3 +3,4 @@
3
3
 
4
4
  # Transaction types and signing utilities — mirrors @solana/transactions.
5
5
  require_relative 'transactions/transaction'
6
+ require_relative 'transactions/compiler'
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '0.1.0'
7
+ VERSION = '0.1.5'
8
8
  end
9
9
  end
10
10
  end
@@ -5,7 +5,7 @@ require_relative 'lib/solana/ruby/kit/version'
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'solana-ruby-kit'
7
7
  spec.version = Solana::Ruby::Kit::VERSION
8
- spec.authors = ['Paul Zupan']
8
+ spec.authors = ['Paul Zupan, Idhra Inc.']
9
9
  spec.summary = 'Ruby port of the Anza TypeScript SDK (@anza-xyz/kit)'
10
10
  spec.license = 'MIT'
11
11
  spec.required_ruby_version = '>= 3.2.0'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
- - Paul Zupan
7
+ - Paul Zupan, Idhra Inc.
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-21 00:00:00.000000000 Z
10
+ date: 2026-04-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: openssl
@@ -208,7 +208,12 @@ files:
208
208
  - lib/solana/ruby/kit/fast_stable_stringify.rb
209
209
  - lib/solana/ruby/kit/functional.rb
210
210
  - lib/solana/ruby/kit/instruction_plans.rb
211
+ - lib/solana/ruby/kit/instruction_plans/instruction_plan.rb
211
212
  - lib/solana/ruby/kit/instruction_plans/plans.rb
213
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan.rb
214
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb
215
+ - lib/solana/ruby/kit/instruction_plans/transaction_plan_result.rb
216
+ - lib/solana/ruby/kit/instruction_plans/transaction_planner.rb
212
217
  - lib/solana/ruby/kit/instructions.rb
213
218
  - lib/solana/ruby/kit/instructions/accounts.rb
214
219
  - lib/solana/ruby/kit/instructions/instruction.rb
@@ -225,6 +230,8 @@ files:
225
230
  - lib/solana/ruby/kit/options/option.rb
226
231
  - lib/solana/ruby/kit/plugin_core.rb
227
232
  - lib/solana/ruby/kit/programs.rb
233
+ - lib/solana/ruby/kit/programs/associated_token_account.rb
234
+ - lib/solana/ruby/kit/programs/system_program.rb
228
235
  - lib/solana/ruby/kit/promises.rb
229
236
  - lib/solana/ruby/kit/railtie.rb
230
237
  - lib/solana/ruby/kit/rpc.rb
@@ -285,6 +292,7 @@ files:
285
292
  - lib/solana/ruby/kit/transaction_messages.rb
286
293
  - lib/solana/ruby/kit/transaction_messages/transaction_message.rb
287
294
  - lib/solana/ruby/kit/transactions.rb
295
+ - lib/solana/ruby/kit/transactions/compiler.rb
288
296
  - lib/solana/ruby/kit/transactions/transaction.rb
289
297
  - lib/solana/ruby/kit/version.rb
290
298
  - solana-ruby-kit.gemspec