solana_ruby_wallet_adapter 0.1.1

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.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Abstract base class for all wallet adapters.
6
+ # Mirrors BaseWalletAdapter from @solana/wallet-adapter-base.
7
+ #
8
+ # Subclasses MUST implement:
9
+ # - name (String)
10
+ # - url (String)
11
+ # - icon (String)
12
+ # - ready_state (WalletReadyState)
13
+ # - public_key (T.nilable(PublicKey))
14
+ # - connecting? (Boolean)
15
+ # - connect
16
+ # - disconnect
17
+ # - send_transaction(transaction, rpc_url, options)
18
+ #
19
+ # In Rails the adapter is used server-side to:
20
+ # 1. Supply wallet metadata (name/icon/url) for the frontend.
21
+ # 2. Verify signatures returned by the front-end wallet.
22
+ # 3. Facilitate Sign-In-With-Solana flows.
23
+ class BaseWalletAdapter
24
+ extend T::Sig
25
+ extend T::Helpers
26
+ include EventEmitter
27
+
28
+ abstract!
29
+
30
+ # -------------------------------------------------------------------------
31
+ # Abstract interface
32
+ # -------------------------------------------------------------------------
33
+
34
+ # Human-readable wallet name.
35
+ sig { abstract.returns(String) }
36
+ def name; end
37
+
38
+ # URL pointing to the wallet's download / landing page.
39
+ sig { abstract.returns(String) }
40
+ def url; end
41
+
42
+ # Base64-encoded SVG or PNG data URI for the wallet's icon.
43
+ sig { abstract.returns(String) }
44
+ def icon; end
45
+
46
+ # Current ready state of this adapter.
47
+ sig { abstract.returns(WalletReadyState) }
48
+ def ready_state; end
49
+
50
+ # The currently connected public key, or nil when not connected.
51
+ sig { abstract.returns(T.nilable(PublicKey)) }
52
+ def public_key; end
53
+
54
+ # True while a connection is in progress.
55
+ sig { abstract.returns(T::Boolean) }
56
+ def connecting?; end
57
+
58
+ # Set of supported transaction versions. nil means all versions.
59
+ sig { returns(SupportedTransactionVersions) }
60
+ def supported_transaction_versions
61
+ nil
62
+ end
63
+
64
+ # Initiate a wallet connection.
65
+ sig { abstract.void }
66
+ def connect; end
67
+
68
+ # Disconnect the wallet.
69
+ sig { abstract.void }
70
+ def disconnect; end
71
+
72
+ # Send a transaction to the network via the RPC endpoint.
73
+ sig do
74
+ abstract
75
+ .params(
76
+ transaction: Transaction,
77
+ rpc_url: String,
78
+ options: SendTransactionOptions
79
+ )
80
+ .returns(String) # transaction signature (base58)
81
+ end
82
+ def send_transaction(transaction, rpc_url, options); end
83
+
84
+ # -------------------------------------------------------------------------
85
+ # Concrete helpers
86
+ # -------------------------------------------------------------------------
87
+
88
+ # True when a public key is present (i.e. connected).
89
+ sig { returns(T::Boolean) }
90
+ def connected?
91
+ !public_key.nil?
92
+ end
93
+
94
+ # Serialize adapter metadata as a plain Hash (useful for JSON APIs).
95
+ sig { returns(T::Hash[String, T.untyped]) }
96
+ def to_h
97
+ {
98
+ "name" => name,
99
+ "url" => url,
100
+ "icon" => icon,
101
+ "readyState" => ready_state.serialize,
102
+ "connected" => connected?,
103
+ "publicKey" => public_key&.to_base58,
104
+ }
105
+ end
106
+
107
+ sig { returns(String) }
108
+ def inspect
109
+ "#<#{self.class} name=#{name.inspect} ready_state=#{ready_state.serialize}>"
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Helpers mixed into ActionController::Base when the Railtie loads.
6
+ module ControllerHelpers
7
+ extend T::Sig
8
+
9
+ # Verify a Solana message signature submitted from the browser.
10
+ # Raises WalletSignMessageError if verification fails.
11
+ #
12
+ # Params:
13
+ # public_key_b58 – Base58 string of the signer's public key
14
+ # message – the original UTF-8 message string
15
+ # signature_b64 – Base64-encoded 64-byte Ed25519 signature
16
+ sig do
17
+ params(
18
+ public_key_b58: String,
19
+ message: String,
20
+ signature_b64: String
21
+ ).void
22
+ end
23
+ def verify_wallet_signature!(public_key_b58:, message:, signature_b64:)
24
+ require "base64"
25
+ public_key = PublicKey.new(public_key_b58)
26
+ signature = Base64.strict_decode64(signature_b64)
27
+ SignatureVerifier.new.verify!(
28
+ public_key: public_key,
29
+ message: message,
30
+ signature: signature
31
+ )
32
+ end
33
+
34
+ # Verify a Sign-In-With-Solana output POSTed from the browser.
35
+ # Raises WalletSignInError if verification fails.
36
+ sig do
37
+ params(
38
+ input_params: T::Hash[String, T.untyped],
39
+ output_params: T::Hash[String, T.untyped]
40
+ ).void
41
+ end
42
+ def verify_sign_in!(input_params:, output_params:)
43
+ require "base64"
44
+
45
+ input = SignInInput.new(
46
+ domain: input_params.fetch("domain"),
47
+ address: input_params.fetch("address"),
48
+ statement: input_params["statement"],
49
+ uri: input_params["uri"],
50
+ version: input_params.fetch("version", "1"),
51
+ nonce: input_params["nonce"],
52
+ issued_at: input_params["issued_at"],
53
+ expiration_time: input_params["expiration_time"],
54
+ not_before: input_params["not_before"],
55
+ request_id: input_params["request_id"],
56
+ resources: Array(input_params["resources"]),
57
+ )
58
+
59
+ output = SignInOutput.new(
60
+ address: output_params.fetch("address"),
61
+ signed_message: output_params.fetch("signed_message"),
62
+ signature: Base64.strict_decode64(output_params.fetch("signature")),
63
+ signature_type: output_params.fetch("signature_type", "ed25519"),
64
+ )
65
+
66
+ SignatureVerifier.new.verify_sign_in!(input: input, output: output)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Base error for all wallet-adapter errors.
6
+ # Mirrors WalletError from @solana/wallet-adapter-base.
7
+ class WalletError < StandardError
8
+ extend T::Sig
9
+
10
+ sig { returns(T.nilable(Exception)) }
11
+ attr_reader :cause_error
12
+
13
+ sig { params(message: T.nilable(String), cause_error: T.nilable(Exception)).void }
14
+ def initialize(message = nil, cause_error = nil)
15
+ super(message)
16
+ @cause_error = cause_error
17
+ end
18
+ end
19
+
20
+ # Raised when a wallet is not in a ready / installed state.
21
+ class WalletNotReadyError < WalletError; end
22
+
23
+ # Raised when the wallet extension / app fails to load.
24
+ class WalletLoadError < WalletError; end
25
+
26
+ # Raised when the wallet adapter is misconfigured.
27
+ class WalletConfigError < WalletError; end
28
+
29
+ # Raised when the wallet connection attempt fails.
30
+ class WalletConnectionError < WalletError; end
31
+
32
+ # Raised when the wallet disconnects unexpectedly.
33
+ class WalletDisconnectedError < WalletError; end
34
+
35
+ # Raised when an explicit disconnect request fails.
36
+ class WalletDisconnectionError < WalletError; end
37
+
38
+ # Raised when the wallet returns an unexpected account.
39
+ class WalletAccountError < WalletError; end
40
+
41
+ # Raised when the wallet returns an invalid public key.
42
+ class WalletPublicKeyError < WalletError; end
43
+
44
+ # Raised for keypair-level errors.
45
+ class WalletKeypairError < WalletError; end
46
+
47
+ # Raised when an operation requires a connected wallet.
48
+ class WalletNotConnectedError < WalletError; end
49
+
50
+ # Raised when sending a transaction fails.
51
+ class WalletSendTransactionError < WalletError; end
52
+
53
+ # Raised when signing a transaction fails.
54
+ class WalletSignTransactionError < WalletError; end
55
+
56
+ # Raised when signing a message fails.
57
+ class WalletSignMessageError < WalletError; end
58
+
59
+ # Raised when a Sign-In-With-Solana flow fails.
60
+ class WalletSignInError < WalletError; end
61
+
62
+ # Raised when a wallet operation times out.
63
+ class WalletTimeoutError < WalletError; end
64
+
65
+ # Raised when a popup window is blocked.
66
+ class WalletWindowBlockedError < WalletError; end
67
+
68
+ # Raised when the user closes the wallet popup.
69
+ class WalletWindowClosedError < WalletError; end
70
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # A simple synchronous event-emitter mixin.
6
+ # Mirrors the EventEmitter3 interface used by the TypeScript adapters.
7
+ #
8
+ # Usage:
9
+ # class MyAdapter
10
+ # include EventEmitter
11
+ # # ...
12
+ # def do_something
13
+ # emit(:connect, public_key)
14
+ # end
15
+ # end
16
+ #
17
+ # adapter = MyAdapter.new
18
+ # adapter.on(:connect) { |pk| puts "connected: #{pk}" }
19
+ module EventEmitter
20
+ extend T::Sig
21
+ extend T::Helpers
22
+
23
+ # @!visibility private
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ extend T::Sig
30
+ end
31
+
32
+ # Register a listener for the given event name.
33
+ # Returns self for chaining.
34
+ sig { params(event: Symbol, block: T.proc.params(args: T.untyped).void).returns(T.self_type) }
35
+ def on(event, &block)
36
+ listeners_for(event) << block
37
+ self
38
+ end
39
+
40
+ # Register a one-shot listener that auto-removes itself after the first call.
41
+ sig { params(event: Symbol, block: T.proc.params(args: T.untyped).void).returns(T.self_type) }
42
+ def once(event, &block)
43
+ wrapper = T.let(nil, T.nilable(Proc))
44
+ wrapper = proc do |*args|
45
+ off(event, T.must(wrapper))
46
+ block.call(*args)
47
+ end
48
+ on(event, &T.must(wrapper))
49
+ self
50
+ end
51
+
52
+ # Remove a specific listener (or all listeners if block is omitted).
53
+ sig { params(event: Symbol, block: T.nilable(Proc)).returns(T.self_type) }
54
+ def off(event, block = nil)
55
+ if block
56
+ listeners_for(event).delete(block)
57
+ else
58
+ event_listeners.delete(event)
59
+ end
60
+ self
61
+ end
62
+
63
+ # Emit an event, calling all registered listeners synchronously.
64
+ sig { params(event: Symbol, args: T.untyped).void }
65
+ def emit(event, *args)
66
+ listeners_for(event).dup.each { |listener| listener.call(*args) }
67
+ end
68
+
69
+ private
70
+
71
+ sig { params(event: Symbol).returns(T::Array[Proc]) }
72
+ def listeners_for(event)
73
+ event_listeners[event] ||= []
74
+ end
75
+
76
+ sig { returns(T::Hash[Symbol, T::Array[Proc]]) }
77
+ def event_listeners
78
+ @event_listeners ||= T.let({}, T.nilable(T::Hash[Symbol, T::Array[Proc]]))
79
+ T.must(@event_listeners)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Mixin for adapters that can sign arbitrary byte messages.
6
+ # Mirrors MessageSignerWalletAdapterProps from @solana/wallet-adapter-base.
7
+ module MessageSignerWalletAdapter
8
+ extend T::Sig
9
+ extend T::Helpers
10
+
11
+ requires_ancestor { BaseWalletAdapter }
12
+
13
+ # Sign an arbitrary message and return the 64-byte Ed25519 signature.
14
+ sig do
15
+ abstract
16
+ .params(message: String) # binary string
17
+ .returns(String) # 64-byte binary signature
18
+ end
19
+ def sign_message(message); end
20
+ end
21
+
22
+ # Abstract base class combining transaction + message signing.
23
+ # Mirrors BaseMessageSignerWalletAdapter from @solana/wallet-adapter-base.
24
+ class BaseMessageSignerWalletAdapter < BaseSignerWalletAdapter
25
+ extend T::Sig
26
+ extend T::Helpers
27
+ include MessageSignerWalletAdapter
28
+
29
+ abstract!
30
+ end
31
+
32
+ # -------------------------------------------------------------------------
33
+ # Sign-In-With-Solana (SIWS) support
34
+ # Mirrors SignInMessageSignerWalletAdapterProps from @solana/wallet-adapter-base.
35
+ # -------------------------------------------------------------------------
36
+
37
+ # Input parameters for a SIWS sign-in request.
38
+ class SignInInput < T::Struct
39
+ # The domain presenting the sign-in request (e.g. "example.com").
40
+ const :domain, String
41
+
42
+ # The Solana address being authenticated.
43
+ const :address, String
44
+
45
+ # Human-readable statement.
46
+ const :statement, T.nilable(String), default: nil
47
+
48
+ # URI of the resource that is the subject of the signing.
49
+ const :uri, T.nilable(String), default: nil
50
+
51
+ # Version of the SIWS message specification.
52
+ const :version, String, default: "1"
53
+
54
+ # Unique nonce to prevent replay attacks.
55
+ const :nonce, T.nilable(String), default: nil
56
+
57
+ # ISO 8601 datetime of when the sign-in was issued.
58
+ const :issued_at, T.nilable(String), default: nil
59
+
60
+ # ISO 8601 datetime after which the sign-in expires.
61
+ const :expiration_time, T.nilable(String), default: nil
62
+
63
+ # ISO 8601 datetime after which the sign-in is valid.
64
+ const :not_before, T.nilable(String), default: nil
65
+
66
+ # Randomised value for this request (request ID).
67
+ const :request_id, T.nilable(String), default: nil
68
+
69
+ # List of information or references the dapp requests to be included.
70
+ const :resources, T::Array[String], default: []
71
+
72
+ # Builds the canonical EIP-4361-like SIWS message string.
73
+ sig { returns(String) }
74
+ def to_message
75
+ lines = ["#{domain} wants you to sign in with your Solana account:"]
76
+ lines << address
77
+ lines << "" << (statement || "") << ""
78
+ lines << "URI: #{uri}" if uri
79
+ lines << "Version: #{version}"
80
+ lines << "Nonce: #{nonce}" if nonce
81
+ lines << "Issued At: #{issued_at}" if issued_at
82
+ lines << "Expiration Time: #{expiration_time}" if expiration_time
83
+ lines << "Not Before: #{not_before}" if not_before
84
+ lines << "Request ID: #{request_id}" if request_id
85
+ resources.each_with_index do |r, i|
86
+ lines << "Resources:" if i.zero?
87
+ lines << "- #{r}"
88
+ end
89
+ lines.join("\n")
90
+ end
91
+ end
92
+
93
+ # Output from a completed SIWS sign-in.
94
+ class SignInOutput < T::Struct
95
+ # The account that signed in.
96
+ const :address, String
97
+
98
+ # The signed SIWS message bytes (UTF-8 binary).
99
+ const :signed_message, String
100
+
101
+ # The 64-byte Ed25519 signature.
102
+ const :signature, String
103
+
104
+ # The type of signature (always "ed25519" for Solana).
105
+ const :signature_type, String, default: "ed25519"
106
+ end
107
+
108
+ # Abstract base class that adds sign-in capability.
109
+ class BaseSignInMessageSignerWalletAdapter < BaseMessageSignerWalletAdapter
110
+ extend T::Sig
111
+ extend T::Helpers
112
+
113
+ abstract!
114
+
115
+ # Perform a SIWS sign-in flow. Returns a +SignInOutput+.
116
+ sig do
117
+ abstract
118
+ .params(input: T.nilable(SignInInput))
119
+ .returns(SignInOutput)
120
+ end
121
+ def sign_in(input = nil); end
122
+ end
123
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Mirrors WalletAdapterNetwork from @solana/wallet-adapter-base.
6
+ class Network < T::Enum
7
+ extend T::Sig
8
+
9
+ enums do
10
+ MainnetBeta = new("mainnet-beta")
11
+ Testnet = new("testnet")
12
+ Devnet = new("devnet")
13
+ end
14
+
15
+ # The RPC cluster URL commonly associated with each network.
16
+ RPC_URLS = T.let(
17
+ {
18
+ MainnetBeta => "https://api.mainnet-beta.solana.com",
19
+ Testnet => "https://api.testnet.solana.com",
20
+ Devnet => "https://api.devnet.solana.com",
21
+ }.freeze,
22
+ T::Hash[Network, String]
23
+ )
24
+
25
+ sig { returns(String) }
26
+ def rpc_url
27
+ RPC_URLS.fetch(self)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require "base58"
5
+
6
+ module SolanaWalletAdapter
7
+ # Lightweight representation of a Solana public key.
8
+ # A Solana public key is a 32-byte Ed25519 public key encoded as Base58.
9
+ class PublicKey
10
+ extend T::Sig
11
+
12
+ BYTE_LENGTH = T.let(32, Integer)
13
+
14
+ sig { returns(String) }
15
+ attr_reader :bytes # raw 32-byte binary string
16
+
17
+ # Accepts a Base58-encoded string, a raw 32-byte binary string, or a
18
+ # 32-element Integer array (matching the JS Uint8Array constructor).
19
+ sig { params(value: T.any(String, T::Array[Integer])).void }
20
+ def initialize(value)
21
+ @bytes = T.let(decode(value), String)
22
+ raise ArgumentError, "Public key must be #{BYTE_LENGTH} bytes" unless @bytes.bytesize == BYTE_LENGTH
23
+ end
24
+
25
+ sig { returns(String) }
26
+ def to_base58
27
+ Base58.binary_to_base58(@bytes, :bitcoin)
28
+ end
29
+
30
+ alias to_s to_base58
31
+
32
+ sig { returns(T::Array[Integer]) }
33
+ def to_bytes
34
+ @bytes.bytes
35
+ end
36
+
37
+ sig { params(other: PublicKey).returns(T::Boolean) }
38
+ def ==(other)
39
+ other.is_a?(PublicKey) && bytes == other.bytes
40
+ end
41
+
42
+ sig { returns(Integer) }
43
+ def hash
44
+ bytes.hash
45
+ end
46
+
47
+ sig { returns(String) }
48
+ def inspect
49
+ "#<#{self.class} #{to_base58}>"
50
+ end
51
+
52
+ private
53
+
54
+ sig { params(value: T.any(String, T::Array[Integer])).returns(String) }
55
+ def decode(value)
56
+ case value
57
+ when Array
58
+ value.pack("C*")
59
+ when String
60
+ # Detect binary vs Base58: binary strings are 32 bytes, Base58 strings
61
+ # are 32–44 ASCII characters.
62
+ if value.encoding == Encoding::BINARY || value.bytesize == BYTE_LENGTH
63
+ value.b
64
+ else
65
+ Base58.base58_to_binary(value, :bitcoin)
66
+ end
67
+ else
68
+ T.absurd(value)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module SolanaWalletAdapter
5
+ # Hooks into Rails to auto-load configuration from
6
+ # config/initializers/solana_wallet_adapter.rb (if present) and to expose
7
+ # a controller helper module.
8
+ class Railtie < Rails::Railtie
9
+ # Expose helper methods in ActionController and ActionView.
10
+ initializer "solana_wallet_adapter.action_controller" do
11
+ ActiveSupport.on_load(:action_controller_base) do
12
+ include SolanaWalletAdapter::ControllerHelpers
13
+ end
14
+ end
15
+
16
+ initializer "solana_wallet_adapter.action_view" do
17
+ ActiveSupport.on_load(:action_view) do
18
+ include SolanaWalletAdapter::ViewHelpers
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require "ed25519"
5
+
6
+ module SolanaWalletAdapter
7
+ # Server-side Ed25519 signature verification for Solana.
8
+ #
9
+ # Usage:
10
+ # verifier = SolanaWalletAdapter::SignatureVerifier.new
11
+ #
12
+ # # Verify a raw message + signature
13
+ # verifier.verify!(
14
+ # public_key: PublicKey.new("..."),
15
+ # message: "hello world",
16
+ # signature: Base64.decode64("...") # 64-byte binary
17
+ # )
18
+ #
19
+ # # Verify a SIWS sign-in output
20
+ # verifier.verify_sign_in!(input: sign_in_input, output: sign_in_output)
21
+ class SignatureVerifier
22
+ extend T::Sig
23
+
24
+ # Verify that +signature+ was produced by the holder of +public_key+
25
+ # over +message+. Raises +WalletSignMessageError+ on failure.
26
+ sig do
27
+ params(
28
+ public_key: PublicKey,
29
+ message: String, # arbitrary binary / UTF-8 bytes
30
+ signature: String # 64-byte binary Ed25519 signature
31
+ ).returns(T::Boolean)
32
+ end
33
+ def verify(public_key:, message:, signature:)
34
+ vk = Ed25519::VerifyKey.new(public_key.bytes)
35
+ vk.verify(signature, message)
36
+ true
37
+ rescue Ed25519::VerifyError
38
+ false
39
+ end
40
+
41
+ # Same as +verify+ but raises +WalletSignMessageError+ on failure.
42
+ sig do
43
+ params(
44
+ public_key: PublicKey,
45
+ message: String,
46
+ signature: String
47
+ ).void
48
+ end
49
+ def verify!(public_key:, message:, signature:)
50
+ return if verify(public_key: public_key, message: message, signature: signature)
51
+
52
+ raise WalletSignMessageError, "Signature verification failed for public key #{public_key}"
53
+ end
54
+
55
+ # Verify a complete SIWS sign-in output against its input.
56
+ # Raises +WalletSignInError+ on failure.
57
+ sig do
58
+ params(
59
+ input: SignInInput,
60
+ output: SignInOutput
61
+ ).void
62
+ end
63
+ def verify_sign_in!(input:, output:)
64
+ unless output.address == input.address
65
+ raise WalletSignInError,
66
+ "Sign-in address mismatch: expected #{input.address}, got #{output.address}"
67
+ end
68
+
69
+ expected_message = input.to_message
70
+ unless output.signed_message == expected_message
71
+ raise WalletSignInError, "Sign-in message does not match the expected SIWS message"
72
+ end
73
+
74
+ public_key = PublicKey.new(output.address)
75
+ verify!(
76
+ public_key: public_key,
77
+ message: output.signed_message,
78
+ signature: output.signature
79
+ )
80
+ rescue WalletError
81
+ raise
82
+ rescue => e
83
+ raise WalletSignInError.new(e.message, e)
84
+ end
85
+ end
86
+ end