block_given 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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ # Solidity custom error (`error InsufficientBalance(uint256 available, uint256 required)`).
6
+ class CustomError
7
+ attr_reader :name, :inputs
8
+
9
+ def initialize(definition)
10
+ definition = definition.transform_keys(&:to_s)
11
+ @name = definition["name"].to_s
12
+ @inputs = Array(definition["inputs"]).each_with_index.map { |i, idx| Parameter.new(i, index: idx) }
13
+ end
14
+
15
+ def signature = "#{name}(#{inputs.map(&:type).join(',')})"
16
+ def selector = @selector ||= Utils.keccak256(signature)[0, 10]
17
+
18
+ # Returns a Hash of decoded arguments keyed by snake_case names.
19
+ def decode(revert_data)
20
+ payload = "0x#{Utils.strip_hex(revert_data)[8..]}"
21
+ return {} if inputs.empty?
22
+
23
+ inputs.map(&:ruby_name).zip(Coder.decode(inputs, payload)).to_h
24
+ end
25
+
26
+ def inspect = "#<BlockGiven::Abi::CustomError #{signature}>"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ class Event
6
+ attr_reader :name, :inputs, :anonymous
7
+
8
+ def initialize(definition)
9
+ definition = definition.transform_keys(&:to_s)
10
+ @name = definition["name"].to_s
11
+ @inputs = Array(definition["inputs"]).each_with_index.map { |i, idx| Parameter.new(i, index: idx) }
12
+ @anonymous = !!definition["anonymous"]
13
+ end
14
+
15
+ def ruby_name = Utils.snake_case(name).to_sym
16
+ def signature = "#{name}(#{inputs.map(&:type).join(',')})"
17
+ def topic = @topic ||= Utils.keccak256(signature)
18
+ def anonymous? = anonymous
19
+
20
+ def indexed_inputs = inputs.select(&:indexed?)
21
+ def data_inputs = inputs.reject(&:indexed?)
22
+
23
+ def matches?(log)
24
+ topic0 = Array(log[:topics] || log["topics"]).first
25
+ !anonymous? && topic0&.downcase == topic
26
+ end
27
+
28
+ # Decodes a normalized log (Hash with :topics and :data) into a BlockGiven::Event.
29
+ def decode(log)
30
+ topics = Array(log[:topics] || log["topics"])
31
+ data = log[:data] || log["data"] || "0x"
32
+ indexed_topics = anonymous? ? topics : topics[1..] || []
33
+
34
+ args = {}
35
+ indexed_inputs.each_with_index do |param, i|
36
+ args[param.ruby_name] = decode_topic(indexed_topics[i], param)
37
+ end
38
+ unless data_inputs.empty?
39
+ Coder.decode(data_inputs, data).each_with_index do |value, i|
40
+ args[data_inputs[i].ruby_name] = value
41
+ end
42
+ end
43
+ # keep declaration order
44
+ ordered = inputs.to_h { |p| [p.ruby_name, args[p.ruby_name]] }
45
+ BlockGiven::Event.new(name: name, signature: signature, args: ordered, log: log)
46
+ end
47
+
48
+ # Builds the topics filter array for eth_getLogs from indexed argument values.
49
+ # Values can be nil (wildcard), a single value or an Array (OR).
50
+ def encode_topics(filters = {})
51
+ normalized = filters.transform_keys { |k| Utils.snake_case(k).to_sym }
52
+ unknown = normalized.keys - indexed_inputs.map(&:ruby_name)
53
+ unless unknown.empty?
54
+ indexed = indexed_inputs.map(&:ruby_name).join(", ")
55
+ raise InvalidArgumentError, "#{name}: #{unknown.join(', ')} is not an indexed parameter (indexed: #{indexed})"
56
+ end
57
+
58
+ topics = indexed_inputs.map do |param|
59
+ value = normalized[param.ruby_name]
60
+ next nil if value.nil?
61
+
62
+ value.is_a?(Array) ? value.map { |v| encode_topic(v, param) } : encode_topic(value, param)
63
+ end
64
+ topics = topics.reverse.drop_while(&:nil?).reverse # trailing wildcards are implicit
65
+ anonymous? ? topics : [topic, *topics]
66
+ end
67
+
68
+ def to_s = signature
69
+ def inspect = "#<BlockGiven::Abi::Event #{signature}>"
70
+
71
+ private
72
+
73
+ def decode_topic(topic, param)
74
+ return nil if topic.nil?
75
+ return topic if param.dynamic? # only the keccak hash of the value is available
76
+
77
+ Coder.decode([param], topic).first
78
+ end
79
+
80
+ def encode_topic(value, param)
81
+ return Utils.keccak256(param.raw_type == "string" ? value.to_s : value) if param.dynamic?
82
+
83
+ Utils.prefix_hex(Utils.strip_hex(Coder.encode([param], [value])))
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ class Function
6
+ attr_reader :name, :inputs, :outputs, :state_mutability
7
+
8
+ def initialize(definition)
9
+ definition = definition.transform_keys(&:to_s)
10
+ @name = definition["name"].to_s
11
+ @inputs = Array(definition["inputs"]).each_with_index.map { |i, idx| Parameter.new(i, index: idx) }
12
+ @outputs = Array(definition["outputs"]).each_with_index.map { |o, idx| Parameter.new(o, index: idx) }
13
+ @state_mutability = (definition["stateMutability"] || legacy_mutability(definition)).to_s
14
+ end
15
+
16
+ def ruby_name = Utils.snake_case(name).to_sym
17
+ def signature = "#{name}(#{inputs.map(&:type).join(',')})"
18
+ def selector = @selector ||= Utils.keccak256(signature)[0, 10]
19
+
20
+ def read? = %w[view pure].include?(state_mutability)
21
+ def write? = !read?
22
+ def payable? = state_mutability == "payable"
23
+
24
+ def input_names = inputs.map(&:ruby_name)
25
+
26
+ # Positional args or keyword args (matched on snake_cased input names).
27
+ def encode(args = [], kwargs = {})
28
+ values = resolve_args(args, kwargs)
29
+ selector + Utils.strip_hex(Coder.encode(inputs, values))
30
+ end
31
+
32
+ # Single output -> value; several -> Array.
33
+ def decode_output(hex)
34
+ values = Coder.decode(outputs, hex)
35
+ outputs.size == 1 ? values.first : values
36
+ end
37
+
38
+ def resolve_args(args, kwargs)
39
+ raise InvalidArgumentError, "#{name}: mix of positional and keyword arguments" if !args.empty? && !kwargs.empty?
40
+ return args if kwargs.empty?
41
+
42
+ if inputs.any?(&:unnamed?)
43
+ raise InvalidArgumentError, "#{name}: inputs are unnamed in the ABI, use positional arguments"
44
+ end
45
+
46
+ normalized = kwargs.transform_keys { |k| Utils.snake_case(k).to_sym }
47
+ unknown = normalized.keys - input_names
48
+ unless unknown.empty?
49
+ raise InvalidArgumentError,
50
+ "#{name}: unknown argument(s) #{unknown.join(', ')} (expected #{input_names.join(', ')})"
51
+ end
52
+
53
+ missing = input_names - normalized.keys
54
+ raise InvalidArgumentError, "#{name}: missing argument(s) #{missing.join(', ')}" unless missing.empty?
55
+
56
+ input_names.map { |n| normalized[n] }
57
+ end
58
+
59
+ def to_s = signature
60
+ def inspect = "#<BlockGiven::Abi::Function #{signature} #{state_mutability}>"
61
+
62
+ private
63
+
64
+ def legacy_mutability(definition)
65
+ return "payable" if definition["payable"]
66
+ return "view" if definition["constant"]
67
+
68
+ "nonpayable"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BlockGiven
6
+ module Abi
7
+ # Parsed ABI: functions (with overload resolution), events and custom errors.
8
+ class Interface
9
+ attr_reader :functions, :events, :errors, :constructor, :raw
10
+
11
+ # Accepts an ABI Array, a Hardhat/Foundry artifact Hash (with "abi"), a JSON String or a Pathname.
12
+ def self.parse(source)
13
+ return source if source.is_a?(Interface)
14
+
15
+ new(load_definitions(source))
16
+ end
17
+
18
+ def self.load_definitions(source)
19
+ case source
20
+ when Array then source
21
+ when Hash then source["abi"] || source[:abi] || raise(AbiError, "Hash has no 'abi' key")
22
+ when Pathname, File then load_definitions(JSON.parse(File.read(source)))
23
+ when String then load_definitions(JSON.parse(source))
24
+ else raise AbiError, "cannot parse ABI from #{source.class}"
25
+ end
26
+ rescue JSON::ParserError => e
27
+ raise AbiError, "invalid ABI JSON: #{e.message}"
28
+ end
29
+
30
+ def initialize(definitions)
31
+ @raw = definitions.map { |d| d.transform_keys(&:to_s) }
32
+ @functions = []
33
+ @events = []
34
+ @errors = []
35
+ @constructor = nil
36
+ @raw.each do |definition|
37
+ case definition["type"]
38
+ when "function", nil then @functions << Function.new(definition)
39
+ when "event" then @events << Event.new(definition)
40
+ when "error" then @errors << CustomError.new(definition)
41
+ when "constructor" then @constructor = definition
42
+ end
43
+ end
44
+ @functions_by_name = @functions.group_by(&:ruby_name)
45
+ end
46
+
47
+ def function_names = @functions_by_name.keys
48
+
49
+ # Finds a function by name (snake_case or camelCase) or by full signature
50
+ # ("transfer(address,uint256)"). Overloads are disambiguated by argument
51
+ # count or keyword names.
52
+ def function(name, args: [], kwargs: {})
53
+ name = name.to_s
54
+ return function_by_signature(name) if name.include?("(")
55
+
56
+ candidates = @functions_by_name[Utils.snake_case(name).to_sym]
57
+ if candidates.nil?
58
+ raise FunctionNotFoundError,
59
+ "no function #{name.inspect} in ABI (known: #{function_names.join(', ')})"
60
+ end
61
+ return candidates.first if candidates.size == 1
62
+
63
+ matching =
64
+ if kwargs.empty?
65
+ candidates.select { |f| f.inputs.size == args.size }
66
+ else
67
+ keys = kwargs.keys.map { |k| Utils.snake_case(k).to_sym }.sort
68
+ candidates.select { |f| f.input_names.sort == keys }
69
+ end
70
+ return matching.first if matching.size == 1
71
+
72
+ if matching.size > 1
73
+ raise AmbiguousFunctionError,
74
+ "#{name} is overloaded, use the full signature: #{candidates.map(&:signature).join(' | ')}"
75
+ end
76
+ raise FunctionNotFoundError,
77
+ "no overload of #{name} matches the given arguments (#{candidates.map(&:signature).join(' | ')})"
78
+ end
79
+
80
+ def function_by_signature(signature)
81
+ @functions.find { |f| f.signature == signature.delete(" ") } ||
82
+ raise(FunctionNotFoundError, "no function with signature #{signature.inspect}")
83
+ end
84
+
85
+ def function_by_selector(selector)
86
+ @functions.find { |f| f.selector == selector.downcase }
87
+ end
88
+
89
+ def event(name)
90
+ name = name.to_s
91
+ key = Utils.snake_case(name).to_sym
92
+ @events.find { |e| e.ruby_name == key || e.signature == name } ||
93
+ raise(EventNotFoundError, "no event #{name.inspect} in ABI (known: #{@events.map(&:name).join(', ')})")
94
+ end
95
+
96
+ def event_by_topic(topic)
97
+ @events.find { |e| e.topic == topic.to_s.downcase }
98
+ end
99
+
100
+ def error_by_selector(selector)
101
+ @errors.find { |e| e.selector == selector.to_s.downcase }
102
+ end
103
+
104
+ def inspect
105
+ "#<BlockGiven::Abi::Interface functions=#{@functions.size} events=#{@events.size} errors=#{@errors.size}>"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ # One ABI input/output. Knows its canonical Solidity type ("(uint256,address)[]").
6
+ class Parameter
7
+ attr_reader :name, :raw_type, :components, :indexed, :internal_type
8
+
9
+ def initialize(definition, index: 0)
10
+ definition = definition.transform_keys(&:to_s)
11
+ @name = definition["name"].to_s
12
+ @name = "arg#{index}" if @name.empty?
13
+ @unnamed = definition["name"].to_s.empty?
14
+ @raw_type = definition["type"].to_s
15
+ @indexed = definition["indexed"] ? true : false
16
+ @internal_type = definition["internalType"]
17
+ @components = Array(definition["components"]).each_with_index.map { |c, i| Parameter.new(c, index: i) }
18
+ end
19
+
20
+ def unnamed? = @unnamed
21
+ def indexed? = indexed
22
+ def ruby_name = Utils.snake_case(name).to_sym
23
+
24
+ def tuple? = raw_type.start_with?("tuple")
25
+ def array? = raw_type.end_with?("]")
26
+
27
+ # "tuple[]" with components -> "(uint256,address)[]"
28
+ def type
29
+ return raw_type unless tuple?
30
+
31
+ raw_type.sub("tuple", "(#{components.map(&:type).join(',')})")
32
+ end
33
+
34
+ # Base type without the outermost array dimension.
35
+ def element
36
+ return nil unless array?
37
+
38
+ Parameter.new(
39
+ { "name" => name, "type" => raw_type.sub(/\[\d*\]\z/, ""),
40
+ "components" => components.map(&:to_h) }
41
+ )
42
+ end
43
+
44
+ def dynamic?
45
+ raw_type == "string" || raw_type == "bytes" || raw_type.end_with?("[]") ||
46
+ (tuple? && components.any?(&:dynamic?)) || (array? && element.dynamic?)
47
+ end
48
+
49
+ def to_h
50
+ h = { "name" => unnamed? ? "" : name, "type" => raw_type }
51
+ h["components"] = components.map(&:to_h) if tuple?
52
+ h["indexed"] = indexed if indexed
53
+ h
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Static description of an EVM network, similar to viem/chains.
5
+ Chain = Struct.new(
6
+ :id, :name, :network, :native_currency, :rpc_urls, :block_explorer_url, :alchemy_network, :testnet,
7
+ keyword_init: true
8
+ ) do
9
+ def initialize(id:, name:, network: nil, native_currency: nil, rpc_urls: [], block_explorer_url: nil,
10
+ alchemy_network: nil, testnet: false)
11
+ super(
12
+ id: id, name: name, network: network || name.to_s.downcase.gsub(/[^a-z0-9]+/, "-"),
13
+ native_currency: native_currency || { name: "Ether", symbol: "ETH", decimals: 18 },
14
+ rpc_urls: Array(rpc_urls), block_explorer_url: block_explorer_url,
15
+ alchemy_network: alchemy_network, testnet: testnet
16
+ )
17
+ end
18
+
19
+ def testnet? = !!testnet
20
+
21
+ def explorer_tx_url(hash)
22
+ block_explorer_url && "#{block_explorer_url}/tx/#{hash}"
23
+ end
24
+
25
+ def explorer_address_url(address)
26
+ block_explorer_url && "#{block_explorer_url}/address/#{address}"
27
+ end
28
+
29
+ def to_s = "#{name} (#{id})"
30
+ def inspect = "#<BlockGiven::Chain #{self}>"
31
+ end
32
+
33
+ module Chains
34
+ MAINNET = Chain.new(
35
+ id: 1, name: "Ethereum", network: "mainnet",
36
+ rpc_urls: ["https://eth.merkle.io"], block_explorer_url: "https://etherscan.io",
37
+ alchemy_network: "eth-mainnet"
38
+ )
39
+ SEPOLIA = Chain.new(
40
+ id: 11_155_111, name: "Sepolia", network: "sepolia",
41
+ rpc_urls: ["https://sepolia.drpc.org"], block_explorer_url: "https://sepolia.etherscan.io",
42
+ alchemy_network: "eth-sepolia", testnet: true
43
+ )
44
+ BASE = Chain.new(
45
+ id: 8453, name: "Base", network: "base",
46
+ rpc_urls: ["https://mainnet.base.org"], block_explorer_url: "https://basescan.org",
47
+ alchemy_network: "base-mainnet"
48
+ )
49
+ BASE_SEPOLIA = Chain.new(
50
+ id: 84_532, name: "Base Sepolia", network: "base-sepolia",
51
+ rpc_urls: ["https://sepolia.base.org"], block_explorer_url: "https://sepolia.basescan.org",
52
+ alchemy_network: "base-sepolia", testnet: true
53
+ )
54
+ POLYGON = Chain.new(
55
+ id: 137, name: "Polygon", network: "polygon",
56
+ native_currency: { name: "POL", symbol: "POL", decimals: 18 },
57
+ rpc_urls: ["https://polygon-rpc.com"], block_explorer_url: "https://polygonscan.com",
58
+ alchemy_network: "polygon-mainnet"
59
+ )
60
+ POLYGON_AMOY = Chain.new(
61
+ id: 80_002, name: "Polygon Amoy", network: "polygon-amoy",
62
+ native_currency: { name: "POL", symbol: "POL", decimals: 18 },
63
+ rpc_urls: ["https://rpc-amoy.polygon.technology"], block_explorer_url: "https://amoy.polygonscan.com",
64
+ alchemy_network: "polygon-amoy", testnet: true
65
+ )
66
+ ARBITRUM = Chain.new(
67
+ id: 42_161, name: "Arbitrum One", network: "arbitrum",
68
+ rpc_urls: ["https://arb1.arbitrum.io/rpc"], block_explorer_url: "https://arbiscan.io",
69
+ alchemy_network: "arb-mainnet"
70
+ )
71
+ ARBITRUM_SEPOLIA = Chain.new(
72
+ id: 421_614, name: "Arbitrum Sepolia", network: "arbitrum-sepolia",
73
+ rpc_urls: ["https://sepolia-rollup.arbitrum.io/rpc"], block_explorer_url: "https://sepolia.arbiscan.io",
74
+ alchemy_network: "arb-sepolia", testnet: true
75
+ )
76
+ OPTIMISM = Chain.new(
77
+ id: 10, name: "OP Mainnet", network: "optimism",
78
+ rpc_urls: ["https://mainnet.optimism.io"], block_explorer_url: "https://optimistic.etherscan.io",
79
+ alchemy_network: "opt-mainnet"
80
+ )
81
+ OPTIMISM_SEPOLIA = Chain.new(
82
+ id: 11_155_420, name: "OP Sepolia", network: "optimism-sepolia",
83
+ rpc_urls: ["https://sepolia.optimism.io"], block_explorer_url: "https://sepolia-optimism.etherscan.io",
84
+ alchemy_network: "opt-sepolia", testnet: true
85
+ )
86
+ # Hardhat / Anvil / Ganache default chain, handy with a local fork.
87
+ LOCALHOST = Chain.new(
88
+ id: 31_337, name: "Localhost", network: "localhost",
89
+ rpc_urls: ["http://127.0.0.1:8545"], testnet: true
90
+ )
91
+
92
+ ALL = [MAINNET, SEPOLIA, BASE, BASE_SEPOLIA, POLYGON, POLYGON_AMOY, ARBITRUM, ARBITRUM_SEPOLIA,
93
+ OPTIMISM, OPTIMISM_SEPOLIA, LOCALHOST].freeze
94
+
95
+ module_function
96
+
97
+ # Chains.resolve(:base) / Chains.resolve("base-sepolia") / Chains.resolve(8453) / Chains.resolve(chain)
98
+ def resolve(value)
99
+ case value
100
+ when Chain then value
101
+ when Integer then find_by_id(value) || raise(ConfigurationError, "unknown chain id #{value}")
102
+ when Symbol, String
103
+ key = value.to_s.downcase.tr("_", "-")
104
+ ALL.find { |c| c.network == key } || raise(ConfigurationError, "unknown chain #{value.inspect}")
105
+ else raise ConfigurationError, "cannot resolve chain from #{value.inspect}"
106
+ end
107
+ end
108
+
109
+ def find_by_id(id) = ALL.find { |c| c.id == id }
110
+ def [](value) = resolve(value)
111
+ end
112
+ end