solana-ruby-kit 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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/core_extensions/tapioca/name_patch.rb +32 -0
  3. data/lib/core_extensions/tapioca/required_ancestors.rb +13 -0
  4. data/lib/generators/solana/ruby/kit/install/install_generator.rb +17 -0
  5. data/lib/generators/solana/ruby/kit/install/templates/solana_ruby_kit.rb.tt +8 -0
  6. data/lib/solana/ruby/kit/accounts/account.rb +47 -0
  7. data/lib/solana/ruby/kit/accounts/maybe_account.rb +86 -0
  8. data/lib/solana/ruby/kit/accounts.rb +6 -0
  9. data/lib/solana/ruby/kit/addresses/address.rb +133 -0
  10. data/lib/solana/ruby/kit/addresses/curve.rb +112 -0
  11. data/lib/solana/ruby/kit/addresses/program_derived_address.rb +155 -0
  12. data/lib/solana/ruby/kit/addresses/public_key.rb +39 -0
  13. data/lib/solana/ruby/kit/addresses.rb +11 -0
  14. data/lib/solana/ruby/kit/codecs/bytes.rb +58 -0
  15. data/lib/solana/ruby/kit/codecs/codec.rb +135 -0
  16. data/lib/solana/ruby/kit/codecs/data_structures.rb +177 -0
  17. data/lib/solana/ruby/kit/codecs/decoder.rb +43 -0
  18. data/lib/solana/ruby/kit/codecs/encoder.rb +52 -0
  19. data/lib/solana/ruby/kit/codecs/numbers.rb +217 -0
  20. data/lib/solana/ruby/kit/codecs/strings.rb +116 -0
  21. data/lib/solana/ruby/kit/codecs.rb +25 -0
  22. data/lib/solana/ruby/kit/configuration.rb +48 -0
  23. data/lib/solana/ruby/kit/encoding/base58.rb +62 -0
  24. data/lib/solana/ruby/kit/errors.rb +226 -0
  25. data/lib/solana/ruby/kit/fast_stable_stringify.rb +62 -0
  26. data/lib/solana/ruby/kit/functional.rb +29 -0
  27. data/lib/solana/ruby/kit/instruction_plans/plans.rb +27 -0
  28. data/lib/solana/ruby/kit/instruction_plans.rb +47 -0
  29. data/lib/solana/ruby/kit/instructions/accounts.rb +80 -0
  30. data/lib/solana/ruby/kit/instructions/instruction.rb +71 -0
  31. data/lib/solana/ruby/kit/instructions/roles.rb +84 -0
  32. data/lib/solana/ruby/kit/instructions.rb +7 -0
  33. data/lib/solana/ruby/kit/keys/key_pair.rb +84 -0
  34. data/lib/solana/ruby/kit/keys/private_key.rb +39 -0
  35. data/lib/solana/ruby/kit/keys/public_key.rb +31 -0
  36. data/lib/solana/ruby/kit/keys/signatures.rb +171 -0
  37. data/lib/solana/ruby/kit/keys.rb +11 -0
  38. data/lib/solana/ruby/kit/offchain_messages/codec.rb +107 -0
  39. data/lib/solana/ruby/kit/offchain_messages/message.rb +22 -0
  40. data/lib/solana/ruby/kit/offchain_messages.rb +16 -0
  41. data/lib/solana/ruby/kit/options/option.rb +132 -0
  42. data/lib/solana/ruby/kit/options.rb +5 -0
  43. data/lib/solana/ruby/kit/plugin_core.rb +58 -0
  44. data/lib/solana/ruby/kit/programs.rb +42 -0
  45. data/lib/solana/ruby/kit/promises.rb +85 -0
  46. data/lib/solana/ruby/kit/railtie.rb +18 -0
  47. data/lib/solana/ruby/kit/rpc/api/get_account_info.rb +76 -0
  48. data/lib/solana/ruby/kit/rpc/api/get_balance.rb +41 -0
  49. data/lib/solana/ruby/kit/rpc/api/get_block_height.rb +29 -0
  50. data/lib/solana/ruby/kit/rpc/api/get_epoch_info.rb +47 -0
  51. data/lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb +52 -0
  52. data/lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb +29 -0
  53. data/lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb +56 -0
  54. data/lib/solana/ruby/kit/rpc/api/get_program_accounts.rb +60 -0
  55. data/lib/solana/ruby/kit/rpc/api/get_signature_statuses.rb +56 -0
  56. data/lib/solana/ruby/kit/rpc/api/get_slot.rb +30 -0
  57. data/lib/solana/ruby/kit/rpc/api/get_token_account_balance.rb +38 -0
  58. data/lib/solana/ruby/kit/rpc/api/get_token_accounts_by_owner.rb +48 -0
  59. data/lib/solana/ruby/kit/rpc/api/get_transaction.rb +36 -0
  60. data/lib/solana/ruby/kit/rpc/api/get_vote_accounts.rb +62 -0
  61. data/lib/solana/ruby/kit/rpc/api/is_blockhash_valid.rb +41 -0
  62. data/lib/solana/ruby/kit/rpc/api/request_airdrop.rb +35 -0
  63. data/lib/solana/ruby/kit/rpc/api/send_transaction.rb +61 -0
  64. data/lib/solana/ruby/kit/rpc/api/simulate_transaction.rb +47 -0
  65. data/lib/solana/ruby/kit/rpc/client.rb +83 -0
  66. data/lib/solana/ruby/kit/rpc/transport.rb +137 -0
  67. data/lib/solana/ruby/kit/rpc.rb +13 -0
  68. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +33 -0
  69. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +33 -0
  70. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +51 -0
  71. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +52 -0
  72. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +38 -0
  73. data/lib/solana/ruby/kit/rpc_parsed_types.rb +16 -0
  74. data/lib/solana/ruby/kit/rpc_subscriptions/api/account_notifications.rb +29 -0
  75. data/lib/solana/ruby/kit/rpc_subscriptions/api/logs_notifications.rb +28 -0
  76. data/lib/solana/ruby/kit/rpc_subscriptions/api/program_notifications.rb +30 -0
  77. data/lib/solana/ruby/kit/rpc_subscriptions/api/root_notifications.rb +19 -0
  78. data/lib/solana/ruby/kit/rpc_subscriptions/api/signature_notifications.rb +28 -0
  79. data/lib/solana/ruby/kit/rpc_subscriptions/api/slot_notifications.rb +19 -0
  80. data/lib/solana/ruby/kit/rpc_subscriptions/autopinger.rb +42 -0
  81. data/lib/solana/ruby/kit/rpc_subscriptions/client.rb +80 -0
  82. data/lib/solana/ruby/kit/rpc_subscriptions/subscription.rb +58 -0
  83. data/lib/solana/ruby/kit/rpc_subscriptions/transport.rb +163 -0
  84. data/lib/solana/ruby/kit/rpc_subscriptions.rb +12 -0
  85. data/lib/solana/ruby/kit/rpc_types/account_info.rb +53 -0
  86. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +56 -0
  87. data/lib/solana/ruby/kit/rpc_types/commitment.rb +52 -0
  88. data/lib/solana/ruby/kit/rpc_types/lamports.rb +43 -0
  89. data/lib/solana/ruby/kit/rpc_types.rb +8 -0
  90. data/lib/solana/ruby/kit/signers/keypair_signer.rb +126 -0
  91. data/lib/solana/ruby/kit/signers.rb +5 -0
  92. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +80 -0
  93. data/lib/solana/ruby/kit/subscribable/data_publisher.rb +90 -0
  94. data/lib/solana/ruby/kit/subscribable.rb +13 -0
  95. data/lib/solana/ruby/kit/sysvars/addresses.rb +19 -0
  96. data/lib/solana/ruby/kit/sysvars/clock.rb +37 -0
  97. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +34 -0
  98. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +22 -0
  99. data/lib/solana/ruby/kit/sysvars/rent.rb +29 -0
  100. data/lib/solana/ruby/kit/sysvars.rb +33 -0
  101. data/lib/solana/ruby/kit/transaction_confirmation.rb +159 -0
  102. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +168 -0
  103. data/lib/solana/ruby/kit/transaction_messages.rb +5 -0
  104. data/lib/solana/ruby/kit/transactions/transaction.rb +135 -0
  105. data/lib/solana/ruby/kit/transactions.rb +5 -0
  106. data/lib/solana/ruby/kit/version.rb +10 -0
  107. data/lib/solana/ruby/kit.rb +100 -0
  108. data/solana-ruby-kit.gemspec +29 -0
  109. metadata +311 -0
