viem_rb 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE +21 -0
  4. data/README.md +365 -0
  5. data/lib/viem/abi/decoder.rb +28 -0
  6. data/lib/viem/abi/encoder.rb +48 -0
  7. data/lib/viem/abi/parse.rb +59 -0
  8. data/lib/viem/accounts/mnemonic_account.rb +34 -0
  9. data/lib/viem/accounts/private_key_account.rb +51 -0
  10. data/lib/viem/actions/public/call.rb +35 -0
  11. data/lib/viem/actions/public/ens.rb +85 -0
  12. data/lib/viem/actions/public/get_balance.rb +15 -0
  13. data/lib/viem/actions/public/get_block.rb +47 -0
  14. data/lib/viem/actions/public/get_code.rb +21 -0
  15. data/lib/viem/actions/public/get_gas.rb +33 -0
  16. data/lib/viem/actions/public/get_logs.rb +32 -0
  17. data/lib/viem/actions/public/get_transaction.rb +72 -0
  18. data/lib/viem/actions/public/read_contract.rb +41 -0
  19. data/lib/viem/actions/wallet/send_transaction.rb +92 -0
  20. data/lib/viem/actions/wallet/sign_message.rb +16 -0
  21. data/lib/viem/actions/wallet/sign_typed_data.rb +16 -0
  22. data/lib/viem/actions/wallet/write_contract.rb +48 -0
  23. data/lib/viem/chains/base.rb +21 -0
  24. data/lib/viem/chains/definitions.rb +185 -0
  25. data/lib/viem/clients/public_client.rb +39 -0
  26. data/lib/viem/clients/test_client.rb +55 -0
  27. data/lib/viem/clients/wallet_client.rb +25 -0
  28. data/lib/viem/errors.rb +63 -0
  29. data/lib/viem/transports/base.rb +26 -0
  30. data/lib/viem/transports/fallback.rb +24 -0
  31. data/lib/viem/transports/http.rb +45 -0
  32. data/lib/viem/transports/web_socket.rb +64 -0
  33. data/lib/viem/utils/address.rb +31 -0
  34. data/lib/viem/utils/hash.rb +27 -0
  35. data/lib/viem/utils/hex.rb +66 -0
  36. data/lib/viem/utils/units.rb +37 -0
  37. data/lib/viem/version.rb +5 -0
  38. data/lib/viem_rb.rb +71 -0
  39. metadata +166 -0
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module Ens
7
+ ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"
8
+
9
+ ENS_PUBLIC_RESOLVER_ABI = [
10
+ {
11
+ "name" => "addr",
12
+ "type" => "function",
13
+ "inputs" => [{ "type" => "bytes32", "name" => "node" }],
14
+ "outputs" => [{ "type" => "address", "name" => "" }],
15
+ "stateMutability" => "view"
16
+ },
17
+ {
18
+ "name" => "name",
19
+ "type" => "function",
20
+ "inputs" => [{ "type" => "bytes32", "name" => "node" }],
21
+ "outputs" => [{ "type" => "string", "name" => "" }],
22
+ "stateMutability" => "view"
23
+ }
24
+ ].freeze
25
+
26
+ REGISTRY_ABI = [
27
+ {
28
+ "name" => "resolver",
29
+ "type" => "function",
30
+ "inputs" => [{ "type" => "bytes32", "name" => "node" }],
31
+ "outputs" => [{ "type" => "address", "name" => "" }],
32
+ "stateMutability" => "view"
33
+ }
34
+ ].freeze
35
+
36
+ def get_ens_address(name:)
37
+ node = namehash(name)
38
+ resolver_addr = read_contract(
39
+ address: ENS_REGISTRY, abi: REGISTRY_ABI,
40
+ function_name: "resolver", args: [node]
41
+ )
42
+ return nil if Utils::Address.is_zero_address?(resolver_addr)
43
+
44
+ read_contract(
45
+ address: resolver_addr, abi: ENS_PUBLIC_RESOLVER_ABI,
46
+ function_name: "addr", args: [node]
47
+ )
48
+ rescue StandardError => e
49
+ raise Error, "ENS resolution failed for #{name}: #{e.message}"
50
+ end
51
+
52
+ def get_ens_name(address:)
53
+ address = Utils::Address.get_address(address)
54
+ reversed = "#{address.downcase.delete_prefix("0x")}.addr.reverse"
55
+ node = namehash(reversed)
56
+ resolver_addr = read_contract(
57
+ address: ENS_REGISTRY, abi: REGISTRY_ABI,
58
+ function_name: "resolver", args: [node]
59
+ )
60
+ return nil if Utils::Address.is_zero_address?(resolver_addr)
61
+
62
+ read_contract(
63
+ address: resolver_addr, abi: ENS_PUBLIC_RESOLVER_ABI,
64
+ function_name: "name", args: [node]
65
+ )
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def namehash(name)
73
+ node = "\x00" * 32
74
+ return Eth::Util.keccak256(node) if name.empty?
75
+
76
+ name.split(".").reverse.each do |label|
77
+ label_hash = Eth::Util.keccak256(label)
78
+ node = Eth::Util.keccak256(node + label_hash)
79
+ end
80
+ node
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetBalance
7
+ def get_balance(address:, block_tag: "latest")
8
+ address = Utils::Address.get_address(address)
9
+ result = @transport.request("eth_getBalance", [address, block_tag.to_s])
10
+ Utils::Hex.hex_to_number(result)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetBlock
7
+ def get_block(block_number: nil, block_hash: nil, block_tag: "latest", include_transactions: false)
8
+ if block_hash
9
+ result = @transport.request("eth_getBlockByHash", [block_hash, include_transactions])
10
+ else
11
+ tag = block_number ? Utils::Hex.number_to_hex(block_number) : block_tag.to_s
12
+ result = @transport.request("eth_getBlockByNumber", [tag, include_transactions])
13
+ end
14
+ raise BlockNotFoundError, "Block not found" unless result
15
+
16
+ format_block(result)
17
+ end
18
+
19
+ def get_block_number
20
+ result = @transport.request("eth_blockNumber", [])
21
+ Utils::Hex.hex_to_number(result)
22
+ end
23
+
24
+ private
25
+
26
+ # Fields that should remain as hex strings (hashes, addresses)
27
+ BLOCK_STRING_FIELDS = %w[
28
+ hash parentHash sha3Uncles miner stateRoot transactionsRoot
29
+ receiptsRoot logsBloom mixHash nonce extraData
30
+ ].freeze
31
+
32
+ def format_block(raw)
33
+ raw.each_with_object({}) do |(k, v), h|
34
+ key = k.to_sym
35
+ if BLOCK_STRING_FIELDS.include?(k.to_s)
36
+ h[key] = v
37
+ elsif v.is_a?(String) && v.start_with?("0x") && v.match?(/\A0x[0-9a-f]+\z/)
38
+ h[key] = Utils::Hex.hex_to_number(v)
39
+ else
40
+ h[key] = v
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetCode
7
+ def get_code(address:, block_tag: "latest")
8
+ address = Utils::Address.get_address(address)
9
+ result = @transport.request("eth_getCode", [address, block_tag.to_s])
10
+ result == "0x" ? nil : result
11
+ end
12
+
13
+ def get_storage_at(address:, slot:, block_tag: "latest")
14
+ address = Utils::Address.get_address(address)
15
+ slot = slot.is_a?(Integer) ? Utils::Hex.number_to_hex(slot, size: 32) : slot
16
+ @transport.request("eth_getStorageAt", [address, slot, block_tag.to_s])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetGas
7
+ def get_gas_price
8
+ result = @transport.request("eth_gasPrice", [])
9
+ Utils::Hex.hex_to_number(result)
10
+ end
11
+
12
+ def get_fee_history(block_count:, newest_block: "latest", reward_percentiles: [])
13
+ result = @transport.request("eth_feeHistory", [
14
+ Utils::Hex.number_to_hex(block_count),
15
+ newest_block.to_s,
16
+ reward_percentiles
17
+ ])
18
+ result
19
+ end
20
+
21
+ def get_max_priority_fee_per_gas
22
+ result = @transport.request("eth_maxPriorityFeePerGas", [])
23
+ Utils::Hex.hex_to_number(result)
24
+ rescue RpcError
25
+ # Fallback: estimate from fee history
26
+ history = get_fee_history(block_count: 4, newest_block: "latest", reward_percentiles: [50])
27
+ rewards = (history["reward"] || []).flatten.map { |r| Utils::Hex.hex_to_number(r) }
28
+ rewards.sum / [rewards.size, 1].max
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetLogs
7
+ def get_logs(address: nil, event: nil, args: {}, from_block: nil, to_block: nil)
8
+ params = {}
9
+ params[:address] = address if address
10
+ params[:fromBlock] = from_block.is_a?(Integer) ? Utils::Hex.number_to_hex(from_block) : (from_block || "earliest")
11
+ params[:toBlock] = to_block.is_a?(Integer) ? Utils::Hex.number_to_hex(to_block) : (to_block || "latest")
12
+ params[:topics] = encode_event_topics(event, args) if event
13
+ results = @transport.request("eth_getLogs", [stringify_keys(params)])
14
+ results.map { |l| format_log(l) }
15
+ end
16
+
17
+ private
18
+
19
+ def encode_event_topics(event_abi, args)
20
+ sig = event_signature(event_abi)
21
+ topic0 = Utils::Hash.keccak256(sig)
22
+ [topic0]
23
+ end
24
+
25
+ def event_signature(abi_item)
26
+ inputs = (abi_item["inputs"] || []).map { |i| i["type"] }.join(",")
27
+ "#{abi_item["name"]}(#{inputs})"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module GetTransaction
7
+ def get_transaction(hash:)
8
+ result = @transport.request("eth_getTransactionByHash", [hash])
9
+ raise TransactionNotFoundError, "Transaction #{hash} not found" unless result
10
+
11
+ format_transaction(result)
12
+ end
13
+
14
+ def get_transaction_receipt(hash:)
15
+ result = @transport.request("eth_getTransactionReceipt", [hash])
16
+ raise TransactionReceiptNotFoundError, "Receipt for #{hash} not found" unless result
17
+
18
+ format_receipt(result)
19
+ end
20
+
21
+ def get_transaction_count(address:, block_tag: "latest")
22
+ address = Utils::Address.get_address(address)
23
+ result = @transport.request("eth_getTransactionCount", [address, block_tag.to_s])
24
+ Utils::Hex.hex_to_number(result)
25
+ end
26
+
27
+ def wait_for_transaction_receipt(hash:, poll_interval: 4, timeout: 120)
28
+ deadline = Time.now + timeout
29
+ loop do
30
+ receipt = @transport.request("eth_getTransactionReceipt", [hash])
31
+ return format_receipt(receipt) if receipt
32
+ raise WaitForTransactionReceiptTimeoutError, "Timeout after #{timeout}s waiting for #{hash}" if Time.now > deadline
33
+
34
+ sleep poll_interval
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def format_transaction(raw)
41
+ raw.transform_keys(&:to_sym).tap do |tx|
42
+ tx[:block_number] = Utils::Hex.hex_to_number(tx[:blockNumber]) if tx[:blockNumber]
43
+ tx[:transaction_index] = Utils::Hex.hex_to_number(tx[:transactionIndex]) if tx[:transactionIndex]
44
+ tx[:nonce] = Utils::Hex.hex_to_number(tx[:nonce]) if tx[:nonce]
45
+ tx[:gas] = Utils::Hex.hex_to_number(tx[:gas]) if tx[:gas]
46
+ tx[:gas_price] = Utils::Hex.hex_to_number(tx[:gasPrice]) if tx[:gasPrice]
47
+ tx[:value] = Utils::Hex.hex_to_number(tx[:value]) if tx[:value]
48
+ end
49
+ end
50
+
51
+ def format_receipt(raw)
52
+ raw.transform_keys(&:to_sym).tap do |r|
53
+ r[:block_number] = Utils::Hex.hex_to_number(r[:blockNumber]) if r[:blockNumber]
54
+ r[:transaction_index] = Utils::Hex.hex_to_number(r[:transactionIndex]) if r[:transactionIndex]
55
+ r[:gas_used] = Utils::Hex.hex_to_number(r[:gasUsed]) if r[:gasUsed]
56
+ r[:cumulative_gas_used] = Utils::Hex.hex_to_number(r[:cumulativeGasUsed]) if r[:cumulativeGasUsed]
57
+ r[:status] = Utils::Hex.hex_to_number(r[:status]) if r[:status]
58
+ r[:logs] = (r[:logs] || []).map { |l| format_log(l) }
59
+ end
60
+ end
61
+
62
+ def format_log(raw)
63
+ raw.transform_keys(&:to_sym).tap do |l|
64
+ l[:block_number] = Utils::Hex.hex_to_number(l[:blockNumber]) if l[:blockNumber]
65
+ l[:transaction_index] = Utils::Hex.hex_to_number(l[:transactionIndex]) if l[:transactionIndex]
66
+ l[:log_index] = Utils::Hex.hex_to_number(l[:logIndex]) if l[:logIndex]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Public
6
+ module ReadContract
7
+ def read_contract(address:, abi:, function_name:, args: [], block_tag: "latest")
8
+ abi_item = find_abi_item(abi, function_name, type: "function")
9
+ data = Abi::Encoder.encode_function_data(abi_item, args: args)
10
+ result = call(to: address, data: data, block_tag: block_tag)
11
+ Abi::Decoder.decode_function_result(abi_item, result)
12
+ rescue RpcError => e
13
+ raise ContractFunctionExecutionError.new(e,
14
+ contract_address: address, function_name: function_name, args: args)
15
+ end
16
+
17
+ def simulate_contract(address:, abi:, function_name:, args: [], account: nil, value: nil, block_tag: "latest")
18
+ abi_item = find_abi_item(abi, function_name, type: "function")
19
+ data = Abi::Encoder.encode_function_data(abi_item, args: args)
20
+ from = account&.address
21
+ result = call(to: address, data: data, from: from, value: value, block_tag: block_tag)
22
+ Abi::Decoder.decode_function_result(abi_item, result)
23
+ rescue RpcError => e
24
+ raise ContractFunctionExecutionError.new(e,
25
+ contract_address: address, function_name: function_name, args: args)
26
+ end
27
+
28
+ private
29
+
30
+ def find_abi_item(abi, name, type: nil)
31
+ item = abi.find do |i|
32
+ i["name"] == name.to_s && (type.nil? || i["type"] == type)
33
+ end
34
+ raise AbiEncodingError, "Function '#{name}' not found in ABI" unless item
35
+
36
+ item
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Wallet
6
+ module SendTransaction
7
+ def send_transaction(
8
+ to:,
9
+ value: 0,
10
+ data: nil,
11
+ gas: nil,
12
+ gas_price: nil,
13
+ max_fee_per_gas: nil,
14
+ max_priority_fee_per_gas: nil,
15
+ nonce: nil,
16
+ account: nil
17
+ )
18
+ acct = account || @account
19
+ raise AccountRequiredError unless acct
20
+
21
+ nonce ||= get_transaction_count(address: acct.address)
22
+ chain_id = @chain&.id || get_chain_id
23
+
24
+ if max_fee_per_gas
25
+ gas ||= begin
26
+ estimate_gas(to: to, from: acct.address, data: data, value: value)
27
+ rescue StandardError
28
+ 21_000
29
+ end
30
+ tx = build_eip1559_tx(
31
+ to: to, value: value, data: data, gas: gas,
32
+ max_fee_per_gas: max_fee_per_gas,
33
+ max_priority_fee_per_gas: max_priority_fee_per_gas,
34
+ nonce: nonce, chain_id: chain_id
35
+ )
36
+ else
37
+ gas ||= begin
38
+ estimate_gas(to: to, from: acct.address, data: data, value: value)
39
+ rescue StandardError
40
+ 21_000
41
+ end
42
+ gas_price ||= get_gas_price
43
+ tx = build_legacy_tx(
44
+ to: to, value: value, data: data, gas: gas,
45
+ gas_price: gas_price, nonce: nonce, chain_id: chain_id
46
+ )
47
+ end
48
+
49
+ signed = sign_tx(acct, tx)
50
+ @transport.request("eth_sendRawTransaction", [signed])
51
+ end
52
+
53
+ private
54
+
55
+ def get_chain_id
56
+ result = @transport.request("eth_chainId", [])
57
+ Utils::Hex.hex_to_number(result)
58
+ end
59
+
60
+ def build_eip1559_tx(to:, value:, data:, gas:, max_fee_per_gas:, max_priority_fee_per_gas:, nonce:, chain_id:)
61
+ Eth::Tx::Eip1559.new({
62
+ chain_id: chain_id,
63
+ nonce: nonce,
64
+ max_priority_fee_per_gas: max_priority_fee_per_gas || Utils::Units.parse_gwei("1.5"),
65
+ max_fee_per_gas: max_fee_per_gas,
66
+ gas_limit: gas,
67
+ to: to,
68
+ value: value,
69
+ data: data || ""
70
+ })
71
+ end
72
+
73
+ def build_legacy_tx(to:, value:, data:, gas:, gas_price:, nonce:, chain_id:)
74
+ Eth::Tx::Legacy.new({
75
+ chain_id: chain_id,
76
+ nonce: nonce,
77
+ gas_price: gas_price,
78
+ gas_limit: gas,
79
+ to: to,
80
+ value: value,
81
+ data: data || ""
82
+ })
83
+ end
84
+
85
+ def sign_tx(account, tx)
86
+ tx.sign(Eth::Key.new(priv: account.private_key.delete_prefix("0x")))
87
+ "0x#{tx.hex}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Wallet
6
+ module SignMessage
7
+ def sign_message(message:, account: nil)
8
+ acct = account || @account
9
+ raise AccountRequiredError unless acct
10
+
11
+ acct.sign_message(message)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Wallet
6
+ module SignTypedData
7
+ def sign_typed_data(domain:, types:, primary_type:, message:, account: nil)
8
+ acct = account || @account
9
+ raise AccountRequiredError unless acct
10
+
11
+ acct.sign_typed_data(domain, types, message)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Actions
5
+ module Wallet
6
+ module WriteContract
7
+ def write_contract(
8
+ address:,
9
+ abi:,
10
+ function_name:,
11
+ args: [],
12
+ value: nil,
13
+ gas: nil,
14
+ gas_price: nil,
15
+ max_fee_per_gas: nil,
16
+ max_priority_fee_per_gas: nil,
17
+ account: nil
18
+ )
19
+ acct = account || @account
20
+ raise AccountRequiredError unless acct
21
+
22
+ abi_item = find_abi_item(abi, function_name, type: "function")
23
+ data = Abi::Encoder.encode_function_data(abi_item, args: args)
24
+
25
+ send_transaction(
26
+ to: address, data: data, value: value || 0,
27
+ gas: gas, gas_price: gas_price,
28
+ max_fee_per_gas: max_fee_per_gas,
29
+ max_priority_fee_per_gas: max_priority_fee_per_gas,
30
+ account: acct
31
+ )
32
+ rescue RpcError => e
33
+ raise ContractFunctionExecutionError.new(e,
34
+ contract_address: address, function_name: function_name, args: args)
35
+ end
36
+
37
+ def deploy_contract(abi:, bytecode:, args: [], value: nil, gas: nil, account: nil)
38
+ acct = account || @account
39
+ raise AccountRequiredError unless acct
40
+
41
+ constructor = abi.find { |i| i["type"] == "constructor" }
42
+ data = Abi::Encoder.encode_deploy_data(bytecode, constructor, args: args)
43
+ send_transaction(to: nil, data: data, value: value || 0, gas: gas, account: acct)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Viem
4
+ module Chains
5
+ Chain = Struct.new(
6
+ :id, :name, :network, :native_currency,
7
+ :rpc_urls, :block_explorers, :testnet,
8
+ keyword_init: true
9
+ ) do
10
+ def rpc_url
11
+ rpc_urls.dig(:default, :http, 0)
12
+ end
13
+
14
+ def testnet?
15
+ !!testnet
16
+ end
17
+ end
18
+
19
+ NativeCurrency = Struct.new(:name, :symbol, :decimals, keyword_init: true)
20
+ end
21
+ end