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.
- checksums.yaml +7 -0
- data/README.md +130 -0
- data/lib/generators/solrengine/program/program_generator.rb +101 -0
- data/lib/generators/solrengine/program/templates/account.rb.erb +15 -0
- data/lib/generators/solrengine/program/templates/instruction.rb.erb +12 -0
- data/lib/generators/solrengine/program/templates/stimulus_controller.js.erb +171 -0
- data/lib/solrengine/programs/account.rb +157 -0
- data/lib/solrengine/programs/borsh_types.rb +160 -0
- data/lib/solrengine/programs/configuration.rb +44 -0
- data/lib/solrengine/programs/engine.rb +11 -0
- data/lib/solrengine/programs/error_mapper.rb +50 -0
- data/lib/solrengine/programs/idl_parser.rb +151 -0
- data/lib/solrengine/programs/instruction.rb +132 -0
- data/lib/solrengine/programs/pda.rb +99 -0
- data/lib/solrengine/programs/transaction_builder.rb +207 -0
- data/lib/solrengine/programs/version.rb +7 -0
- data/lib/solrengine/programs.rb +41 -0
- metadata +132 -0
|
@@ -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,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
|