@@ -0,0 +1,80 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative 'roles'
6
+
7
+ module Solana::Ruby::Kit
8
+ module Instructions
9
+ extend T::Sig
10
+ # Metadata for a single account referenced by an instruction.
11
+ # Mirrors TypeScript's `AccountMeta<TAddress>`.
12
+ #
13
+ # Combines an address with an `AccountRole` that declares whether the
14
+ # account is a signer, writable, or both.
15
+ class AccountMeta < T::Struct
16
+ const :address, Addresses::Address
17
+ const :role, Integer # one of AccountRole constants
18
+ end
19
+
20
+ # AccountMeta subtypes — mirrors the four TypeScript convenience types.
21
+
22
+ module_function
23
+
24
+ # Creates a read-only account reference.
25
+ sig { params(address: Addresses::Address).returns(AccountMeta) }
26
+ def readonly_account(address)
27
+ AccountMeta.new(address: address, role: AccountRole::READONLY)
28
+ end
29
+
30
+ # Creates a writable account reference.
31
+ sig { params(address: Addresses::Address).returns(AccountMeta) }
32
+ def writable_account(address)
33
+ AccountMeta.new(address: address, role: AccountRole::WRITABLE)
34
+ end
35
+
36
+ # Creates a read-only signer account reference.
37
+ sig { params(address: Addresses::Address).returns(AccountMeta) }
38
+ def readonly_signer_account(address)
39
+ AccountMeta.new(address: address, role: AccountRole::READONLY_SIGNER)
40
+ end
41
+
42
+ # Creates a writable signer account reference.
43
+ sig { params(address: Addresses::Address).returns(AccountMeta) }
44
+ def writable_signer_account(address)
45
+ AccountMeta.new(address: address, role: AccountRole::WRITABLE_SIGNER)
46
+ end
47
+
48
+ # -------------------------------------------------------------------
49
+ # Address lookup table accounts
50
+ # Mirrors TypeScript's `AccountLookupMeta<TAddress, TLookupTableAddress>`.
51
+ # Accounts resolved through a lookup table cannot act as signers.
52
+ # -------------------------------------------------------------------
53
+ class AccountLookupMeta < T::Struct
54
+ const :address, Addresses::Address
55
+ const :address_index, Integer
56
+ const :lookup_table_address, Addresses::Address
57
+ const :role, Integer # READONLY or WRITABLE only
58
+ end
59
+
60
+ sig { params(address: Addresses::Address, lookup_table_address: Addresses::Address, address_index: Integer).returns(AccountLookupMeta) }
61
+ def readonly_lookup_account(address, lookup_table_address:, address_index:)
62
+ AccountLookupMeta.new(
63
+ address: address,
64
+ address_index: address_index,
65
+ lookup_table_address: lookup_table_address,
66
+ role: AccountRole::READONLY
67
+ )
68
+ end
69
+
70
+ sig { params(address: Addresses::Address, lookup_table_address: Addresses::Address, address_index: Integer).returns(AccountLookupMeta) }
71
+ def writable_lookup_account(address, lookup_table_address:, address_index:)
72
+ AccountLookupMeta.new(
73
+ address: address,
74
+ address_index: address_index,
75
+ lookup_table_address: lookup_table_address,
76
+ role: AccountRole::WRITABLE
77
+ )
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,71 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../addresses/address'
5
+ require_relative '../errors'
6
+ require_relative 'accounts'
7
+
8
+ module Solana::Ruby::Kit
9
+ module Instructions
10
+ extend T::Sig
11
+ # A single instruction to be executed by a Solana program.
12
+ # Mirrors TypeScript's `Instruction<TProgramAddress, TAccounts, TData>`.
13
+ #
14
+ # All fields are optional — instructions may omit accounts or data.
15
+ class Instruction < T::Struct
16
+ const :program_address, Addresses::Address
17
+ const :accounts, T.nilable(T::Array[T.any(AccountMeta, AccountLookupMeta)])
18
+ const :data, T.nilable(String) # binary String (Uint8Array equivalent)
19
+ end
20
+
21
+ module_function
22
+
23
+ # Returns true if the instruction targets the given program address.
24
+ # Mirrors `isInstructionForProgram()`.
25
+ sig { params(instruction: Instruction, program_address: Addresses::Address).returns(T::Boolean) }
26
+ def instruction_for_program?(instruction, program_address)
27
+ instruction.program_address == program_address
28
+ end
29
+
30
+ # Raises SolanaError unless the instruction targets the given program.
31
+ # Mirrors `assertIsInstructionForProgram()`.
32
+ sig { params(instruction: Instruction, program_address: Addresses::Address).void }
33
+ def assert_instruction_for_program!(instruction, program_address)
34
+ return if instruction_for_program?(instruction, program_address)
35
+
36
+ Kernel.raise SolanaError.new(
37
+ :SOLANA_ERROR__INSTRUCTION__EXPECTED_TO_HAVE_ACCOUNTS,
38
+ expected_program_address: program_address.value,
39
+ actual_program_address: instruction.program_address.value
40
+ )
41
+ end
42
+
43
+ # Returns true if the instruction has at least one account.
44
+ # Mirrors `isInstructionWithAccounts()`.
45
+ sig { params(instruction: Instruction).returns(T::Boolean) }
46
+ def instruction_with_accounts?(instruction)
47
+ !instruction.accounts.nil? && !T.must(instruction.accounts).empty?
48
+ end
49
+
50
+ # Raises unless the instruction has accounts.
51
+ # Mirrors `assertIsInstructionWithAccounts()`.
52
+ sig { params(instruction: Instruction).void }
53
+ def assert_instruction_with_accounts!(instruction)
54
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__INSTRUCTION__EXPECTED_TO_HAVE_ACCOUNTS) unless instruction_with_accounts?(instruction)
55
+ end
56
+
57
+ # Returns true if the instruction carries data bytes.
58
+ # Mirrors `isInstructionWithData()`.
59
+ sig { params(instruction: Instruction).returns(T::Boolean) }
60
+ def instruction_with_data?(instruction)
61
+ !instruction.data.nil?
62
+ end
63
+
64
+ # Raises unless the instruction has data.
65
+ # Mirrors `assertIsInstructionWithData()`.
66
+ sig { params(instruction: Instruction).void }
67
+ def assert_instruction_with_data!(instruction)
68
+ Kernel.raise SolanaError.new(:SOLANA_ERROR__INSTRUCTION__EXPECTED_TO_HAVE_DATA) unless instruction_with_data?(instruction)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,84 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Instructions
6
+ # Bitflag-based account roles for Solana instructions.
7
+ # Mirrors TypeScript's `AccountRole` enum from @solana/instructions.
8
+ #
9
+ # Bit layout:
10
+ # bit 1 (0b10) → signer privilege
11
+ # bit 0 (0b01) → writable privilege
12
+ module AccountRole
13
+ extend T::Sig
14
+
15
+ READONLY = T.let(0b00, Integer) # read-only, no signing required
16
+ WRITABLE = T.let(0b01, Integer) # writable, no signing required
17
+ READONLY_SIGNER = T.let(0b10, Integer) # must sign, read-only
18
+ WRITABLE_SIGNER = T.let(0b11, Integer) # must sign AND writable
19
+
20
+ ALL = T.let([READONLY, WRITABLE, READONLY_SIGNER, WRITABLE_SIGNER].freeze, T::Array[Integer])
21
+
22
+ module_function
23
+
24
+ # Returns true if the role requires the account to sign the transaction.
25
+ sig { params(role: Integer).returns(T::Boolean) }
26
+ def signer_role?(role)
27
+ (role & 0b10) != 0
28
+ end
29
+
30
+ # Returns true if the role permits writing to the account.
31
+ sig { params(role: Integer).returns(T::Boolean) }
32
+ def writable_role?(role)
33
+ (role & 0b01) != 0
34
+ end
35
+
36
+ # Returns the role that grants the highest privileges of both inputs.
37
+ # Mirrors `mergeRoles()`.
38
+ sig { params(a: Integer, b: Integer).returns(Integer) }
39
+ def merge(a, b)
40
+ a | b
41
+ end
42
+
43
+ # Removes the signer bit from a role.
44
+ # Mirrors `downgradeRoleToNonSigner()`.
45
+ sig { params(role: Integer).returns(Integer) }
46
+ def downgrade_to_non_signer(role)
47
+ role & 0b01
48
+ end
49
+
50
+ # Removes the writable bit from a role.
51
+ # Mirrors `downgradeRoleToReadonly()`.
52
+ sig { params(role: Integer).returns(Integer) }
53
+ def downgrade_to_readonly(role)
54
+ role & 0b10
55
+ end
56
+
57
+ # Adds the signer bit to a role.
58
+ # Mirrors `upgradeRoleToSigner()`.
59
+ sig { params(role: Integer).returns(Integer) }
60
+ def upgrade_to_signer(role)
61
+ role | 0b10
62
+ end
63
+
64
+ # Adds the writable bit to a role.
65
+ # Mirrors `upgradeRoleToWritable()`.
66
+ sig { params(role: Integer).returns(Integer) }
67
+ def upgrade_to_writable(role)
68
+ role | 0b01
69
+ end
70
+
71
+ # Human-readable name for a role (useful for debugging).
72
+ sig { params(role: Integer).returns(String) }
73
+ def name(role)
74
+ case role
75
+ when READONLY then 'READONLY'
76
+ when WRITABLE then 'WRITABLE'
77
+ when READONLY_SIGNER then 'READONLY_SIGNER'
78
+ when WRITABLE_SIGNER then 'WRITABLE_SIGNER'
79
+ else "UNKNOWN(#{role})"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,7 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Instruction types and utilities — mirrors @solana/instructions.
5
+ require_relative 'instructions/roles'
6
+ require_relative 'instructions/accounts'
7
+ require_relative 'instructions/instruction'
@@ -0,0 +1,84 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'rbnacl'
5
+ require_relative '../errors'
6
+ require_relative 'signatures'
7
+
8
+ module Solana::Ruby::Kit
9
+ module Keys
10
+ extend T::Sig
11
+ # Represents an Ed25519 key pair: a signing key (private) and its
12
+ # corresponding verification key (public).
13
+ #
14
+ # Mirrors TypeScript's `CryptoKeyPair`:
15
+ # { privateKey: CryptoKey, publicKey: CryptoKey }
16
+ #
17
+ # In TypeScript this wraps Web Crypto opaque `CryptoKey` handles.
18
+ # In Ruby, RbNaCl::SigningKey holds the 32-byte private key seed, and
19
+ # RbNaCl::VerifyKey holds the 32-byte public key.
20
+ class KeyPair < T::Struct
21
+ # 32-byte Ed25519 signing (private) key
22
+ const :signing_key, T.untyped # RbNaCl::SigningKey
23
+ # Corresponding 32-byte verification (public) key
24
+ const :verify_key, T.untyped # RbNaCl::VerifyKey
25
+ end
26
+
27
+ module_function
28
+
29
+ # Generates a fresh Ed25519 key pair using a cryptographically secure RNG.
30
+ # Mirrors `generateKeyPair()` in TypeScript.
31
+ sig { returns(KeyPair) }
32
+ def generate_key_pair
33
+ signing_key = RbNaCl::SigningKey.generate
34
+ KeyPair.new(signing_key: signing_key, verify_key: signing_key.verify_key)
35
+ end
36
+
37
+ # Creates a KeyPair from a 64-byte seed array (first 32 = private seed,
38
+ # last 32 = expected public key bytes) and verifies they match by performing
39
+ # a sign-and-verify round-trip.
40
+ #
41
+ # Mirrors `createKeyPairFromBytes(bytes: ReadonlyUint8Array)` in TypeScript.
42
+ #
43
+ # @param bytes [String] A 64-byte binary string (private seed || public key).
44
+ sig { params(bytes: String).returns(KeyPair) }
45
+ def create_key_pair_from_bytes(bytes)
46
+ if bytes.bytesize != 64
47
+ Kernel.raise SolanaError.new(
48
+ SolanaError::KEYS__INVALID_KEY_PAIR_BYTE_LENGTH,
49
+ byte_length: bytes.bytesize
50
+ )
51
+ end
52
+
53
+ private_seed = bytes.byteslice(0, 32) || Kernel.raise(SolanaError.new(SolanaError::KEYS__INVALID_KEY_PAIR_BYTE_LENGTH, byte_length: bytes.bytesize))
54
+ public_bytes = bytes.byteslice(32, 32) || Kernel.raise(SolanaError.new(SolanaError::KEYS__INVALID_KEY_PAIR_BYTE_LENGTH, byte_length: bytes.bytesize))
55
+
56
+ signing_key = RbNaCl::SigningKey.new(private_seed.b)
57
+ verify_key = signing_key.verify_key
58
+
59
+ # Verify that the embedded public key matches the derived one.
60
+ unless verify_key.to_bytes == public_bytes.b
61
+ Kernel.raise SolanaError.new(SolanaError::KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY)
62
+ end
63
+
64
+ # Round-trip sign-and-verify with random data (mirrors the TypeScript check).
65
+ random_data = RbNaCl::Random.random_bytes(32)
66
+ signed_data = sign_bytes(signing_key, random_data)
67
+ unless verify_signature(verify_key, signed_data, random_data)
68
+ Kernel.raise SolanaError.new(SolanaError::KEYS__PUBLIC_KEY_MUST_MATCH_PRIVATE_KEY)
69
+ end
70
+
71
+ KeyPair.new(signing_key: signing_key, verify_key: verify_key)
72
+ end
73
+
74
+ # Creates a KeyPair from a 32-byte private key seed alone.
75
+ # The matching public key is derived automatically.
76
+ #
77
+ # Mirrors `createKeyPairFromPrivateKeyBytes(bytes: ReadonlyUint8Array)`.
78
+ sig { params(bytes: String).returns(KeyPair) }
79
+ def create_key_pair_from_private_key_bytes(bytes)
80
+ signing_key = RbNaCl::SigningKey.new(bytes.b)
81
+ KeyPair.new(signing_key: signing_key, verify_key: signing_key.verify_key)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'rbnacl'
5
+ require_relative '../errors'
6
+
7
+ module Solana::Ruby::Kit
8
+ module Keys
9
+ extend T::Sig
10
+ module_function
11
+
12
+ # Creates an Ed25519 signing key from a raw 32-byte private key seed.
13
+ #
14
+ # Mirrors `createPrivateKeyFromBytes(bytes, extractable)` in TypeScript.
15
+ # TypeScript wraps the raw seed in a PKCS#8 ASN.1 header before calling
16
+ # `crypto.subtle.importKey` because the Web Crypto API requires PKCS#8 format.
17
+ # In Ruby, RbNaCl::SigningKey accepts raw 32-byte seeds directly, so no
18
+ # wrapping is necessary.
19
+ #
20
+ # The `extractable` parameter exists for API parity with the TypeScript
21
+ # original. RbNaCl::SigningKey always allows seed extraction via `#to_bytes`.
22
+ # Pass `extractable: false` to signal intent; enforcement is caller-side.
23
+ #
24
+ # @param bytes [String] 32-byte binary string (Ed25519 private key seed)
25
+ # @param extractable [Boolean] whether the raw seed may be re-exported (advisory)
26
+ # @return [RbNaCl::SigningKey]
27
+ sig { params(bytes: String, extractable: T::Boolean).returns(T.untyped) }
28
+ def create_private_key_from_bytes(bytes, extractable: false)
29
+ if bytes.bytesize != 32
30
+ Kernel.raise SolanaError.new(
31
+ SolanaError::KEYS__INVALID_KEY_PAIR_BYTE_LENGTH,
32
+ byte_length: bytes.bytesize
33
+ )
34
+ end
35
+
36
+ RbNaCl::SigningKey.new(bytes.b)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'rbnacl'
5
+ require_relative '../errors'
6
+
7
+ module Solana::Ruby::Kit
8
+ module Keys
9
+ extend T::Sig
10
+ module_function
11
+
12
+ # Derives the Ed25519 verification (public) key from a signing (private) key.
13
+ #
14
+ # Mirrors `getPublicKeyFromPrivateKey(privateKey, extractable)` in TypeScript.
15
+ # TypeScript exports the JWK representation of the private key and re-imports
16
+ # only the public component because Web Crypto keys are opaque handles.
17
+ # In Ruby, RbNaCl::SigningKey#verify_key returns the VerifyKey directly.
18
+ #
19
+ # @param signing_key [RbNaCl::SigningKey]
20
+ # @param extractable [Boolean] advisory flag for API parity with TypeScript
21
+ # @return [RbNaCl::VerifyKey]
22
+ sig { params(signing_key: T.untyped, extractable: T::Boolean).returns(T.untyped) }
23
+ def get_public_key_from_private_key(signing_key, extractable: false)
24
+ unless signing_key.respond_to?(:verify_key)
25
+ Kernel.raise SolanaError.new(SolanaError::ADDRESSES__INVALID_ED25519_PUBLIC_KEY)
26
+ end
27
+
28
+ signing_key.verify_key
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,171 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'rbnacl'
5
+ require_relative '../errors'
6
+ require_relative '../encoding/base58'
7
+
8
+ module Solana::Ruby::Kit
9
+ module Keys
10
+ extend T::Sig
11
+ # A validated base58-encoded Ed25519 signature string (64 bytes on-wire).
12
+ # Mirrors TypeScript:
13
+ # type Signature = Brand<EncodedString<string, 'base58'>, 'Signature'>
14
+ class Signature
15
+ extend T::Sig
16
+
17
+ sig { returns(String) }
18
+ attr_reader :value
19
+
20
+ sig { params(value: String).void }
21
+ def initialize(value)
22
+ @value = T.let(value, String)
23
+ end
24
+
25
+ sig { returns(String) }
26
+ def to_s = @value
27
+
28
+ sig { params(other: T.untyped).returns(T::Boolean) }
29
+ def ==(other)
30
+ !!(other.is_a?(Signature) && @value == other.value)
31
+ end
32
+ end
33
+
34
+ # A 64-byte binary string holding raw Ed25519 signature bytes.
35
+ # Mirrors TypeScript:
36
+ # type SignatureBytes = Brand<Uint8Array, 'SignatureBytes'>
37
+ class SignatureBytes
38
+ extend T::Sig
39
+
40
+ BYTE_LENGTH = 64
41
+
42
+ sig { returns(String) }
43
+ attr_reader :value # binary String, always 64 bytes
44
+
45
+ sig { params(value: String).void }
46
+ def initialize(value)
47
+ @value = T.let(value, String)
48
+ end
49
+
50
+ sig { returns(Integer) }
51
+ def bytesize = @value.bytesize
52
+
53
+ sig { params(other: T.untyped).returns(T::Boolean) }
54
+ def ==(other)
55
+ !!(other.is_a?(SignatureBytes) && @value == other.value)
56
+ end
57
+ end
58
+
59
+ # Length bounds for a base58-encoded 64-byte signature string.
60
+ SIGNATURE_MIN_STR_LEN = 64
61
+ SIGNATURE_MAX_STR_LEN = 88
62
+
63
+ module_function
64
+
65
+ # Returns true if the string is a valid base58-encoded Ed25519 signature.
66
+ # Mirrors `isSignature()` in TypeScript.
67
+ sig { params(putative: String).returns(T::Boolean) }
68
+ def signature?(putative)
69
+ return false unless putative.length.between?(SIGNATURE_MIN_STR_LEN, SIGNATURE_MAX_STR_LEN)
70
+
71
+ bytes = Encoding::Base58.decode(putative)
72
+ bytes.bytesize == SignatureBytes::BYTE_LENGTH
73
+ rescue ArgumentError
74
+ false
75
+ end
76
+
77
+ # Raises SolanaError if the string is not a valid base58-encoded signature.
78
+ # Mirrors `assertIsSignature()` in TypeScript.
79
+ sig { params(putative: String).void }
80
+ def assert_signature!(putative)
81
+ unless putative.length.between?(SIGNATURE_MIN_STR_LEN, SIGNATURE_MAX_STR_LEN)
82
+ Kernel.raise SolanaError.new(
83
+ SolanaError::KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE,
84
+ actual_length: putative.length
85
+ )
86
+ end
87
+
88
+ bytes = Encoding::Base58.decode(putative)
89
+ assert_signature_bytes!(bytes)
90
+ rescue ArgumentError
91
+ Kernel.raise SolanaError.new(SolanaError::KEYS__SIGNATURE_STRING_LENGTH_OUT_OF_RANGE, actual_length: putative.length)
92
+ end
93
+
94
+ # Validates and wraps a string in a Signature value object.
95
+ # Mirrors `signature()` in TypeScript.
96
+ sig { params(putative: String).returns(Signature) }
97
+ def signature(putative)
98
+ assert_signature!(putative)
99
+ Signature.new(putative)
100
+ end
101
+
102
+ # Returns true if the binary string is exactly 64 bytes (valid signature length).
103
+ # Mirrors `isSignatureBytes()` in TypeScript.
104
+ sig { params(putative: String).returns(T::Boolean) }
105
+ def signature_bytes?(putative)
106
+ putative.bytesize == SignatureBytes::BYTE_LENGTH
107
+ end
108
+
109
+ # Raises SolanaError if the binary string is not exactly 64 bytes.
110
+ # Mirrors `assertIsSignatureBytes()` in TypeScript.
111
+ sig { params(putative: String).void }
112
+ def assert_signature_bytes!(putative)
113
+ unless putative.bytesize == SignatureBytes::BYTE_LENGTH
114
+ Kernel.raise SolanaError.new(
115
+ SolanaError::KEYS__INVALID_SIGNATURE_BYTE_LENGTH,
116
+ actual_length: putative.bytesize
117
+ )
118
+ end
119
+ end
120
+
121
+ # Validates and wraps raw bytes in a SignatureBytes value object.
122
+ # Mirrors `signatureBytes()` in TypeScript.
123
+ sig { params(putative: String).returns(SignatureBytes) }
124
+ def signature_bytes(putative)
125
+ assert_signature_bytes!(putative)
126
+ SignatureBytes.new(putative.b)
127
+ end
128
+
129
+ # Signs data with an Ed25519 signing key, returning raw 64-byte SignatureBytes.
130
+ #
131
+ # Mirrors `signBytes(key: CryptoKey, data: ReadonlyUint8Array)` in TypeScript.
132
+ # TypeScript uses `crypto.subtle.sign` (async); Ruby uses RbNaCl (sync).
133
+ #
134
+ # @param signing_key [RbNaCl::SigningKey]
135
+ # @param data [String] binary string to sign
136
+ sig { params(signing_key: T.untyped, data: String).returns(SignatureBytes) }
137
+ def sign_bytes(signing_key, data)
138
+ raw = signing_key.sign(data.b)
139
+ SignatureBytes.new(raw)
140
+ end
141
+
142
+ # Verifies an Ed25519 signature against a message.
143
+ #
144
+ # Mirrors `verifySignature(key, signature, data)` in TypeScript.
145
+ # Returns false rather than raising on mismatch, matching the TS return type.
146
+ #
147
+ # @param verify_key [RbNaCl::VerifyKey]
148
+ # @param sig_bytes [SignatureBytes]
149
+ # @param data [String] binary string that was signed
150
+ sig { params(verify_key: T.untyped, sig_bytes: SignatureBytes, data: String).returns(T::Boolean) }
151
+ def verify_signature(verify_key, sig_bytes, data)
152
+ verify_key.verify(sig_bytes.value, data.b)
153
+ true
154
+ rescue RbNaCl::BadSignatureError
155
+ false
156
+ end
157
+
158
+ # Convenience: encode a SignatureBytes to a base58 Signature string.
159
+ sig { params(sig_bytes: SignatureBytes).returns(Signature) }
160
+ def encode_signature(sig_bytes)
161
+ Signature.new(Encoding::Base58.encode(sig_bytes.value))
162
+ end
163
+
164
+ # Convenience: decode a base58 Signature string to SignatureBytes.
165
+ sig { params(sig: Signature).returns(SignatureBytes) }
166
+ def decode_signature(sig)
167
+ bytes = Encoding::Base58.decode(sig.value)
168
+ signature_bytes(bytes)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,11 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Utilities for validating, generating, and manipulating Ed25519 key material
5
+ # and signatures. Mirrors the TypeScript package @solana/keys.
6
+ #
7
+ # Can be used standalone or as part of Solana::Ruby::Kit.
8
+ require_relative 'keys/private_key'
9
+ require_relative 'keys/public_key'
10
+ require_relative 'keys/signatures'
11
+ require_relative 'keys/key_pair'
@@ -0,0 +1,107 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+
6
+ module Solana::Ruby::Kit
7
+ module OffchainMessages
8
+ # Wire format for off-chain messages (Solana standard).
9
+ #
10
+ # v0: [0xFF, 0xFF] + domain_len(u8) + domain + version(u8) + message_len(u16LE) + message
11
+ # v1: [0xFF, 0xFF] + domain_len(u8) + domain + version(u8) + app_domain_len(u16LE)
12
+ # + app_domain + message_len(u16LE) + message
13
+ module Codec
14
+ extend T::Sig
15
+
16
+ MAGIC = T.let("\xFF\xFF".b.freeze, String)
17
+
18
+ module_function
19
+
20
+ # Serialize a Message to its canonical binary form.
21
+ sig { params(msg: Message).returns(String) }
22
+ def encode_offchain_message(msg)
23
+ domain_b = msg.domain.encode('ASCII').b
24
+ Kernel.raise ArgumentError, 'Domain exceeds 255 bytes' if domain_b.bytesize > 255
25
+
26
+ message_b = msg.message.encode('UTF-8').b
27
+ Kernel.raise ArgumentError, 'Message too large' if message_b.bytesize > 0xFFFF
28
+
29
+ buf = MAGIC.dup
30
+ buf << [domain_b.bytesize].pack('C')
31
+ buf << domain_b
32
+ buf << [msg.version].pack('C')
33
+
34
+ if msg.version >= 1 && msg.application_domain
35
+ app_b = T.must(msg.application_domain).encode('ASCII').b
36
+ Kernel.raise ArgumentError, 'Application domain exceeds 65535 bytes' if app_b.bytesize > 0xFFFF
37
+
38
+ buf << [app_b.bytesize].pack('v')
39
+ buf << app_b
40
+ end
41
+
42
+ buf << [message_b.bytesize].pack('v')
43
+ buf << message_b
44
+ buf.b
45
+ end
46
+
47
+ # Deserialize a Message from its canonical binary form.
48
+ sig { params(bytes: String).returns(Message) }
49
+ def decode_offchain_message(bytes) # rubocop:disable Metrics/MethodLength
50
+ b = bytes.b
51
+ Kernel.raise SolanaError.new(SolanaError::OFFCHAIN_MESSAGES__INVALID_MESSAGE_FORMAT) unless b.byteslice(0, 2) == MAGIC
52
+
53
+ offset = 2
54
+ domain_len = b.byteslice(offset, 1)&.unpack1('C') || 0
55
+ offset += 1
56
+ domain = b.byteslice(offset, domain_len)&.force_encoding('ASCII') || ''
57
+ offset += domain_len
58
+ version = b.byteslice(offset, 1)&.unpack1('C') || 0
59
+ offset += 1
60
+
61
+ application_domain = nil
62
+ if version >= 1
63
+ app_len = b.byteslice(offset, 2)&.unpack1('v') || 0
64
+ offset += 2
65
+ application_domain = b.byteslice(offset, app_len)&.force_encoding('ASCII')
66
+ offset += app_len
67
+ end
68
+
69
+ msg_len = b.byteslice(offset, 2)&.unpack1('v') || 0
70
+ offset += 2
71
+ message = b.byteslice(offset, msg_len)&.force_encoding('UTF-8') || ''
72
+
73
+ Message.new(
74
+ version: version,
75
+ domain: domain,
76
+ message: message,
77
+ application_domain: application_domain
78
+ )
79
+ end
80
+
81
+ # Sign an off-chain message with a KeyPairSigner.
82
+ sig { params(signer: Signers::KeyPairSigner, msg: Message).returns(Keys::Signature) }
83
+ def sign_offchain_message(signer, msg)
84
+ payload = encode_offchain_message(msg)
85
+ Keys.encode_signature(signer.sign(payload))
86
+ end
87
+
88
+ # Verify an off-chain message signature.
89
+ sig do
90
+ params(
91
+ verify_key: String, # 32-byte Ed25519 public key
92
+ signature: Keys::Signature,
93
+ msg: Message
94
+ ).returns(T::Boolean)
95
+ end
96
+ def verify_offchain_message_signature(verify_key, signature, msg)
97
+ payload = encode_offchain_message(msg)
98
+ vk = RbNaCl::VerifyKey.new(verify_key)
99
+ sig_bytes = signature.respond_to?(:to_bytes) ? signature.to_s : [signature.value].pack('H*')
100
+ vk.verify(sig_bytes, payload)
101
+ true
102
+ rescue RbNaCl::BadSignatureError
103
+ false
104
+ end
105
+ end
106
+ end
107
+ end