solrengine-programs 0.1.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.
@@ -0,0 +1,207 @@
1
+ require "ed25519"
2
+ require "base58"
3
+ require "base64"
4
+
5
+ module Solrengine
6
+ module Programs
7
+ class TransactionBuilder
8
+ def initialize
9
+ @instructions = []
10
+ @signers = [] # Array of { secret_key:, public_key: } hashes
11
+ @fee_payer = nil
12
+ @recent_blockhash = nil
13
+ end
14
+
15
+ def add_instruction(instruction)
16
+ if instruction.is_a?(Instruction)
17
+ @instructions << instruction.to_instruction
18
+ elsif instruction.is_a?(Hash)
19
+ @instructions << instruction
20
+ else
21
+ raise Error, "Instruction must be a Solrengine::Programs::Instruction or Hash"
22
+ end
23
+ self
24
+ end
25
+
26
+ def add_signer(keypair)
27
+ @signers << keypair
28
+ self
29
+ end
30
+
31
+ def set_fee_payer(pubkey)
32
+ @fee_payer = pubkey
33
+ self
34
+ end
35
+
36
+ def set_recent_blockhash(blockhash)
37
+ @recent_blockhash = blockhash
38
+ self
39
+ end
40
+
41
+ # Build the serialized transaction (signatures + message)
42
+ def build
43
+ resolve_blockhash! unless @recent_blockhash
44
+ resolve_fee_payer! unless @fee_payer
45
+
46
+ message = build_message
47
+ signatures = sign_message(message)
48
+
49
+ # Serialize: compact-u16 signature count + signatures + message
50
+ result = BorshTypes.encode_compact_u16(signatures.size)
51
+ signatures.each { |sig| result += sig }
52
+ result += message
53
+ result
54
+ end
55
+
56
+ # Build, sign, and send the transaction
57
+ def sign_and_send(commitment: "confirmed")
58
+ ensure_signers!
59
+
60
+ tx_bytes = build
61
+ tx_base64 = Base64.strict_encode64(tx_bytes)
62
+
63
+ result = Solrengine::Rpc.client.request("sendTransaction", [
64
+ tx_base64,
65
+ {
66
+ "encoding" => "base64",
67
+ "skipPreflight" => false,
68
+ "preflightCommitment" => commitment
69
+ }
70
+ ])
71
+
72
+ if result["error"]
73
+ raise TransactionError, "Transaction failed: #{result["error"].inspect}"
74
+ end
75
+
76
+ result["result"] # Returns the transaction signature
77
+ end
78
+
79
+ private
80
+
81
+ def resolve_blockhash!
82
+ bh = Solrengine::Rpc.client.get_latest_blockhash
83
+ raise TransactionError, "Failed to fetch blockhash" unless bh
84
+ @recent_blockhash = bh[:blockhash]
85
+ end
86
+
87
+ def resolve_fee_payer!
88
+ if @signers.any?
89
+ @fee_payer = Base58.binary_to_base58(@signers.first[:public_key], :bitcoin)
90
+ else
91
+ raise ConfigurationError, "Fee payer must be set or a signer must be added"
92
+ end
93
+ end
94
+
95
+ def ensure_signers!
96
+ if @signers.empty?
97
+ keypair = Solrengine::Programs.configuration.server_keypair
98
+ raise ConfigurationError, "Server keypair not configured. Set SOLANA_KEYPAIR environment variable." unless keypair
99
+ @signers << keypair
100
+ end
101
+ end
102
+
103
+ def build_message
104
+ # Collect all unique account keys, ordered:
105
+ # 1. Fee payer (always first, signer + writable)
106
+ # 2. Other signers (writable first, then read-only)
107
+ # 3. Non-signers (writable first, then read-only)
108
+ account_metas = collect_account_metas
109
+ ordered_keys = order_account_keys(account_metas)
110
+
111
+ # Build key index lookup
112
+ key_index = ordered_keys.each_with_index.to_h { |k, i| [ k[:pubkey], i ] }
113
+
114
+ # Count categories
115
+ num_required_signatures = ordered_keys.count { |k| k[:is_signer] }
116
+ num_readonly_signed = ordered_keys.count { |k| k[:is_signer] && !k[:is_writable] }
117
+ num_readonly_unsigned = ordered_keys.count { |k| !k[:is_signer] && !k[:is_writable] }
118
+
119
+ # Message header (3 bytes)
120
+ message = [ num_required_signatures, num_readonly_signed, num_readonly_unsigned ].pack("CCC")
121
+
122
+ # Account keys (compact-u16 count + 32 bytes each)
123
+ message += BorshTypes.encode_compact_u16(ordered_keys.size)
124
+ ordered_keys.each do |key|
125
+ message += Base58.base58_to_binary(key[:pubkey], :bitcoin)
126
+ end
127
+
128
+ # Recent blockhash (32 bytes)
129
+ message += Base58.base58_to_binary(@recent_blockhash, :bitcoin)
130
+
131
+ # Instructions (compact-u16 count + each instruction)
132
+ message += BorshTypes.encode_compact_u16(@instructions.size)
133
+ @instructions.each do |ix|
134
+ # Program ID index (1 byte)
135
+ prog_index = key_index[ix[:program_id]]
136
+ message += [ prog_index ].pack("C")
137
+
138
+ # Account indices (compact-u16 count + 1 byte each)
139
+ acct_indices = ix[:accounts].map { |a| key_index[a[:pubkey]] }
140
+ message += BorshTypes.encode_compact_u16(acct_indices.size)
141
+ message += acct_indices.pack("C*")
142
+
143
+ # Instruction data (compact-u16 length + data)
144
+ data = ix[:data] || ""
145
+ message += BorshTypes.encode_compact_u16(data.bytesize)
146
+ message += data
147
+ end
148
+
149
+ message
150
+ end
151
+
152
+ def sign_message(message)
153
+ @signers.map do |signer|
154
+ signing_key = Ed25519::SigningKey.new(signer[:secret_key])
155
+ signing_key.sign(message)
156
+ end
157
+ end
158
+
159
+ def collect_account_metas
160
+ metas = {}
161
+
162
+ # Fee payer is always signer + writable
163
+ metas[@fee_payer] = { pubkey: @fee_payer, is_signer: true, is_writable: true }
164
+
165
+ @instructions.each do |ix|
166
+ # Program ID is a non-signer, read-only account
167
+ unless metas.key?(ix[:program_id])
168
+ metas[ix[:program_id]] = { pubkey: ix[:program_id], is_signer: false, is_writable: false }
169
+ end
170
+
171
+ ix[:accounts].each do |acct|
172
+ if metas.key?(acct[:pubkey])
173
+ # Merge: upgrade to signer/writable if needed
174
+ metas[acct[:pubkey]][:is_signer] ||= acct[:is_signer]
175
+ metas[acct[:pubkey]][:is_writable] ||= acct[:is_writable]
176
+ else
177
+ metas[acct[:pubkey]] = acct.dup
178
+ end
179
+ end
180
+ end
181
+
182
+ metas.values
183
+ end
184
+
185
+ def order_account_keys(metas)
186
+ # Sort order:
187
+ # 1. Signer + writable
188
+ # 2. Signer + read-only
189
+ # 3. Non-signer + writable
190
+ # 4. Non-signer + read-only
191
+ # Fee payer is always first
192
+ fee_payer_meta = metas.find { |m| m[:pubkey] == @fee_payer }
193
+ others = metas.reject { |m| m[:pubkey] == @fee_payer }
194
+
195
+ sorted = others.sort_by do |m|
196
+ [
197
+ m[:is_signer] ? 0 : 1,
198
+ m[:is_writable] ? 0 : 1,
199
+ m[:pubkey] # deterministic ordering within category
200
+ ]
201
+ end
202
+
203
+ [ fee_payer_meta ] + sorted
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Solrengine
4
+ module Programs
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ require "solrengine/rpc"
2
+ require "borsh"
3
+
4
+ module Solrengine
5
+ module Programs
6
+ class Error < StandardError; end
7
+ class ConfigurationError < Error; end
8
+ class DeserializationError < Error; end
9
+ class TransactionError < Error; end
10
+ class ProgramError < Error
11
+ attr_reader :code, :error_name
12
+
13
+ def initialize(code:, error_name:, message:)
14
+ @code = code
15
+ @error_name = error_name
16
+ super("#{error_name} (#{code}): #{message}")
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield(configuration)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ require_relative "programs/version"
33
+ require_relative "programs/configuration"
34
+ require_relative "programs/borsh_types"
35
+ require_relative "programs/idl_parser"
36
+ require_relative "programs/pda"
37
+ require_relative "programs/error_mapper"
38
+ require_relative "programs/account"
39
+ require_relative "programs/instruction"
40
+ require_relative "programs/transaction_builder"
41
+ require_relative "programs/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solrengine-programs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jose Ferrer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: solrengine-rpc
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: borsh
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: ed25519
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.3'
69
+ - !ruby/object:Gem::Dependency
70
+ name: base58
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
83
+ description: Parse Anchor IDL files to scaffold Ruby account models, instruction builders,
84
+ and Stimulus controllers for interacting with custom Solana programs.
85
+ email:
86
+ - estoy@moviendo.me
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - README.md
92
+ - lib/generators/solrengine/program/program_generator.rb
93
+ - lib/generators/solrengine/program/templates/account.rb.erb
94
+ - lib/generators/solrengine/program/templates/instruction.rb.erb
95
+ - lib/generators/solrengine/program/templates/stimulus_controller.js.erb
96
+ - lib/solrengine/programs.rb
97
+ - lib/solrengine/programs/account.rb
98
+ - lib/solrengine/programs/borsh_types.rb
99
+ - lib/solrengine/programs/configuration.rb
100
+ - lib/solrengine/programs/engine.rb
101
+ - lib/solrengine/programs/error_mapper.rb
102
+ - lib/solrengine/programs/idl_parser.rb
103
+ - lib/solrengine/programs/instruction.rb
104
+ - lib/solrengine/programs/pda.rb
105
+ - lib/solrengine/programs/transaction_builder.rb
106
+ - lib/solrengine/programs/version.rb
107
+ homepage: https://github.com/solrengine/programs
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ homepage_uri: https://github.com/solrengine/programs
112
+ source_code_uri: https://github.com/solrengine/programs
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 3.2.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.22
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Solana program interaction for Rails via Anchor IDL parsing
132
+ test_files: []