Class: Solana::Transaction

Inherits:
Object
  • Object
show all
Defined in:
lib/solana/transaction.rb

Constant Summary collapse

SYSTEM_PROGRAM_ID =
"\x00" * 32
TOKEN_PROGRAM_ID =
Keypair.decode_base58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
ASSOCIATED_TOKEN_PROGRAM_ID =
Keypair.decode_base58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL")
SYSVAR_RENT_PUBKEY =
Keypair.decode_base58("SysvarRent111111111111111111111111111111111")

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeTransaction

Returns a new instance of Transaction.



12
13
14
15
16
# File 'lib/solana/transaction.rb', line 12

def initialize
  @instructions = []
  @signers = []
  @recent_blockhash = nil
end

Instance Attribute Details

#instructionsObject (readonly)

Returns the value of attribute instructions.



10
11
12
# File 'lib/solana/transaction.rb', line 10

def instructions
  @instructions
end

#signersObject (readonly)

Returns the value of attribute signers.



10
11
12
# File 'lib/solana/transaction.rb', line 10

def signers
  @signers
end

Class Method Details

.anchor_discriminator(name) ⇒ Object

Compute Anchor instruction discriminator: SHA256(“global:<name>”)



19
20
21
# File 'lib/solana/transaction.rb', line 19

def self.anchor_discriminator(name)
  Digest::SHA256.digest("global:#{name}")[0, 8]
end

.find_pda(seeds, program_id_bytes) ⇒ Object

Derive PDA (Program Derived Address)



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/solana/transaction.rb', line 24

def self.find_pda(seeds, program_id_bytes)
  program_id_bytes = Keypair.decode_base58(program_id_bytes) if program_id_bytes.is_a?(String) && program_id_bytes.length != 32

  255.downto(0) do |bump|
    candidate_seeds = seeds + [[bump].pack("C")]
    begin
      hash_input = candidate_seeds.map { |s| s.is_a?(String) ? s.b : s.pack("C*") }.join
      hash_input += program_id_bytes.b
      hash_input += "ProgramDerivedAddress".b

      candidate = Digest::SHA256.digest(hash_input)

      # Check if the point is on the Ed25519 curve — PDA must NOT be on curve
      unless on_curve?(candidate)
        return [candidate, bump]
      end
    rescue
      next
    end
  end
  raise "Could not find PDA"
end

.on_curve?(bytes) ⇒ Boolean

Returns:

  • (Boolean)


279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/solana/transaction.rb', line 279

def self.on_curve?(bytes)
  bytes = bytes.b
  # Decode y-coordinate (little-endian, clear high bit)
  y = bytes.unpack("C*").each_with_index.sum { |b, i| b * (256**i) }
  y &= (2**255) - 1 # clear sign bit
  return false if y >= ED25519_P

  # Check if x^2 = (y^2 - 1) / (d*y^2 + 1) has a square root mod p
  y2 = y.pow(2, ED25519_P)
  u = (y2 - 1) % ED25519_P
  v = (ED25519_D * y2 + 1) % ED25519_P

  # Compute candidate: x = (u/v)^((p+3)/8) mod p
  v_inv = v.pow(ED25519_P - 2, ED25519_P)
  x2 = (u * v_inv) % ED25519_P
  x = x2.pow((ED25519_P + 3) / 8, ED25519_P)

  # Verify: v * x^2 must equal u or -u mod p
  vx2 = (v * x.pow(2, ED25519_P)) % ED25519_P
  vx2 == u % ED25519_P || vx2 == (ED25519_P - u) % ED25519_P
end

Instance Method Details

#add_instruction(program_id:, accounts:, data:) ⇒ Object



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/solana/transaction.rb', line 57

def add_instruction(program_id:, accounts:, data:)
  program_id_bytes = normalize_pubkey(program_id)
  @instructions << {
    program_id: program_id_bytes,
    accounts: accounts.map { |a|
      {
        pubkey: normalize_pubkey(a[:pubkey]),
        is_signer: a[:is_signer] || false,
        is_writable: a[:is_writable] || false
      }
    },
    data: data.is_a?(String) ? data.b : data.pack("C*")
  }
  self
