solana-ruby-kit 0.1.0 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 328621bd69f0601b600df302ea5169001983cf56fa44cf23225e3f2515d5d34a
4
- data.tar.gz: 5e2bc165e53ff6818b5650f5d8551e6ec3331f65df3e656d0bb7ecd588c16fb3
3
+ metadata.gz: 8be22a1330f9190db447c2a209a2dc44991c75efc6758bbe9e94f6e56b579473
4
+ data.tar.gz: c33dc52550866af9c572ce7911fe0baa9d053dc0f4723e5b700f0473ee5ffee4
5
5
  SHA512:
6
- metadata.gz: 7664963dae246c46ef5d09c7f1d3d05b5c67cecc38dbd961955da8e86cce9a4d0aeff55fcc47425b35826dfe552bb032715f5f178dea8e8c716828013ebb151d
7
- data.tar.gz: a5ee1ed23cca39029dc7a5e84f6319f06ef3319aaeb62488b12b702c6f7c391b8256ed7c706c140e4242285042a4d5b8c0ce7eb0fa484368a182556aa7ac0b84
6
+ metadata.gz: ee653c6ea8f2b6b2dd5359e6d49281431d9127db48600d836d560ebd32dde7e75294fc6cc44f8b609de4fb0bc565bf3e810a30ca050ce78d7e8506177e7ba6cd
7
+ data.tar.gz: 5483176779980f87b8102e81dca85afc4f71a413281b5b296d50edee38ebae9b000c370d75362a442abd84589ec6014b661e075623aa5ba7cf5920df0b20bd4b
@@ -0,0 +1,133 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative '../addresses/program_derived_address'
6
+ require_relative '../instructions/instruction'
7
+ require_relative '../instructions/accounts'
8
+
9
+ module Solana::Ruby::Kit
10
+ module Programs
11
+ # Ruby interface for the SPL Associated Token Account on-chain program.
12
+ #
13
+ # An Associated Token Account (ATA) is a Program Derived Address that holds
14
+ # SPL token balances for a given wallet + mint pair. The ATA address is
15
+ # deterministic: given a wallet and a mint, there is exactly one "canonical"
16
+ # token account for that combination.
17
+ #
18
+ # Seeds used for PDA derivation (all raw 32-byte address buffers):
19
+ # [ wallet_bytes, token_program_bytes, mint_bytes ]
20
+ # Program: PROGRAM_ID (the Associated Token Account program)
21
+ #
22
+ # Reference: https://github.com/solana-program/associated-token-account
23
+ module AssociatedTokenAccount
24
+ extend T::Sig
25
+
26
+ # SPL Associated Token Account program.
27
+ PROGRAM_ID = T.let(
28
+ Addresses::Address.new('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'),
29
+ Addresses::Address
30
+ )
31
+
32
+ # Original SPL Token program (spl-token).
33
+ TOKEN_PROGRAM_ID = T.let(
34
+ Addresses::Address.new('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
35
+ Addresses::Address
36
+ )
37
+
38
+ # Token Extensions program (spl-token-2022).
39
+ TOKEN_2022_PROGRAM_ID = T.let(
40
+ Addresses::Address.new('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'),
41
+ Addresses::Address
42
+ )
43
+
44
+ # Solana System Program.
45
+ SYSTEM_PROGRAM_ID = T.let(
46
+ Addresses::Address.new('11111111111111111111111111111111'),
47
+ Addresses::Address
48
+ )
49
+
50
+ module_function
51
+
52
+ # Derives the canonical Associated Token Account address (a PDA) for a
53
+ # given wallet and mint.
54
+ #
55
+ # @param wallet [Addresses::Address] the account that will own the ATA
56
+ # @param mint [Addresses::Address] the token mint
57
+ # @param token_program_id [Addresses::Address] TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID
58
+ # @return [Addresses::ProgramDerivedAddress] the ATA address + bump seed
59
+ sig do
60
+ params(
61
+ wallet: Addresses::Address,
62
+ mint: Addresses::Address,
63
+ token_program_id: Addresses::Address
64
+ ).returns(Addresses::ProgramDerivedAddress)
65
+ end
66
+ def get_associated_token_address(wallet:, mint:, token_program_id: TOKEN_PROGRAM_ID)
67
+ wallet_bytes = Addresses.decode_address(wallet)
68
+ token_program_bytes = Addresses.decode_address(token_program_id)
69
+ mint_bytes = Addresses.decode_address(mint)
70
+
71
+ Addresses.get_program_derived_address(
72
+ program_address: PROGRAM_ID,
73
+ seeds: [wallet_bytes, token_program_bytes, mint_bytes]
74
+ )
75
+ end
76
+
77
+ # Builds an instruction that creates an Associated Token Account.
78
+ #
79
+ # Account layout expected by the on-chain program (in order):
80
+ # 0. payer — writable, signer (funds the rent)
81
+ # 1. associated_token_account — writable (the new ATA; derived via PDA)
82
+ # 2. wallet — readonly (the ATA's owner)
83
+ # 3. mint — readonly
84
+ # 4. system_program — readonly
85
+ # 5. token_program — readonly
86
+ #
87
+ # Instruction data:
88
+ # nil / empty → "Create" (fails if the ATA already exists)
89
+ # "\x01" → "CreateIdempotent" (no-op if already initialised)
90
+ #
91
+ # @param payer [Addresses::Address] pays for rent
92
+ # @param wallet [Addresses::Address] will own the ATA
93
+ # @param mint [Addresses::Address] token mint
94
+ # @param token_program_id [Addresses::Address] which token program to use
95
+ # @param idempotent [Boolean] use the idempotent variant
96
+ # @return [Instructions::Instruction]
97
+ sig do
98
+ params(
99
+ payer: Addresses::Address,
100
+ wallet: Addresses::Address,
101
+ mint: Addresses::Address,
102
+ token_program_id: Addresses::Address,
103
+ idempotent: T::Boolean
104
+ ).returns(Instructions::Instruction)
105
+ end
106
+ def create_instruction(
107
+ payer:,
108
+ wallet:,
109
+ mint:,
110
+ token_program_id: TOKEN_PROGRAM_ID,
111
+ idempotent: false
112
+ )
113
+ ata = get_associated_token_address(wallet: wallet, mint: mint, token_program_id: token_program_id)
114
+
115
+ # Discriminator: none for "Create", 0x01 for "CreateIdempotent"
116
+ data = idempotent ? "\x01".b : nil
117
+
118
+ Instructions::Instruction.new(
119
+ program_address: PROGRAM_ID,
120
+ accounts: [
121
+ Instructions.writable_signer_account(payer), # 0 payer
122
+ Instructions.writable_account(ata.address), # 1 ATA to create
123
+ Instructions.readonly_account(wallet), # 2 owner (readonly)
124
+ Instructions.readonly_account(mint), # 3 mint
125
+ Instructions.readonly_account(SYSTEM_PROGRAM_ID), # 4 System Program
126
+ Instructions.readonly_account(token_program_id), # 5 Token Program
127
+ ],
128
+ data: data
129
+ )
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,69 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative '../instructions/instruction'
6
+ require_relative '../instructions/accounts'
7
+
8
+ module Solana::Ruby::Kit
9
+ module Programs
10
+ # Ruby interface for the Solana System Program (11111111111111111111111111111111).
11
+ #
12
+ # The System Program is responsible for creating accounts, transferring SOL,
13
+ # and other low-level operations. Instruction data uses little-endian encoding:
14
+ # a u32 discriminator identifying the instruction variant, followed by any
15
+ # instruction-specific fields.
16
+ #
17
+ # Reference: https://github.com/solana-program/system
18
+ module SystemProgram
19
+ extend T::Sig
20
+
21
+ # The System Program address (all zeros, encoded in base58).
22
+ PROGRAM_ID = T.let(
23
+ Addresses::Address.new('11111111111111111111111111111111'),
24
+ Addresses::Address
25
+ )
26
+
27
+ # Instruction discriminators (u32 little-endian).
28
+ DISCRIMINATOR_TRANSFER = T.let(2, Integer)
29
+
30
+ module_function
31
+
32
+ # Builds a System Program Transfer instruction that moves +lamports+ from
33
+ # +sender+ to +recipient+.
34
+ #
35
+ # Instruction data layout (12 bytes):
36
+ # [0..3] u32 LE — discriminator (2)
37
+ # [4..11] u64 LE — lamports
38
+ #
39
+ # Accounts:
40
+ # 0. sender — writable, signer (source of SOL)
41
+ # 1. recipient — writable (destination)
42
+ #
43
+ # @param sender [Addresses::Address]
44
+ # @param recipient [Addresses::Address]
45
+ # @param lamports [Integer] amount to transfer (1 SOL = 1_000_000_000 lamports)
46
+ # @return [Instructions::Instruction]
47
+ sig do
48
+ params(
49
+ sender: Addresses::Address,
50
+ recipient: Addresses::Address,
51
+ lamports: Integer
52
+ ).returns(Instructions::Instruction)
53
+ end
54
+ def transfer_instruction(sender:, recipient:, lamports:)
55
+ # Pack as: u32 LE discriminator || u64 LE lamports
56
+ data = [DISCRIMINATOR_TRANSFER, lamports].pack('VQ<').b
57
+
58
+ Instructions::Instruction.new(
59
+ program_address: PROGRAM_ID,
60
+ accounts: [
61
+ Instructions.writable_signer_account(sender), # 0 sender
62
+ Instructions.writable_account(recipient), # 1 recipient
63
+ ],
64
+ data: data
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,6 +3,8 @@
3
3
 
4
4
  # Mirrors @solana/programs — helpers for inspecting custom program errors
5
5
  # returned in Solana transaction failures.
6
+ require_relative 'programs/associated_token_account'
7
+ require_relative 'programs/system_program'
6
8
  #
7
9
  # A program error in a transaction result looks like:
8
10
  # { "InstructionError" => [0, { "Custom" => 1234 }] }
@@ -3,6 +3,7 @@
3
3
 
4
4
  require 'base64'
5
5
  require_relative '../../keys/signatures'
6
+ require_relative '../../transactions/compiler'
6
7
 
7
8
  module Solana::Ruby::Kit
8
9
  module Rpc
@@ -43,7 +44,7 @@ module Solana::Ruby::Kit
43
44
  transaction
44
45
  else
45
46
  # Assume it responds to .message_bytes (Transactions::Transaction)
46
- Base64.strict_encode64(transaction.message_bytes)
47
+ Base64.strict_encode64(Transactions.wire_encode_transaction(transaction))
47
48
  end
48
49
 
49
50
  config = { 'encoding' => 'base64' }
@@ -0,0 +1,192 @@
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
+ module_function
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # compile_transaction_message
16
+ # ---------------------------------------------------------------------------
17
+ # Compiles a TransactionMessage into a Transaction ready for signing.
18
+ #
19
+ # Only legacy (version: :legacy) transactions are supported; the serialised
20
+ # format follows the Solana on-wire message layout:
21
+ #
22
+ # [header: 3 bytes]
23
+ # [compact-u16 account count] [32-byte addresses …]
24
+ # [recent blockhash: 32 bytes]
25
+ # [compact-u16 instruction count]
26
+ # [for each instruction:
27
+ # [program_id_index: u8]
28
+ # [compact-u16 account count] [account indices: u8 …]
29
+ # [compact-u16 data length] [data bytes]
30
+ # ]
31
+ #
32
+ # The returned Transaction contains:
33
+ # - message_bytes — the serialised message (the bytes that are signed)
34
+ # - signatures — an ordered hash of signer_address → nil (unfilled)
35
+ #
36
+ # Use wire_encode_transaction to prepend the signatures section before
37
+ # submitting to the RPC node via sendTransaction.
38
+ sig { params(message: TransactionMessages::TransactionMessage).returns(Transaction) }
39
+ def compile_transaction_message(message)
40
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING) if message.fee_payer.nil?
41
+ fee_payer = T.must(message.fee_payer)
42
+
43
+ constraint = message.lifetime_constraint
44
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME) unless constraint.is_a?(TransactionMessages::BlockhashLifetimeConstraint)
45
+ blockhash_str = constraint.blockhash
46
+
47
+ # ── 1. Collect accounts and merge roles ────────────────────────────────
48
+ # Insertion-ordered hash: address_str → merged AccountRole integer.
49
+ account_roles = T.let({}, T::Hash[String, Integer])
50
+
51
+ # Fee payer is always the first writable signer.
52
+ account_roles[fee_payer.value] = Instructions::AccountRole::WRITABLE_SIGNER
53
+
54
+ message.instructions.each do |ix|
55
+ # The instruction's program address is a readonly non-signer participant.
56
+ prog = ix.program_address.value
57
+ account_roles[prog] ||= Instructions::AccountRole::READONLY
58
+
59
+ (ix.accounts || []).each do |meta|
60
+ addr = meta.address.value
61
+ existing = account_roles[addr] || Instructions::AccountRole::READONLY
62
+ account_roles[addr] = Instructions::AccountRole.merge(existing, meta.role)
63
+ end
64
+ end
65
+
66
+ # ── 2. Partition into the four groups and sort within each ─────────────
67
+ fp = fee_payer.value
68
+
69
+ writable_signers = T.let([], T::Array[String])
70
+ readonly_signers = T.let([], T::Array[String])
71
+ writable_non_signers = T.let([], T::Array[String])
72
+ readonly_non_signers = T.let([], T::Array[String])
73
+
74
+ account_roles.each do |addr, role|
75
+ next if addr == fp # fee payer is handled separately
76
+
77
+ is_signer = Instructions::AccountRole.signer_role?(role)
78
+ is_writable = Instructions::AccountRole.writable_role?(role)
79
+
80
+ if is_signer && is_writable
81
+ writable_signers << addr
82
+ elsif is_signer
83
+ readonly_signers << addr
84
+ elsif is_writable
85
+ writable_non_signers << addr
86
+ else
87
+ readonly_non_signers << addr
88
+ end
89
+ end
90
+
91
+ writable_signers.sort!
92
+ readonly_signers.sort!
93
+ writable_non_signers.sort!
94
+ readonly_non_signers.sort!
95
+
96
+ # Fee payer first, then writable signers, readonly signers, non-signers.
97
+ ordered = [fp] + writable_signers + readonly_signers +
98
+ writable_non_signers + readonly_non_signers
99
+
100
+ # ── 3. Build index lookup ──────────────────────────────────────────────
101
+ account_index = T.let({}, T::Hash[String, Integer])
102
+ ordered.each_with_index { |addr, i| account_index[addr] = i }
103
+
104
+ # ── 4. Message header (3 bytes) ────────────────────────────────────────
105
+ num_required_sigs = 1 + writable_signers.size + readonly_signers.size
106
+ num_readonly_signed = readonly_signers.size
107
+ num_readonly_unsigned = readonly_non_signers.size
108
+
109
+ header = [num_required_sigs, num_readonly_signed, num_readonly_unsigned].pack('CCC').b
110
+
111
+ # ── 5. Account addresses section ───────────────────────────────────────
112
+ accounts_section = encode_compact_u16(ordered.size)
113
+ ordered.each do |addr_str|
114
+ accounts_section = accounts_section + Addresses.decode_address(Addresses::Address.new(addr_str))
115
+ end
116
+
117
+ # ── 6. Recent blockhash (32 bytes) ─────────────────────────────────────
118
+ blockhash_bytes = Addresses.decode_address(Addresses::Address.new(blockhash_str))
119
+
120
+ # ── 7. Instructions section ────────────────────────────────────────────
121
+ ixs_section = encode_compact_u16(message.instructions.size)
122
+
123
+ message.instructions.each do |ix|
124
+ prog_idx = T.must(account_index[ix.program_address.value])
125
+ ix_accounts = ix.accounts || []
126
+ ix_indices = ix_accounts.map { |m| T.must(account_index[m.address.value]) }
127
+ data_bytes = (ix.data || '').b
128
+
129
+ ixs_section = ixs_section +
130
+ [prog_idx].pack('C').b +
131
+ encode_compact_u16(ix_indices.size) +
132
+ ix_indices.pack('C*').b +
133
+ encode_compact_u16(data_bytes.bytesize) +
134
+ data_bytes
135
+ end
136
+
137
+ message_bytes = (header + accounts_section + blockhash_bytes + ixs_section).b
138
+
139
+ # ── 8. Signatures map (one nil slot per required signer) ───────────────
140
+ signer_addresses = [fp] + writable_signers + readonly_signers
141
+ signatures = T.let({}, T::Hash[String, T.nilable(String)])
142
+ signer_addresses.each { |addr| signatures[addr] = nil }
143
+
144
+ Transaction.new(message_bytes: message_bytes, signatures: signatures)
145
+ end
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # wire_encode_transaction
149
+ # ---------------------------------------------------------------------------
150
+ # Encodes a Transaction (signed or partially signed) into the full Solana
151
+ # on-wire format that the RPC node's sendTransaction method expects:
152
+ #
153
+ # [compact-u16 signature count]
154
+ # [64-byte signature or 64 zero bytes if nil] × count
155
+ # [message bytes]
156
+ #
157
+ # The result is a binary String; base64-encode it before sending via HTTP.
158
+ sig { params(transaction: Transaction).returns(String) }
159
+ def wire_encode_transaction(transaction)
160
+ sigs = transaction.signatures
161
+ header = encode_compact_u16(sigs.size)
162
+ sig_bytes = sigs.values.map { |s| (s || ("\x00" * 64)).b }.join
163
+
164
+ (header + sig_bytes + transaction.message_bytes).b
165
+ end
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Private helpers
169
+ # ---------------------------------------------------------------------------
170
+
171
+ # Encodes a non-negative integer using Solana's compact-u16 format.
172
+ # 0–127 → 1 byte
173
+ # 128–16383 → 2 bytes
174
+ # 16384+ → 3 bytes (max value 0x7fff used in practice)
175
+ sig { params(value: Integer).returns(String) }
176
+ def encode_compact_u16(value)
177
+ result = T.let([], T::Array[Integer])
178
+ remaining = value
179
+
180
+ Kernel.loop do
181
+ byte = remaining & 0x7f
182
+ remaining = remaining >> 7
183
+ byte |= 0x80 if remaining > 0
184
+ result << byte
185
+ break if remaining == 0
186
+ end
187
+
188
+ result.pack('C*').b
189
+ end
190
+ private_class_method :encode_compact_u16
191
+ end
192
+ end
@@ -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.4'
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.4
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-02-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: openssl
@@ -225,6 +225,8 @@ files:
225
225
  - lib/solana/ruby/kit/options/option.rb
226
226
  - lib/solana/ruby/kit/plugin_core.rb
227
227
  - lib/solana/ruby/kit/programs.rb
228
+ - lib/solana/ruby/kit/programs/associated_token_account.rb
229
+ - lib/solana/ruby/kit/programs/system_program.rb
228
230
  - lib/solana/ruby/kit/promises.rb
229
231
  - lib/solana/ruby/kit/railtie.rb
230
232
  - lib/solana/ruby/kit/rpc.rb
@@ -285,6 +287,7 @@ files:
285
287
  - lib/solana/ruby/kit/transaction_messages.rb
286
288
  - lib/solana/ruby/kit/transaction_messages/transaction_message.rb
287
289
  - lib/solana/ruby/kit/transactions.rb
290
+ - lib/solana/ruby/kit/transactions/compiler.rb
288
291
  - lib/solana/ruby/kit/transactions/transaction.rb
289
292
  - lib/solana/ruby/kit/version.rb
290
293
  - solana-ruby-kit.gemspec