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,160 @@
1
+ require "digest"
2
+ require "base58"
3
+
4
+ module Solrengine
5
+ module Programs
6
+ module BorshTypes
7
+ # Solana PublicKey: 32-byte fixed array, exposed as Base58 string
8
+ module PublicKey
9
+ def self.decode(buffer)
10
+ bytes = buffer.read(32)
11
+ Base58.binary_to_base58(bytes, :bitcoin)
12
+ end
13
+
14
+ def self.encode(buffer, value)
15
+ bytes = Base58.base58_to_binary(value, :bitcoin)
16
+ raise DeserializationError, "PublicKey must be 32 bytes (got #{bytes.bytesize})" unless bytes.bytesize == 32
17
+ buffer.write(bytes)
18
+ end
19
+
20
+ def self.size
21
+ 32
22
+ end
23
+ end
24
+
25
+ # Anchor discriminator: first 8 bytes of SHA256
26
+ module Discriminator
27
+ def self.for_account(name)
28
+ Digest::SHA256.digest("account:#{name}")[0, 8]
29
+ end
30
+
31
+ def self.for_instruction(name)
32
+ Digest::SHA256.digest("global:#{name}")[0, 8]
33
+ end
34
+ end
35
+
36
+ # Maps IDL type strings to borsh gem read/write operations
37
+ TYPE_REGISTRY = {
38
+ "bool" => { read: :read_bool, write: :write_bool, size: 1 },
39
+ "u8" => { read: :read_u8, write: :write_u8, size: 1 },
40
+ "u16" => { read: :read_u16, write: :write_u16, size: 2 },
41
+ "u32" => { read: :read_u32, write: :write_u32, size: 4 },
42
+ "u64" => { read: :read_u64, write: :write_u64, size: 8 },
43
+ "u128" => { read: :read_u128, write: :write_u128, size: 16 },
44
+ "i8" => { read: :read_i8, write: :write_i8, size: 1 },
45
+ "i16" => { read: :read_i16, write: :write_i16, size: 2 },
46
+ "i32" => { read: :read_i32, write: :write_i32, size: 4 },
47
+ "i64" => { read: :read_i64, write: :write_i64, size: 8 },
48
+ "i128" => { read: :read_i128, write: :write_i128, size: 16 },
49
+ "f32" => { read: :read_f32, write: :write_f32, size: 4 },
50
+ "f64" => { read: :read_f64, write: :write_f64, size: 8 },
51
+ "string" => { read: :read_string, write: :write_string, size: nil },
52
+ "pubkey" => { read: :read_pubkey, write: :write_pubkey, size: 32 }
53
+ }.freeze
54
+
55
+ # Read a value from a Borsh::Buffer based on IDL type
56
+ def self.read_field(buffer, type)
57
+ case type
58
+ when String
59
+ if type == "pubkey"
60
+ PublicKey.decode(buffer)
61
+ elsif TYPE_REGISTRY.key?(type)
62
+ buffer.send(TYPE_REGISTRY[type][:read])
63
+ else
64
+ raise DeserializationError, "Unknown type: #{type}"
65
+ end
66
+ when Hash
67
+ read_complex_type(buffer, type)
68
+ else
69
+ raise DeserializationError, "Unsupported type spec: #{type.inspect}"
70
+ end
71
+ end
72
+
73
+ # Write a value to a Borsh::Buffer based on IDL type
74
+ def self.write_field(buffer, type, value)
75
+ case type
76
+ when String
77
+ if type == "pubkey"
78
+ PublicKey.encode(buffer, value)
79
+ elsif TYPE_REGISTRY.key?(type)
80
+ buffer.send(TYPE_REGISTRY[type][:write], value)
81
+ else
82
+ raise DeserializationError, "Unknown type: #{type}"
83
+ end
84
+ when Hash
85
+ write_complex_type(buffer, type, value)
86
+ else
87
+ raise DeserializationError, "Unsupported type spec: #{type.inspect}"
88
+ end
89
+ end
90
+
91
+ # Calculate the fixed byte size for a type (nil if variable-length)
92
+ def self.field_size(type)
93
+ case type
94
+ when String
95
+ TYPE_REGISTRY.dig(type, :size)
96
+ when Hash
97
+ nil # complex types are variable-length
98
+ else
99
+ nil
100
+ end
101
+ end
102
+
103
+ def self.read_complex_type(buffer, type_hash)
104
+ if type_hash.key?("vec")
105
+ element_type = type_hash["vec"]
106
+ count = buffer.read_u32
107
+ Array.new(count) { read_field(buffer, element_type) }
108
+ elsif type_hash.key?("option")
109
+ inner_type = type_hash["option"]
110
+ present = buffer.read_bool
111
+ present ? read_field(buffer, inner_type) : nil
112
+ elsif type_hash.key?("array")
113
+ element_type, count = type_hash["array"]
114
+ Array.new(count) { read_field(buffer, element_type) }
115
+ elsif type_hash.key?("defined")
116
+ raise DeserializationError, "Custom defined types must be resolved before decoding: #{type_hash["defined"]}"
117
+ else
118
+ raise DeserializationError, "Unknown complex type: #{type_hash.inspect}"
119
+ end
120
+ end
121
+ private_class_method :read_complex_type
122
+
123
+ def self.write_complex_type(buffer, type_hash, value)
124
+ if type_hash.key?("vec")
125
+ element_type = type_hash["vec"]
126
+ buffer.write_u32(value.size)
127
+ value.each { |v| write_field(buffer, element_type, v) }
128
+ elsif type_hash.key?("option")
129
+ inner_type = type_hash["option"]
130
+ if value.nil?
131
+ buffer.write_bool(false)
132
+ else
133
+ buffer.write_bool(true)
134
+ write_field(buffer, inner_type, value)
135
+ end
136
+ elsif type_hash.key?("array")
137
+ element_type, _count = type_hash["array"]
138
+ value.each { |v| write_field(buffer, element_type, v) }
139
+ else
140
+ raise DeserializationError, "Unknown complex type: #{type_hash.inspect}"
141
+ end
142
+ end
143
+ private_class_method :write_complex_type
144
+
145
+ # Encode compact-u16 (Solana's variable-length encoding for array lengths in transactions)
146
+ def self.encode_compact_u16(value)
147
+ bytes = []
148
+ val = value
149
+ loop do
150
+ byte = val & 0x7F
151
+ val >>= 7
152
+ byte |= 0x80 if val > 0
153
+ bytes << byte
154
+ break if val == 0
155
+ end
156
+ bytes.pack("C*")
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,44 @@
1
+ require "base58"
2
+ require "json"
3
+
4
+ module Solrengine
5
+ module Programs
6
+ class Configuration
7
+ attr_accessor :keypair_format
8
+
9
+ def initialize
10
+ @keypair_format = :base58
11
+ end
12
+
13
+ def server_keypair
14
+ @server_keypair ||= load_keypair
15
+ end
16
+
17
+ def server_keypair?
18
+ !ENV["SOLANA_KEYPAIR"].nil? && !ENV["SOLANA_KEYPAIR"].empty?
19
+ end
20
+
21
+ private
22
+
23
+ def load_keypair
24
+ raw = ENV["SOLANA_KEYPAIR"]
25
+ return nil if raw.nil? || raw.empty?
26
+
27
+ bytes = case keypair_format
28
+ when :base58
29
+ Base58.base58_to_binary(raw, :bitcoin)
30
+ when :json_array
31
+ JSON.parse(raw).pack("C*")
32
+ else
33
+ raise ConfigurationError, "Unsupported keypair format: #{keypair_format}"
34
+ end
35
+
36
+ unless bytes.bytesize == 64
37
+ raise ConfigurationError, "Keypair must be 64 bytes (got #{bytes.bytesize})"
38
+ end
39
+
40
+ { secret_key: bytes[0, 32], public_key: bytes[32, 32] }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,11 @@
1
+ module Solrengine
2
+ module Programs
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Solrengine::Programs
5
+
6
+ initializer "solrengine-programs.assets" do |app|
7
+ app.config.assets.paths << root.join("app/assets/javascripts")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ module Solrengine
2
+ module Programs
3
+ class ErrorMapper
4
+ ANCHOR_ERROR_OFFSET = 6000
5
+
6
+ def initialize(parsed_errors)
7
+ @errors_by_code = parsed_errors.each_with_object({}) do |err, h|
8
+ h[err.code] = err
9
+ end
10
+ end
11
+
12
+ def map(error_code)
13
+ err = @errors_by_code[error_code]
14
+ return nil unless err
15
+
16
+ { code: err.code, name: err.name, message: err.message }
17
+ end
18
+
19
+ # Extract custom error code from an RPC InstructionError response
20
+ # Example: {"InstructionError":[0,{"Custom":6001}]}
21
+ def self.extract_custom_error(rpc_error)
22
+ return nil unless rpc_error.is_a?(Hash)
23
+
24
+ instruction_error = rpc_error["InstructionError"]
25
+ return nil unless instruction_error.is_a?(Array) && instruction_error.size == 2
26
+
27
+ custom = instruction_error[1]
28
+ return nil unless custom.is_a?(Hash) && custom.key?("Custom")
29
+
30
+ custom["Custom"]
31
+ end
32
+
33
+ def raise_if_program_error!(rpc_error)
34
+ code = self.class.extract_custom_error(rpc_error)
35
+ return unless code
36
+
37
+ mapped = map(code)
38
+ if mapped
39
+ raise ProgramError.new(
40
+ code: mapped[:code],
41
+ error_name: mapped[:name],
42
+ message: mapped[:message]
43
+ )
44
+ else
45
+ raise TransactionError, "Program error with unknown code: #{code}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,151 @@
1
+ require "json"
2
+
3
+ module Solrengine
4
+ module Programs
5
+ class IdlParser
6
+ class UnsupportedVersionError < Error; end
7
+
8
+ SUPPORTED_SPEC = "0.1.0"
9
+
10
+ ParsedIdl = Struct.new(
11
+ :program_id, :name, :version, :instructions, :accounts, :types, :errors,
12
+ keyword_init: true
13
+ )
14
+
15
+ ParsedInstruction = Struct.new(
16
+ :name, :discriminator, :accounts, :args,
17
+ keyword_init: true
18
+ )
19
+
20
+ ParsedAccount = Struct.new(
21
+ :name, :discriminator, :fields,
22
+ keyword_init: true
23
+ )
24
+
25
+ ParsedType = Struct.new(
26
+ :name, :kind, :fields,
27
+ keyword_init: true
28
+ )
29
+
30
+ ParsedField = Struct.new(
31
+ :name, :type,
32
+ keyword_init: true
33
+ )
34
+
35
+ ParsedAccountMeta = Struct.new(
36
+ :name, :writable, :signer, :address, :relations,
37
+ keyword_init: true
38
+ )
39
+
40
+ ParsedError = Struct.new(
41
+ :code, :name, :message,
42
+ keyword_init: true
43
+ )
44
+
45
+ def self.parse(json_string)
46
+ new(json_string).parse
47
+ end
48
+
49
+ def self.parse_file(path)
50
+ parse(File.read(path))
51
+ end
52
+
53
+ def initialize(json_string)
54
+ @data = JSON.parse(json_string)
55
+ end
56
+
57
+ def parse
58
+ validate_spec_version!
59
+
60
+ ParsedIdl.new(
61
+ program_id: @data["address"],
62
+ name: @data.dig("metadata", "name"),
63
+ version: @data.dig("metadata", "version"),
64
+ instructions: parse_instructions,
65
+ accounts: parse_accounts,
66
+ types: parse_types,
67
+ errors: parse_errors
68
+ )
69
+ end
70
+
71
+ private
72
+
73
+ def validate_spec_version!
74
+ spec = @data.dig("metadata", "spec")
75
+ unless spec == SUPPORTED_SPEC
76
+ raise UnsupportedVersionError,
77
+ "Unsupported Anchor IDL spec version '#{spec}'. Expected '#{SUPPORTED_SPEC}'."
78
+ end
79
+ end
80
+
81
+ def parse_instructions
82
+ (@data["instructions"] || []).map do |ix|
83
+ ParsedInstruction.new(
84
+ name: ix["name"],
85
+ discriminator: ix["discriminator"]&.pack("C*"),
86
+ accounts: parse_account_metas(ix["accounts"] || []),
87
+ args: parse_fields(ix["args"] || [])
88
+ )
89
+ end
90
+ end
91
+
92
+ def parse_accounts
93
+ types_by_name = (@data["types"] || []).each_with_object({}) do |t, h|
94
+ h[t["name"]] = t
95
+ end
96
+
97
+ (@data["accounts"] || []).map do |acct|
98
+ type_def = types_by_name[acct["name"]]
99
+ fields = type_def ? parse_fields(type_def.dig("type", "fields") || []) : []
100
+
101
+ ParsedAccount.new(
102
+ name: acct["name"],
103
+ discriminator: acct["discriminator"]&.pack("C*"),
104
+ fields: fields
105
+ )
106
+ end
107
+ end
108
+
109
+ def parse_types
110
+ (@data["types"] || []).map do |t|
111
+ ParsedType.new(
112
+ name: t["name"],
113
+ kind: t.dig("type", "kind"),
114
+ fields: parse_fields(t.dig("type", "fields") || [])
115
+ )
116
+ end
117
+ end
118
+
119
+ def parse_errors
120
+ (@data["errors"] || []).map do |err|
121
+ ParsedError.new(
122
+ code: err["code"],
123
+ name: err["name"],
124
+ message: err["msg"]
125
+ )
126
+ end
127
+ end
128
+
129
+ def parse_fields(fields)
130
+ fields.map do |f|
131
+ ParsedField.new(
132
+ name: f["name"],
133
+ type: f["type"]
134
+ )
135
+ end
136
+ end
137
+
138
+ def parse_account_metas(accounts)
139
+ accounts.map do |acct|
140
+ ParsedAccountMeta.new(
141
+ name: acct["name"],
142
+ writable: acct["writable"] || false,
143
+ signer: acct["signer"] || false,
144
+ address: acct["address"],
145
+ relations: acct["relations"]
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,132 @@
1
+ module Solrengine
2
+ module Programs
3
+ class Instruction
4
+ attr_reader :errors
5
+
6
+ class << self
7
+ def program_id(id = nil)
8
+ if id
9
+ @program_id = id
10
+ else
11
+ @program_id
12
+ end
13
+ end
14
+
15
+ def argument(name, type)
16
+ arguments_list << { name: name.to_sym, type: type }
17
+ attr_accessor name
18
+ end
19
+
20
+ def account(name, signer: false, writable: false, address: nil)
21
+ accounts_list << {
22
+ name: name.to_sym,
23
+ signer: signer,
24
+ writable: writable,
25
+ address: address
26
+ }
27
+ attr_accessor name unless address
28
+ end
29
+
30
+ def arguments_list
31
+ @arguments_list ||= []
32
+ end
33
+
34
+ def accounts_list
35
+ @accounts_list ||= []
36
+ end
37
+
38
+ def discriminator
39
+ BorshTypes::Discriminator.for_instruction(anchor_instruction_name)
40
+ end
41
+
42
+ # Set the Anchor instruction name used for discriminator computation.
43
+ # If not set, derives from class name: "LockInstruction" -> "lock"
44
+ def instruction_name(name_override = nil)
45
+ if name_override
46
+ @instruction_name = name_override
47
+ else
48
+ @instruction_name
49
+ end
50
+ end
51
+
52
+ def anchor_instruction_name
53
+ @instruction_name || begin
54
+ short_name = name&.split("::")&.last || ""
55
+ short_name.sub(/Instruction$/, "").gsub(/([a-z])([A-Z])/, '\1_\2').downcase
56
+ end
57
+ end
58
+ end
59
+
60
+ def initialize(attributes = {})
61
+ @errors = []
62
+ attributes.each do |key, value|
63
+ send(:"#{key}=", value) if respond_to?(:"#{key}=")
64
+ end
65
+ end
66
+
67
+ def valid?
68
+ @errors = []
69
+ validate_arguments
70
+ validate_accounts
71
+ @errors.empty?
72
+ end
73
+
74
+ # Build the instruction data: 8-byte discriminator + Borsh-encoded arguments
75
+ def instruction_data
76
+ data = Borsh::Buffer.open do |buf|
77
+ # Write discriminator
78
+ buf.write(self.class.discriminator)
79
+
80
+ # Write each argument
81
+ self.class.arguments_list.each do |arg|
82
+ value = send(arg[:name])
83
+ BorshTypes.write_field(buf, arg[:type], value)
84
+ end
85
+ end
86
+
87
+ data
88
+ end
89
+
90
+ # Build the instruction hash suitable for TransactionBuilder
91
+ def to_instruction
92
+ {
93
+ program_id: self.class.program_id,
94
+ accounts: build_account_metas,
95
+ data: instruction_data
96
+ }
97
+ end
98
+
99
+ private
100
+
101
+ def validate_arguments
102
+ self.class.arguments_list.each do |arg|
103
+ value = send(arg[:name])
104
+ if value.nil?
105
+ @errors << "#{arg[:name]} is required"
106
+ end
107
+ end
108
+ end
109
+
110
+ def validate_accounts
111
+ self.class.accounts_list.each do |acct|
112
+ next if acct[:address] # static addresses don't need to be set
113
+ value = send(acct[:name])
114
+ if value.nil?
115
+ @errors << "Account #{acct[:name]} is required"
116
+ end
117
+ end
118
+ end
119
+
120
+ def build_account_metas
121
+ self.class.accounts_list.map do |acct|
122
+ pubkey = acct[:address] || send(acct[:name])
123
+ {
124
+ pubkey: pubkey,
125
+ is_signer: acct[:signer],
126
+ is_writable: acct[:writable]
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,99 @@
1
+ require "digest"
2
+ require "base58"
3
+
4
+ module Solrengine
5
+ module Programs
6
+ module Pda
7
+ # Ed25519 curve order
8
+ ED25519_ORDER = 2**252 + 27742317777372353535851937790883648493
9
+
10
+ # Find a valid Program Derived Address by iterating bump seeds from 255 down to 0.
11
+ # Returns [address_base58, bump].
12
+ def self.find_program_address(seeds, program_id)
13
+ 255.downto(0) do |bump|
14
+ address = create_program_address(seeds + [ bump.chr ], program_id)
15
+ return [ address, bump ] if address
16
+ end
17
+
18
+ raise Error, "Could not find a valid PDA for the given seeds"
19
+ end
20
+
21
+ # Create a program address from seeds. Returns nil if the address is on the Ed25519 curve.
22
+ def self.create_program_address(seeds, program_id)
23
+ program_id_bytes = Base58.base58_to_binary(program_id, :bitcoin)
24
+
25
+ hash_input = seeds.map { |s| seed_bytes(s) }.join
26
+ hash_input += program_id_bytes
27
+ hash_input += "ProgramDerivedAddress"
28
+
29
+ hash = Digest::SHA256.digest(hash_input)
30
+
31
+ # Reject if the hash is a valid Ed25519 point (on-curve)
32
+ return nil if on_curve?(hash)
33
+
34
+ Base58.binary_to_base58(hash, :bitcoin)
35
+ end
36
+
37
+ # Convert a value to seed bytes based on type
38
+ def self.to_seed(value, type = :raw)
39
+ case type
40
+ when :string
41
+ value.encode("UTF-8").b
42
+ when :pubkey
43
+ Base58.base58_to_binary(value, :bitcoin)
44
+ when :u8
45
+ [ value ].pack("C")
46
+ when :u16
47
+ [ value ].pack("v")
48
+ when :u32
49
+ [ value ].pack("V")
50
+ when :u64
51
+ [ value ].pack("Q<")
52
+ when :raw
53
+ value.is_a?(String) ? value.b : value
54
+ else
55
+ raise Error, "Unknown seed type: #{type}"
56
+ end
57
+ end
58
+
59
+ # Check if a 32-byte hash is on the Ed25519 curve.
60
+ # Uses a simplified check: try to decompress the y-coordinate.
61
+ def self.on_curve?(bytes)
62
+ # Interpret as little-endian integer (Ed25519 uses little-endian)
63
+ y = bytes.unpack("C*").each_with_index.sum { |byte, i| byte << (8 * i) }
64
+ y_squared = y * y
65
+ p = 2**255 - 19
66
+
67
+ # x² = (y² - 1) / (d·y² + 1) mod p
68
+ # d = -121665/121666 mod p
69
+ d = (-121665 * mod_inverse(121666, p)) % p
70
+ numerator = (y_squared - 1) % p
71
+ denominator = (d * y_squared + 1) % p
72
+
73
+ # x² = numerator * denominator^(-1) mod p
74
+ x_squared = (numerator * mod_inverse(denominator, p)) % p
75
+
76
+ # Check if x² has a square root mod p
77
+ # Using Euler's criterion: x^((p-1)/2) ≡ 1 (mod p) means it's a QR
78
+ x_squared.pow((p - 1) / 2, p) == 1
79
+ end
80
+ private_class_method :on_curve?
81
+
82
+ def self.mod_inverse(a, m)
83
+ a.pow(m - 2, m)
84
+ end
85
+ private_class_method :mod_inverse
86
+
87
+ def self.seed_bytes(seed)
88
+ if seed.is_a?(String)
89
+ seed.b
90
+ elsif seed.is_a?(Array)
91
+ seed.pack("C*")
92
+ else
93
+ seed.to_s.b
94
+ end
95
+ end
96
+ private_class_method :seed_bytes
97
+ end
98
+ end
99
+ end