end

#add_signer(keypair) ⇒ Object



52
53
54
55
# File 'lib/solana/transaction.rb', line 52

def add_signer(keypair)
  @signers << keypair
  self
end

#serializeObject

Serialize and sign the transaction



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/solana/transaction.rb', line 74

def serialize
  raise "No blockhash set" unless @recent_blockhash
  raise "No signers" if @signers.empty?
  raise "No instructions" if @instructions.empty?

  # Collect all unique accounts in order
   = 
  num_required_signatures = count_required_signatures()
  num_readonly_signed = count_readonly_signed()
  num_readonly_unsigned = count_readonly_unsigned()

  # OPSEC-017: the message header declares num_required_signatures, but we
  # only write @signers.length signatures. A mismatch produces a malformed
  # payload — fail loudly here instead of emitting a silently-broken TX.
  if @signers.length != num_required_signatures
    raise "Signer count mismatch: #{@signers.length} signer(s) provided, " \
          "#{num_required_signatures} required by the account list"
  end

  # Build message
  message = build_message(, num_required_signatures, num_readonly_signed, num_readonly_unsigned)

  # Sign message
  signatures = @signers.map { |signer| signer.sign(message) }

  # Compact-array encode signature count + signatures + message
  compact_u16(signatures.length) + signatures.join.b + message
end

#serialize_base64Object



103
104
105
106
# File 'lib/solana/transaction.rb', line 103

def serialize_base64
  require "base64"
  Base64.strict_encode64(serialize)
end

#serialize_partial(additional_signers: []) ⇒ Object

Serialize with partial signing — signs with available signers, leaves zero-byte placeholders for additional_signers that must sign client-side. additional_signers: array of pubkey bytes (32-byte strings) that will sign later.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/solana/transaction.rb', line 111

def serialize_partial(additional_signers: [])
  raise "No blockhash set" unless @recent_blockhash
  raise "No signers" if @signers.empty?
  raise "No instructions" if @instructions.empty?

  # OPSEC-043: keep additional signers in a local — never an instance ivar.
  # A Transaction shared across threads/requests must not leak signer state
  # between partial-sign flows.
  normalized_additional = additional_signers.map { |pk| normalize_pubkey(pk) }

   = (normalized_additional)
  num_required_signatures = count_required_signatures()
  num_readonly_signed = count_readonly_signed()
  num_readonly_unsigned = count_readonly_unsigned()

  # OPSEC-017: every required signature slot must be covered by a local
  # signer (signed now) or an additional signer (signs client-side later).
  # Otherwise a slot is silently zero-filled and the half-signed TX is
  # still broadcastable.
  provided = @signers.length + normalized_additional.length
  if provided != num_required_signatures
    raise "Signer count mismatch: #{provided} provided " \
          "(#{@signers.length} local + #{normalized_additional.length} additional), " \
          "#{num_required_signatures} required by the account list"
  end

  message = build_message(, num_required_signatures, num_readonly_signed, num_readonly_unsigned)

  # Build ordered signature slots matching the account key order
  signer_map = {}
  @signers.each { |s| signer_map[s.public_key_bytes] = s.sign(message) }

  signatures = .select { |_, meta| meta[:is_signer] }.map do |pk, _|
    signer_map[pk] || ("\x00" * 64).b  # zero placeholder for an additional (client-side) signer
  end

  compact_u16(signatures.length) + signatures.join.b + message
end

#serialize_partial_base64(additional_signers: []) ⇒ Object



150
151
152
153
# File 'lib/solana/transaction.rb', line 150

def serialize_partial_base64(additional_signers: [])
  require "base64"
  Base64.strict_encode64(serialize_partial(additional_signers: additional_signers))
end

#set_recent_blockhash(blockhash) ⇒ Object



47
48
49
50
# File 'lib/solana/transaction.rb', line 47

def set_recent_blockhash(blockhash)
  @recent_blockhash = Keypair.decode_base58(blockhash)
  self
end