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
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
|