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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 241673d21a1e7e5e8df9b93bf9d8ecdefbc7a0721833d5a6e6f0b9b02923a846
4
+ data.tar.gz: 84dd1fdbf4caba59e9b044b5754af24a035eb71ea5601616248a59f2d5a1eccd
5
+ SHA512:
6
+ metadata.gz: facd125073ab5a9cfe9b267a9e7457367965263e67e4a6ae1ea1d18226b00e6f9119afa3147cc9544e63740589358673a2e4c01f78350d0b44a3b98d0c81b4aa
7
+ data.tar.gz: 62f4f89cf129ca4dfee131f6b4694dfc70fea9e99c3c215cfb6394287ad367c31cfed7fc8903d1c2d458cd84a25cdcd7b36f32673c8d7b07234536c009ab97b1
data/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # SolRengine Programs
2
+
3
+ Solana program interaction for Rails. Parse Anchor IDL files to generate Ruby account models, instruction builders, and Stimulus controllers.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "solrengine-programs"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Generate from Anchor IDL
16
+
17
+ ```bash
18
+ rails generate solrengine:program PiggyBank path/to/piggy_bank.json
19
+ ```
20
+
21
+ This creates:
22
+ - `app/models/piggy_bank/lock.rb` — Account model with Borsh decoding
23
+ - `app/services/piggy_bank/lock_instruction.rb` — Instruction builder
24
+ - `app/services/piggy_bank/unlock_instruction.rb` — Instruction builder
25
+ - `app/javascript/controllers/piggy_bank_controller.js` — Stimulus controller
26
+ - `config/idl/piggy_bank.json` — IDL copy
27
+
28
+ ### Query Program Accounts
29
+
30
+ ```ruby
31
+ class PiggyBank::Lock < Solrengine::Programs::Account
32
+ program_id "ZaU8j7XCKSxmmkMvg7NnjrLNK6eiLZbHsJQAc2rFzEN"
33
+ account_name "Lock"
34
+
35
+ borsh_field :dst, "pubkey"
36
+ borsh_field :exp, "u64"
37
+
38
+ def self.for_wallet(wallet_address)
39
+ query(filters: [
40
+ { "memcmp" => { "offset" => 8, "bytes" => wallet_address } }
41
+ ])
42
+ end
43
+
44
+ def expired?
45
+ exp < Time.now.to_i
46
+ end
47
+ end
48
+
49
+ # Query accounts
50
+ locks = PiggyBank::Lock.for_wallet("YourWalletAddress...")
51
+ locks.each do |lock|
52
+ puts "#{lock.pubkey}: #{lock.sol_balance} SOL, expires #{Time.at(lock.exp)}"
53
+ end
54
+ ```
55
+
56
+ ### Build Instructions (Server-Side)
57
+
58
+ ```ruby
59
+ class PiggyBank::LockInstruction < Solrengine::Programs::Instruction
60
+ program_id "ZaU8j7XCKSxmmkMvg7NnjrLNK6eiLZbHsJQAc2rFzEN"
61
+ instruction_name "lock"
62
+
63
+ argument :amt, "u64"
64
+ argument :exp, "u64"
65
+
66
+ account :payer, signer: true, writable: true
67
+ account :dst
68
+ account :lock, signer: true, writable: true
69
+ account :system_program, address: "11111111111111111111111111111111"
70
+ end
71
+
72
+ # Build and send a transaction
73
+ ix = PiggyBank::LockInstruction.new(
74
+ amt: 100_000_000,
75
+ exp: (Time.now + 5.minutes).to_i,
76
+ payer: payer_pubkey,
77
+ dst: destination_pubkey,
78
+ lock: lock_keypair_pubkey
79
+ )
80
+
81
+ builder = Solrengine::Programs::TransactionBuilder.new
82
+ builder.add_instruction(ix)
83
+ builder.add_signer(server_keypair)
84
+ signature = builder.sign_and_send
85
+ ```
86
+
87
+ ### PDA Derivation
88
+
89
+ ```ruby
90
+ address, bump = Solrengine::Programs::Pda.find_program_address(
91
+ ["vault", Solrengine::Programs::Pda.to_seed(user_pubkey, :pubkey)],
92
+ program_id
93
+ )
94
+ ```
95
+
96
+ ### Error Mapping
97
+
98
+ ```ruby
99
+ idl = Solrengine::Programs::IdlParser.parse_file("config/idl/piggy_bank.json")
100
+ mapper = Solrengine::Programs::ErrorMapper.new(idl.errors)
101
+
102
+ begin
103
+ builder.sign_and_send
104
+ rescue Solrengine::Programs::TransactionError => e
105
+ mapper.raise_if_program_error!(e.rpc_error)
106
+ # Raises: ProgramError "LockNotExpired (6002): Lock has not expired yet"
107
+ end
108
+ ```
109
+
110
+ ### Configuration
111
+
112
+ ```ruby
113
+ # config/initializers/solrengine_programs.rb
114
+ Solrengine::Programs.configure do |config|
115
+ config.keypair_format = :base58 # or :json_array
116
+ end
117
+ ```
118
+
119
+ Set `SOLANA_KEYPAIR` environment variable for server-side transaction signing.
120
+
121
+ ## Dependencies
122
+
123
+ - [solrengine-rpc](https://github.com/solrengine/rpc) — Solana RPC client
124
+ - [borsh](https://github.com/dryruby/borsh.rb) — Borsh binary serialization
125
+ - [ed25519](https://github.com/RubyCrypto/ed25519) — Transaction signing
126
+ - [base58](https://github.com/dougal/base58) — Address encoding
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,101 @@
1
+ require "rails/generators"
2
+
3
+ module Solrengine
4
+ module Generators
5
+ class ProgramGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ argument :program_name, type: :string, desc: "Program name (e.g., PiggyBank)"
9
+ argument :idl_path, type: :string, desc: "Path to Anchor IDL JSON file"
10
+
11
+ def parse_idl
12
+ unless File.exist?(idl_path)
13
+ raise Thor::Error, "IDL file not found: #{idl_path}"
14
+ end
15
+
16
+ @idl = Solrengine::Programs::IdlParser.parse_file(idl_path)
17
+ say "Parsed IDL: #{@idl.name} (#{@idl.instructions.size} instructions, #{@idl.accounts.size} accounts)"
18
+ end
19
+
20
+ def copy_idl
21
+ destination = "config/idl/#{program_snake}.json"
22
+ if File.exist?(File.join(destination_root, destination))
23
+ say_skipped(destination)
24
+ else
25
+ copy_file idl_path, destination
26
+ end
27
+ end
28
+
29
+ def create_account_models
30
+ @idl.accounts.each do |account|
31
+ destination = "app/models/#{program_snake}/#{account.name.underscore}.rb"
32
+ if File.exist?(File.join(destination_root, destination))
33
+ say_skipped(destination)
34
+ else
35
+ @account = account
36
+ @program_id = @idl.program_id
37
+ template "account.rb.erb", destination
38
+ end
39
+ end
40
+ end
41
+
42
+ def create_instruction_builders
43
+ @idl.instructions.each do |instruction|
44
+ destination = "app/services/#{program_snake}/#{instruction.name}_instruction.rb"
45
+ if File.exist?(File.join(destination_root, destination))
46
+ say_skipped(destination)
47
+ else
48
+ @instruction = instruction
49
+ @program_id = @idl.program_id
50
+ template "instruction.rb.erb", destination
51
+ end
52
+ end
53
+ end
54
+
55
+ def create_stimulus_controller
56
+ destination = "app/javascript/controllers/#{program_snake}_controller.js"
57
+ if File.exist?(File.join(destination_root, destination))
58
+ say_skipped(destination)
59
+ else
60
+ @idl_instance = @idl
61
+ template "stimulus_controller.js.erb", destination
62
+ end
63
+ end
64
+
65
+ def show_summary
66
+ say ""
67
+ say "Program '#{program_class}' generated successfully!", :green
68
+ say ""
69
+ say "Generated files:"
70
+ say " config/idl/#{program_snake}.json"
71
+ @idl.accounts.each do |acct|
72
+ say " app/models/#{program_snake}/#{acct.name.underscore}.rb"
73
+ end
74
+ @idl.instructions.each do |ix|
75
+ say " app/services/#{program_snake}/#{ix.name}_instruction.rb"
76
+ end
77
+ say " app/javascript/controllers/#{program_snake}_controller.js"
78
+ say ""
79
+ say "Next steps:"
80
+ say " 1. Add custom query methods to your account models"
81
+ say " 2. Register the Stimulus controller in your index.js"
82
+ say " 3. Create Rails controller endpoints for instruction building"
83
+ end
84
+
85
+ private
86
+
87
+ def program_class
88
+ program_name.camelize
89
+ end
90
+
91
+ def program_snake
92
+ program_name.underscore
93
+ end
94
+
95
+ def say_skipped(path)
96
+ say " skip #{path} (already exists)", :yellow
97
+ say " Re-run with different name or manually update the file"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,15 @@
1
+ class <%= program_class %>::<%= @account.name %> < Solrengine::Programs::Account
2
+ program_id "<%= @program_id %>"
3
+ account_name "<%= @account.name %>"
4
+
5
+ <% @account.fields.each do |field| -%>
6
+ borsh_field :<%= field.name %>, <%= field.type.is_a?(String) ? "\"#{field.type}\"" : field.type.inspect %>
7
+ <% end -%>
8
+
9
+ # Add custom query methods, e.g.:
10
+ # def self.for_wallet(wallet_address)
11
+ # query(filters: [
12
+ # { "memcmp" => { "offset" => 8, "bytes" => wallet_address } }
13
+ # ])
14
+ # end
15
+ end
@@ -0,0 +1,12 @@
1
+ class <%= program_class %>::<%= @instruction.name.camelize %>Instruction < Solrengine::Programs::Instruction
2
+ program_id "<%= @program_id %>"
3
+ instruction_name "<%= @instruction.name %>"
4
+
5
+ <% @instruction.args.each do |arg| -%>
6
+ argument :<%= arg.name %>, <%= arg.type.is_a?(String) ? "\"#{arg.type}\"" : arg.type.inspect %>
7
+ <% end -%>
8
+
9
+ <% @instruction.accounts.each do |acct| -%>
10
+ account :<%= acct.name %><%= ", signer: true" if acct.signer %><%= ", writable: true" if acct.writable %><%= ", address: \"#{acct.address}\"" if acct.address %>
11
+ <% end -%>
12
+ end
@@ -0,0 +1,171 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { getWallets } from "@wallet-standard/app"
3
+ import { SolanaSignAndSendTransaction } from "@solana/wallet-standard-features"
4
+ import {
5
+ pipe,
6
+ createTransactionMessage,
7
+ setTransactionMessageLifetimeUsingBlockhash,
8
+ setTransactionMessageFeePayer,
9
+ appendTransactionMessageInstruction,
10
+ compileTransaction,
11
+ getBase64EncodedWireTransaction,
12
+ address
13
+ } from "@solana/kit"
14
+
15
+ // Generated Stimulus controller for <%= @idl_instance.name %> program
16
+ // Program ID: <%= @idl_instance.program_id %>
17
+ //
18
+ // This controller follows the server-built instruction pattern:
19
+ // 1. User fills form → controller sends params to Rails endpoint
20
+ // 2. Rails builds Borsh-encoded instruction data + fetches blockhash
21
+ // 3. Returns instruction data + account metas + blockhash as JSON
22
+ // 4. Controller constructs transaction, wallet signs and sends
23
+ // 5. Reports signature back to server
24
+ export default class extends Controller {
25
+ static targets = ["status"]
26
+ static values = {
27
+ programId: { type: String, default: "<%= @idl_instance.program_id %>" },
28
+ chain: { type: String, default: "solana:devnet" }
29
+ }
30
+
31
+ connect() {
32
+ this.wallet = null
33
+ this.discoverWallet()
34
+ }
35
+
36
+ discoverWallet() {
37
+ const { get, on } = getWallets()
38
+ const wallets = get()
39
+
40
+ for (const wallet of wallets) {
41
+ if (wallet.features[SolanaSignAndSendTransaction]) {
42
+ this.wallet = wallet
43
+ return
44
+ }
45
+ }
46
+
47
+ on("register", (...newWallets) => {
48
+ for (const wallet of newWallets) {
49
+ if (wallet.features[SolanaSignAndSendTransaction]) {
50
+ this.wallet = wallet
51
+ return
52
+ }
53
+ }
54
+ })
55
+ }
56
+
57
+ <% @idl_instance.instructions.each do |ix| -%>
58
+ // Submit '<%= ix.name %>' instruction
59
+ async <%= ix.name.camelize(:lower) %>(event) {
60
+ event?.preventDefault()
61
+ if (!this.wallet) {
62
+ this.showStatus("No wallet found", "error")
63
+ return
64
+ }
65
+
66
+ try {
67
+ this.showStatus("Building transaction...", "pending")
68
+
69
+ // Get instruction data from server
70
+ const response = await this.buildInstruction("<%= ix.name %>", event?.target)
71
+ if (!response.ok) {
72
+ const error = await response.json()
73
+ this.showStatus(error.message || "Failed to build instruction", "error")
74
+ return
75
+ }
76
+
77
+ const { instruction_data, accounts, blockhash, last_valid_block_height } = await response.json()
78
+
79
+ // Build and sign transaction via wallet
80
+ const signature = await this.signAndSend({
81
+ instructionData: instruction_data,
82
+ accounts,
83
+ blockhash,
84
+ lastValidBlockHeight: last_valid_block_height
85
+ })
86
+
87
+ this.showStatus(`Transaction sent: ${signature.slice(0, 8)}...`, "success")
88
+
89
+ // Report signature to server for confirmation tracking
90
+ this.reportSignature("<%= ix.name %>", signature)
91
+ } catch (error) {
92
+ this.showStatus(error.message, "error")
93
+ }
94
+ }
95
+
96
+ <% end -%>
97
+ async buildInstruction(name, form) {
98
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
99
+ const formData = form ? new FormData(form) : new FormData()
100
+ formData.append("instruction", name)
101
+
102
+ return fetch(`/<%= @idl_instance.name %>/instructions`, {
103
+ method: "POST",
104
+ headers: { "X-CSRF-Token": csrfToken },
105
+ body: formData
106
+ })
107
+ }
108
+
109
+ async signAndSend({ instructionData, accounts, blockhash, lastValidBlockHeight }) {
110
+ const walletAccount = this.wallet.accounts.find(a =>
111
+ a.chains.includes(this.chainValue)
112
+ )
113
+ if (!walletAccount) throw new Error("No account for chain: " + this.chainValue)
114
+
115
+ const feature = this.wallet.features[SolanaSignAndSendTransaction]
116
+ const instructionBytes = Uint8Array.from(atob(instructionData), c => c.charCodeAt(0))
117
+
118
+ const instruction = {
119
+ programAddress: address(this.programIdValue),
120
+ accounts: accounts.map(a => ({
121
+ address: address(a.pubkey),
122
+ role: this.accountRole(a)
123
+ })),
124
+ data: instructionBytes
125
+ }
126
+
127
+ const txMessage = pipe(
128
+ createTransactionMessage({ version: "legacy" }),
129
+ m => setTransactionMessageFeePayer(address(walletAccount.address), m),
130
+ m => setTransactionMessageLifetimeUsingBlockhash(
131
+ { blockhash: blockhash, lastValidBlockHeight: BigInt(lastValidBlockHeight) },
132
+ m
133
+ ),
134
+ m => appendTransactionMessageInstruction(instruction, m)
135
+ )
136
+
137
+ const compiled = compileTransaction(txMessage)
138
+ const wireTransaction = getBase64EncodedWireTransaction(compiled)
139
+
140
+ const [{ signature }] = await feature.signAndSendTransaction(walletAccount, [
141
+ { transaction: wireTransaction, chain: this.chainValue }
142
+ ])
143
+
144
+ return typeof signature === "string" ? signature : new TextDecoder().decode(signature)
145
+ }
146
+
147
+ reportSignature(instructionName, signature) {
148
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
149
+ fetch(`/<%= @idl_instance.name %>/signatures`, {
150
+ method: "POST",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ "X-CSRF-Token": csrfToken
154
+ },
155
+ body: JSON.stringify({ instruction: instructionName, signature })
156
+ }).catch(() => {}) // Best effort
157
+ }
158
+
159
+ accountRole(account) {
160
+ if (account.is_signer && account.is_writable) return 0x03 // WRITABLE_SIGNER
161
+ if (account.is_signer) return 0x02 // READONLY_SIGNER
162
+ if (account.is_writable) return 0x01 // WRITABLE
163
+ return 0x00 // READONLY
164
+ }
165
+
166
+ showStatus(message, type) {
167
+ if (!this.hasStatusTarget) return
168
+ this.statusTarget.textContent = message
169
+ this.statusTarget.className = `status-${type}`
170
+ }
171
+ }
@@ -0,0 +1,157 @@
1
+ require "base64"
2
+
3
+ module Solrengine
4
+ module Programs
5
+ class Account
6
+ include ActiveModel::Model if defined?(ActiveModel)
7
+
8
+ attr_reader :pubkey, :lamports
9
+
10
+ class << self
11
+ def program_id(id = nil)
12
+ if id
13
+ @program_id = id
14
+ else
15
+ @program_id
16
+ end
17
+ end
18
+
19
+ # Set the Anchor account name used for discriminator computation.
20
+ # If not set, defaults to the unqualified Ruby class name.
21
+ def account_name(name = nil)
22
+ if name
23
+ @account_name = name
24
+ else
25
+ @account_name || self.name&.split("::")&.last
26
+ end
27
+ end
28
+
29
+ def borsh_field(name, type, **options)
30
+ borsh_fields << { name: name.to_sym, type: type, **options }
31
+
32
+ # Define attribute accessor
33
+ attr_accessor name
34
+ end
35
+
36
+ def borsh_fields
37
+ @borsh_fields ||= []
38
+ end
39
+
40
+ def discriminator
41
+ BorshTypes::Discriminator.for_account(account_name)
42
+ end
43
+
44
+ # Query program accounts via RPC with memcmp filters.
45
+ # At least one user-provided filter is required to prevent unbounded queries.
46
+ def query(filters: [], commitment: "confirmed")
47
+ if filters.empty?
48
+ raise Error, "At least one memcmp filter is required to prevent unbounded getProgramAccounts queries"
49
+ end
50
+
51
+ # Build full filter list: dataSize + discriminator + user filters
52
+ all_filters = build_filters(filters)
53
+
54
+ result = Solrengine::Rpc.client.request("getProgramAccounts", [
55
+ program_id,
56
+ {
57
+ "encoding" => "base64",
58
+ "commitment" => commitment,
59
+ "filters" => all_filters
60
+ }
61
+ ])
62
+
63
+ accounts = result.dig("result") || []
64
+
65
+ accounts.filter_map do |account_data|
66
+ pubkey = account_data["pubkey"]
67
+ data_base64 = account_data.dig("account", "data", 0)
68
+ lamports = account_data.dig("account", "lamports")
69
+
70
+ next unless data_base64
71
+
72
+ begin
73
+ from_account_data(pubkey, data_base64, lamports: lamports)
74
+ rescue DeserializationError, ArgumentError, RangeError => e
75
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
76
+ Rails.logger.warn("Skipping malformed account #{pubkey}: #{e.message}")
77
+ end
78
+ nil
79
+ end
80
+ end
81
+ end
82
+
83
+ # Decode a single account from base64 data
84
+ def from_account_data(pubkey, data_base64, lamports: 0)
85
+ raw = Base64.decode64(data_base64)
86
+
87
+ # Skip empty/closed accounts
88
+ if raw.empty? || raw.bytesize < 8
89
+ raise DeserializationError, "Account data too short (#{raw.bytesize} bytes)"
90
+ end
91
+
92
+ # Skip 8-byte discriminator
93
+ account_data = raw[8..]
94
+ buffer = Borsh::Buffer.new(account_data)
95
+
96
+ instance = new
97
+ instance.instance_variable_set(:@pubkey, pubkey)
98
+ instance.instance_variable_set(:@lamports, lamports)
99
+
100
+ borsh_fields.each do |field|
101
+ value = BorshTypes.read_field(buffer, field[:type])
102
+ instance.send(:"#{field[:name]}=", value)
103
+ end
104
+
105
+ instance
106
+ end
107
+
108
+ private
109
+
110
+ def build_filters(user_filters)
111
+ filters = []
112
+
113
+ # Add dataSize filter if all fields have known sizes
114
+ data_size = calculate_data_size
115
+ filters << { "dataSize" => data_size } if data_size
116
+
117
+ # Add discriminator memcmp filter
118
+ disc = discriminator
119
+ if disc
120
+ disc_base58 = Base58.binary_to_base58(disc, :bitcoin)
121
+ filters << {
122
+ "memcmp" => {
123
+ "offset" => 0,
124
+ "bytes" => disc_base58,
125
+ "encoding" => "base58"
126
+ }
127
+ }
128
+ end
129
+
130
+ filters + user_filters
131
+ end
132
+
133
+ def calculate_data_size
134
+ total = 8 # discriminator
135
+ borsh_fields.each do |field|
136
+ size = BorshTypes.field_size(field[:type])
137
+ return nil unless size # variable-length field makes total unknown
138
+ total += size
139
+ end
140
+ total
141
+ end
142
+ end
143
+
144
+ def initialize(attributes = {})
145
+ attributes.each do |key, value|
146
+ send(:"#{key}=", value) if respond_to?(:"#{key}=")
147
+ end
148
+ end
149
+
150
+ # SOL balance of this account
151
+ def sol_balance
152
+ return 0 unless lamports
153
+ lamports.to_f / 1_000_000_000
154
+ end
155
+ end
156
+ end
157
+ end