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.
- checksums.yaml +7 -0
- data/lib/core_extensions/tapioca/name_patch.rb +32 -0
- data/lib/core_extensions/tapioca/required_ancestors.rb +13 -0
- data/lib/generators/solana/ruby/kit/install/install_generator.rb +17 -0
- data/lib/generators/solana/ruby/kit/install/templates/solana_ruby_kit.rb.tt +8 -0
- data/lib/solana/ruby/kit/accounts/account.rb +47 -0
- data/lib/solana/ruby/kit/accounts/maybe_account.rb +86 -0
- data/lib/solana/ruby/kit/accounts.rb +6 -0
- data/lib/solana/ruby/kit/addresses/address.rb +133 -0
- data/lib/solana/ruby/kit/addresses/curve.rb +112 -0
- data/lib/solana/ruby/kit/addresses/program_derived_address.rb +155 -0
- data/lib/solana/ruby/kit/addresses/public_key.rb +39 -0
- data/lib/solana/ruby/kit/addresses.rb +11 -0
- data/lib/solana/ruby/kit/codecs/bytes.rb +58 -0
- data/lib/solana/ruby/kit/codecs/codec.rb +135 -0
- data/lib/solana/ruby/kit/codecs/data_structures.rb +177 -0
- data/lib/solana/ruby/kit/codecs/decoder.rb +43 -0
- data/lib/solana/ruby/kit/codecs/encoder.rb +52 -0
- data/lib/solana/ruby/kit/codecs/numbers.rb +217 -0
- data/lib/solana/ruby/kit/codecs/strings.rb +116 -0
- data/lib/solana/ruby/kit/codecs.rb +25 -0
- data/lib/solana/ruby/kit/configuration.rb +48 -0
- data/lib/solana/ruby/kit/encoding/base58.rb +62 -0
- data/lib/solana/ruby/kit/errors.rb +226 -0
- data/lib/solana/ruby/kit/fast_stable_stringify.rb +62 -0
- data/lib/solana/ruby/kit/functional.rb +29 -0
- data/lib/solana/ruby/kit/instruction_plans/plans.rb +27 -0
- data/lib/solana/ruby/kit/instruction_plans.rb +47 -0
- data/lib/solana/ruby/kit/instructions/accounts.rb +80 -0
- data/lib/solana/ruby/kit/instructions/instruction.rb +71 -0
- data/lib/solana/ruby/kit/instructions/roles.rb +84 -0
- data/lib/solana/ruby/kit/instructions.rb +7 -0
- data/lib/solana/ruby/kit/keys/key_pair.rb +84 -0
- data/lib/solana/ruby/kit/keys/private_key.rb +39 -0
- data/lib/solana/ruby/kit/keys/public_key.rb +31 -0
- data/lib/solana/ruby/kit/keys/signatures.rb +171 -0
- data/lib/solana/ruby/kit/keys.rb +11 -0
- data/lib/solana/ruby/kit/offchain_messages/codec.rb +107 -0
- data/lib/solana/ruby/kit/offchain_messages/message.rb +22 -0
- data/lib/solana/ruby/kit/offchain_messages.rb +16 -0
- data/lib/solana/ruby/kit/options/option.rb +132 -0
- data/lib/solana/ruby/kit/options.rb +5 -0
- data/lib/solana/ruby/kit/plugin_core.rb +58 -0
- data/lib/solana/ruby/kit/programs.rb +42 -0
- data/lib/solana/ruby/kit/promises.rb +85 -0
- data/lib/solana/ruby/kit/railtie.rb +18 -0
- data/lib/solana/ruby/kit/rpc/api/get_account_info.rb +76 -0
- data/lib/solana/ruby/kit/rpc/api/get_balance.rb +41 -0
- data/lib/solana/ruby/kit/rpc/api/get_block_height.rb +29 -0
- data/lib/solana/ruby/kit/rpc/api/get_epoch_info.rb +47 -0
- data/lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb +52 -0
- data/lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb +29 -0
- data/lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb +56 -0
- data/lib/solana/ruby/kit/rpc/api/get_program_accounts.rb +60 -0
- data/lib/solana/ruby/kit/rpc/api/get_signature_statuses.rb +56 -0
- data/lib/solana/ruby/kit/rpc/api/get_slot.rb +30 -0
- data/lib/solana/ruby/kit/rpc/api/get_token_account_balance.rb +38 -0
- data/lib/solana/ruby/kit/rpc/api/get_token_accounts_by_owner.rb +48 -0
- data/lib/solana/ruby/kit/rpc/api/get_transaction.rb +36 -0
- data/lib/solana/ruby/kit/rpc/api/get_vote_accounts.rb +62 -0
- data/lib/solana/ruby/kit/rpc/api/is_blockhash_valid.rb +41 -0
- data/lib/solana/ruby/kit/rpc/api/request_airdrop.rb +35 -0
- data/lib/solana/ruby/kit/rpc/api/send_transaction.rb +61 -0
- data/lib/solana/ruby/kit/rpc/api/simulate_transaction.rb +47 -0
- data/lib/solana/ruby/kit/rpc/client.rb +83 -0
- data/lib/solana/ruby/kit/rpc/transport.rb +137 -0
- data/lib/solana/ruby/kit/rpc.rb +13 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +33 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +33 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +51 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +52 -0
- data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +38 -0
- data/lib/solana/ruby/kit/rpc_parsed_types.rb +16 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/account_notifications.rb +29 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/logs_notifications.rb +28 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/program_notifications.rb +30 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/root_notifications.rb +19 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/signature_notifications.rb +28 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/api/slot_notifications.rb +19 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/autopinger.rb +42 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/client.rb +80 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/subscription.rb +58 -0
- data/lib/solana/ruby/kit/rpc_subscriptions/transport.rb +163 -0
- data/lib/solana/ruby/kit/rpc_subscriptions.rb +12 -0
- data/lib/solana/ruby/kit/rpc_types/account_info.rb +53 -0
- data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +56 -0
- data/lib/solana/ruby/kit/rpc_types/commitment.rb +52 -0
- data/lib/solana/ruby/kit/rpc_types/lamports.rb +43 -0
- data/lib/solana/ruby/kit/rpc_types.rb +8 -0
- data/lib/solana/ruby/kit/signers/keypair_signer.rb +126 -0
- data/lib/solana/ruby/kit/signers.rb +5 -0
- data/lib/solana/ruby/kit/subscribable/async_iterable.rb +80 -0
- data/lib/solana/ruby/kit/subscribable/data_publisher.rb +90 -0
- data/lib/solana/ruby/kit/subscribable.rb +13 -0
- data/lib/solana/ruby/kit/sysvars/addresses.rb +19 -0
- data/lib/solana/ruby/kit/sysvars/clock.rb +37 -0
- data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +34 -0
- data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +22 -0
- data/lib/solana/ruby/kit/sysvars/rent.rb +29 -0
- data/lib/solana/ruby/kit/sysvars.rb +33 -0
- data/lib/solana/ruby/kit/transaction_confirmation.rb +159 -0
- data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +168 -0
- data/lib/solana/ruby/kit/transaction_messages.rb +5 -0
- data/lib/solana/ruby/kit/transactions/transaction.rb +135 -0
- data/lib/solana/ruby/kit/transactions.rb +5 -0
- data/lib/solana/ruby/kit/version.rb +10 -0
- data/lib/solana/ruby/kit.rb +100 -0
- data/solana-ruby-kit.gemspec +29 -0
- 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,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
|