polarloop 1.0.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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PolarLoop
6
+ module Abi
7
+ ABI_PATH = File.expand_path("abi/polar_loop.json", __dir__)
8
+
9
+ def self.abi
10
+ @abi ||= JSON.parse(File.read(ABI_PATH))
11
+ end
12
+
13
+ # keccak256("OPERATOR_ROLE") — matches Solidity keccak256("OPERATOR_ROLE")
14
+ OPERATOR_ROLE = Eth::Util.keccak256("OPERATOR_ROLE")
15
+
16
+ # bytes32(0) — DEFAULT_ADMIN_ROLE in AccessControl
17
+ DEFAULT_ADMIN_ROLE = "\x00" * 32
18
+
19
+ # Event topic hashes (keccak256 of event signature)
20
+ MANDATE_CREATED_TOPIC = Eth::Util.keccak256(
21
+ "MandateCreated(bytes32,address,address,address,uint128,uint32,uint128,bytes32)"
22
+ )
23
+
24
+ MANDATE_CHARGED_TOPIC = Eth::Util.keccak256(
25
+ "MandateCharged(bytes32,address,address,uint128,uint48)"
26
+ )
27
+
28
+ MANDATE_REVOKED_TOPIC = Eth::Util.keccak256(
29
+ "MandateRevoked(bytes32,address)"
30
+ )
31
+
32
+ def self.topic_hex(topic)
33
+ "0x" + topic.unpack1("H*")
34
+ end
35
+
36
+ # Precomputed error selectors from ABI for decoding revert data
37
+ def self.error_selectors
38
+ @error_selectors ||= abi.select { |e| e["type"] == "error" }.each_with_object({}) do |entry, hash|
39
+ sig = "#{entry["name"]}(#{(entry["inputs"] || []).map { |i| i["type"] }.join(",")})"
40
+ selector = Eth::Util.keccak256(sig)[0, 4].unpack1("H*")
41
+ hash[selector] = { name: entry["name"], types: (entry["inputs"] || []).map { |i| i["type"] } }
42
+ end
43
+ end
44
+
45
+ # Standard Solidity error selectors (not in ABI)
46
+ ERROR_STRING_SELECTOR = "08c379a0" # Error(string) — require(false, "msg")
47
+ PANIC_SELECTOR = "4e487b71" # Panic(uint256) — assert failures
48
+
49
+ # Decode hex revert data into human-readable error string
50
+ def self.decode_revert(hex_data)
51
+ return nil unless hex_data.is_a?(String) && hex_data.sub(/\A0x/i, "").length >= 8
52
+
53
+ raw = hex_data.sub(/\A0x/i, "")
54
+ selector = raw[0, 8]
55
+
56
+ # Standard Error(string)
57
+ if selector == ERROR_STRING_SELECTOR
58
+ encoded = [raw[8..]].pack("H*")
59
+ decoded = Eth::Abi.decode(["string"], encoded)
60
+ return decoded[0]
61
+ end
62
+
63
+ # Standard Panic(uint256)
64
+ if selector == PANIC_SELECTOR
65
+ encoded = [raw[8..]].pack("H*")
66
+ decoded = Eth::Abi.decode(["uint256"], encoded)
67
+ return "Panic(0x#{decoded[0].to_s(16).rjust(2, '0')})"
68
+ end
69
+
70
+ # Custom errors from contract ABI
71
+ error_info = error_selectors[selector]
72
+ return "Unknown error (0x#{selector})" unless error_info
73
+ return error_info[:name] if error_info[:types].empty?
74
+
75
+ encoded = [raw[8..]].pack("H*")
76
+ decoded = Eth::Abi.decode(error_info[:types], encoded)
77
+
78
+ args = decoded.map do |v|
79
+ if v.is_a?(String) && v.encoding == Encoding::BINARY
80
+ "0x" + v.unpack1("H*")
81
+ else
82
+ v.to_s
83
+ end
84
+ end
85
+
86
+ "#{error_info[:name]}(#{args.join(', ')})"
87
+ rescue
88
+ "Unknown error: #{hex_data}"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolarLoop
4
+ class Client
5
+ attr_reader :chain_config, :contract_caller, :event_parser
6
+
7
+ def initialize(chain_config:, key:)
8
+ @chain_config = chain_config
9
+ @key = key
10
+ @contract_caller = ContractCaller.new(chain_config: chain_config, key: key)
11
+ @event_parser = EventParser.new(@contract_caller)
12
+ end
13
+
14
+ # --- Signature for mandate creation (off-chain, no gas) ---
15
+
16
+ def sign_create_mandate(payer:, merchant:, token:, amount:, interval:, max_total_amount:, reference_id:)
17
+ struct_hash = Eth::Util.keccak256(
18
+ Eth::Abi.encode(
19
+ ["address", "address", "address", "uint128", "uint32", "uint128", "bytes32", "uint256", "address"],
20
+ [payer, merchant, token, amount, interval, max_total_amount,
21
+ normalize_bytes32(reference_id), @chain_config.chain_id, @chain_config.contract_address]
22
+ )
23
+ )
24
+
25
+ @key.personal_sign(struct_hash)
26
+ end
27
+
28
+ # --- Write methods ---
29
+
30
+ def charge(mandate_id)
31
+ @contract_caller.send_tx("charge", normalize_bytes32(mandate_id))
32
+ end
33
+
34
+ def batch_charge(mandate_ids)
35
+ @contract_caller.send_batch_tx("batchCharge", mandate_ids.map { |id| normalize_bytes32(id) })
36
+ end
37
+
38
+ def revoke_mandate(mandate_id)
39
+ @contract_caller.send_tx("revokeMandate", normalize_bytes32(mandate_id))
40
+ end
41
+
42
+ def batch_revoke_mandate(mandate_ids)
43
+ @contract_caller.send_batch_tx("batchRevokeMandate", mandate_ids.map { |id| normalize_bytes32(id) })
44
+ end
45
+
46
+ def pause
47
+ @contract_caller.send_tx("pause")
48
+ end
49
+
50
+ def unpause
51
+ @contract_caller.send_tx("unpause")
52
+ end
53
+
54
+ def grant_operator_role(address)
55
+ @contract_caller.send_tx("grantRole", Abi::OPERATOR_ROLE, address)
56
+ end
57
+
58
+ def revoke_operator_role(address)
59
+ @contract_caller.send_tx("revokeRole", Abi::OPERATOR_ROLE, address)
60
+ end
61
+
62
+ def rescue_eth
63
+ @contract_caller.send_tx("rescueETH")
64
+ end
65
+
66
+ def rescue_erc20(token, amount)
67
+ @contract_caller.send_tx("rescueERC20", token, amount)
68
+ end
69
+
70
+ # --- View methods (no gas) ---
71
+
72
+ def get_mandate(mandate_id)
73
+ result = @contract_caller.call_view("getMandate", normalize_bytes32(mandate_id))
74
+ Types::Mandate.from_abi(result)
75
+ end
76
+
77
+ def get_mandate_by_reference_id(reference_id)
78
+ result = @contract_caller.call_view("getMandateByReferenceId", normalize_bytes32(reference_id))
79
+ mandate_id = to_hex_bytes32(result[0])
80
+ mandate = Types::Mandate.from_abi(result[1])
81
+ [mandate_id, mandate]
82
+ end
83
+
84
+ def get_payer_mandates(payer)
85
+ result = @contract_caller.call_view("getPayerMandates", payer)
86
+ Array(result).map { |id| to_hex_bytes32(id) }
87
+ end
88
+
89
+ def charge_ready?(mandate_id)
90
+ result = @contract_caller.call_view("isChargeReady", normalize_bytes32(mandate_id))
91
+ Types::ChargeReadyResult.new(ready: result[0], reason: result[1])
92
+ end
93
+
94
+ def batch_charge_ready?(mandate_ids)
95
+ result = @contract_caller.call_view("batchIsChargeReady", mandate_ids.map { |id| normalize_bytes32(id) })
96
+ ready_arr = result[0]
97
+ reasons_arr = result[1]
98
+ ready_arr.zip(reasons_arr).map do |ready, reason|
99
+ Types::ChargeReadyResult.new(ready: ready, reason: reason)
100
+ end
101
+ end
102
+
103
+ def min_interval
104
+ @contract_caller.call_view("minInterval").to_i
105
+ end
106
+
107
+ def paused?
108
+ @contract_caller.call_view("paused")
109
+ end
110
+
111
+ def latest_block_number
112
+ @contract_caller.latest_block_number
113
+ end
114
+
115
+ # --- Events ---
116
+
117
+ def mandate_created_events(from_block:, to_block: "latest")
118
+ @event_parser.mandate_created_events(from_block: from_block, to_block: to_block)
119
+ end
120
+
121
+ def mandate_charged_events(from_block:, to_block: "latest")
122
+ @event_parser.mandate_charged_events(from_block: from_block, to_block: to_block)
123
+ end
124
+
125
+ def mandate_revoked_events(from_block:, to_block: "latest")
126
+ @event_parser.mandate_revoked_events(from_block: from_block, to_block: to_block)
127
+ end
128
+
129
+ def events_for_tx(tx_hash)
130
+ @event_parser.events_for_tx(tx_hash)
131
+ end
132
+
133
+ # --- Helpers ---
134
+
135
+ def normalize_bytes32(hex_str)
136
+ if hex_str.is_a?(String)
137
+ if hex_str.bytesize == 32 && (hex_str.encoding == Encoding::BINARY || !hex_str.valid_encoding?)
138
+ return hex_str.b
139
+ end
140
+
141
+ hex = hex_str.sub(/\A0x/i, "").rjust(64, "0")
142
+ return [hex].pack("H*")
143
+ end
144
+
145
+ hex = hex_str.to_s.sub(/\A0x/i, "").rjust(64, "0")
146
+ [hex].pack("H*")
147
+ end
148
+
149
+ def to_hex_bytes32(binary)
150
+ if binary.is_a?(String) && binary.start_with?("0x")
151
+ binary.downcase
152
+ elsif binary.is_a?(String)
153
+ "0x" + binary.unpack1("H*")
154
+ else
155
+ "0x" + binary.to_s(16).rjust(64, "0")
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolarLoop
4
+ ChainConfig = Struct.new(
5
+ :rpc_url,
6
+ :contract_address,
7
+ :chain_id,
8
+ :gas_multiplier,
9
+ :tx_timeout,
10
+ keyword_init: true
11
+ )
12
+
13
+ class Configuration
14
+ attr_accessor :mnemonic, :index, :logger
15
+ attr_reader :chains
16
+
17
+ def initialize
18
+ @chains = {}
19
+ @logger = nil
20
+ @mnemonic = nil
21
+ @index = 0
22
+ end
23
+
24
+ def register(chain_name, rpc_url:, contract_address:, chain_id:, gas_multiplier: 1.2, tx_timeout: 120)
25
+ @chains[chain_name.to_sym] = ChainConfig.new(
26
+ rpc_url: rpc_url,
27
+ contract_address: contract_address,
28
+ chain_id: chain_id,
29
+ gas_multiplier: gas_multiplier,
30
+ tx_timeout: tx_timeout
31
+ )
32
+ end
33
+
34
+ def chain(name)
35
+ @chains[name.to_sym] || raise(ChainNotRegisteredError, "Chain :#{name} not registered")
36
+ end
37
+
38
+ def validate!
39
+ raise ConfigurationError, "Mnemonic is required" if mnemonic.nil? || mnemonic.strip.empty?
40
+ raise ConfigurationError, "At least one chain must be registered" if chains.empty?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolarLoop
4
+ module EthHttpRawBodyCapture
5
+ def send_request(payload)
6
+ body = super
7
+ Thread.current[:polarloop_last_raw_response] = body
8
+ body
9
+ end
10
+ end
11
+ Eth::Client::Http.prepend(EthHttpRawBodyCapture) unless Eth::Client::Http.ancestors.include?(EthHttpRawBodyCapture)
12
+
13
+ class ContractCaller
14
+ attr_reader :eth_client, :contract
15
+
16
+ def initialize(chain_config:, key:)
17
+ @chain_config = chain_config
18
+ @key = key
19
+ @eth_client = Eth::Client.create(chain_config.rpc_url)
20
+ @contract = Eth::Contract.from_abi(
21
+ name: "PolarLoop",
22
+ address: chain_config.contract_address,
23
+ abi: Abi.abi
24
+ )
25
+ @gas_strategy = GasStrategy.new(chain_config.chain_id, @eth_client)
26
+ end
27
+
28
+ def call_view(function_name, *args)
29
+ result = @eth_client.call(@contract, function_name, *args)
30
+ raise RpcError, "Empty response from node in call_view: #{function_name}" if result.nil?
31
+ result
32
+ rescue Eth::Client::ContractExecutionError => e
33
+ raise ContractRevertError, e.message
34
+ rescue Eth::Client::RpcError => e
35
+ raise_from_rpc_error(e)
36
+ rescue Errno::ECONNREFUSED => e
37
+ raise RpcError, "RPC connection failed: #{e.message}"
38
+ rescue JSON::ParserError
39
+ raw = Thread.current[:polarloop_last_raw_response]
40
+ raise RpcError, "RPC connection failed: #{raw || 'non-JSON response from node'}"
41
+ rescue NoMethodError => e
42
+ raise RpcError, "Unexpected node response in call_view: #{e.message}"
43
+ end
44
+
45
+ def send_tx(function_name, *args)
46
+ gas = estimate_gas(function_name, *args)
47
+ tx_hash, _success = @eth_client.transact_and_wait(
48
+ @contract,
49
+ function_name,
50
+ *args,
51
+ **tx_opts,
52
+ gas_limit: gas
53
+ )
54
+ build_tx_result(tx_hash)
55
+ rescue Eth::Client::ContractExecutionError => e
56
+ raise ContractRevertError, e.message
57
+ rescue Eth::Client::RpcError => e
58
+ raise_from_rpc_error(e)
59
+ rescue Errno::ECONNREFUSED => e
60
+ raise RpcError, "RPC connection failed: #{e.message}"
61
+ rescue JSON::ParserError
62
+ raw = Thread.current[:polarloop_last_raw_response]
63
+ raise RpcError, "RPC connection failed: #{raw || 'non-JSON response from node'}"
64
+ rescue NoMethodError => e
65
+ raise RpcError, "Unexpected node response in send_tx: #{e.message}"
66
+ end
67
+
68
+ def send_batch_tx(function_name, *args)
69
+ simulation = @eth_client.call(@contract, function_name, *args)
70
+ batch_results = Types::BatchResult.from_abi_array(
71
+ simulation.is_a?(Array) ? simulation : [simulation]
72
+ )
73
+
74
+ gas = estimate_gas(function_name, *args)
75
+ tx_hash, _success = @eth_client.transact_and_wait(
76
+ @contract,
77
+ function_name,
78
+ *args,
79
+ **tx_opts,
80
+ gas_limit: gas
81
+ )
82
+ result = build_tx_result(tx_hash)
83
+ result.batch_results = batch_results
84
+ result
85
+ rescue Eth::Client::ContractExecutionError => e
86
+ raise ContractRevertError, e.message
87
+ rescue Eth::Client::RpcError => e
88
+ raise_from_rpc_error(e)
89
+ rescue Errno::ECONNREFUSED => e
90
+ raise RpcError, "RPC connection failed: #{e.message}"
91
+ rescue JSON::ParserError
92
+ raw = Thread.current[:polarloop_last_raw_response]
93
+ raise RpcError, "RPC connection failed: #{raw || 'non-JSON response from node'}"
94
+ rescue NoMethodError => e
95
+ raise RpcError, "Unexpected node response in send_batch_tx: #{e.message}"
96
+ end
97
+
98
+ def latest_block_number
99
+ response = @eth_client.eth_block_number
100
+ response["result"].to_i(16)
101
+ rescue IOError, Errno::ECONNREFUSED => e
102
+ raise RpcError, "RPC connection failed: #{e.message}"
103
+ rescue JSON::ParserError
104
+ raw = Thread.current[:polarloop_last_raw_response]
105
+ raise RpcError, "RPC connection failed: #{raw || 'non-JSON response from node'}"
106
+ rescue NoMethodError => e
107
+ raise RpcError, "Unexpected node response in latest_block_number: #{e.message}"
108
+ end
109
+
110
+ def get_logs(topics:, from_block:, to_block:)
111
+ filter = {
112
+ address: @chain_config.contract_address,
113
+ topics: topics,
114
+ fromBlock: hex_block(from_block),
115
+ toBlock: hex_block(to_block)
116
+ }
117
+ response = @eth_client.eth_get_logs(filter)
118
+ response["result"] || []
119
+ rescue IOError, Errno::ECONNREFUSED => e
120
+ raise RpcError, "RPC connection failed: #{e.message}"
121
+ rescue JSON::ParserError
122
+ raw = Thread.current[:polarloop_last_raw_response]
123
+ raise RpcError, "RPC connection failed: #{raw || 'non-JSON response from node'}"
124
+ end
125
+
126
+ def get_transaction_receipt(tx_hash)
127
+ @eth_client.eth_get_transaction_receipt(tx_hash)
128
+ end
129
+
130
+ private
131
+
132
+ def raise_from_rpc_error(err)
133
+ if err.message.include?("revert")
134
+ revert_data = extract_revert_data(err)
135
+ decoded = Abi.decode_revert(revert_data) if revert_data
136
+ reason = decoded || err.message
137
+ raise ContractRevertError, reason
138
+ else
139
+ raise RpcError, err.message
140
+ end
141
+ end
142
+
143
+ def extract_revert_data(err)
144
+ # Some nodes put data in err.data, others embed it in the message
145
+ return err.data if err.data && err.data != "0x" && err.data.length > 2
146
+ if err.message =~ /0x[0-9a-fA-F]{8,}/
147
+ err.message[/0x[0-9a-fA-F]{8,}/]
148
+ end
149
+ end
150
+
151
+ def tx_opts
152
+ { sender_key: @key, legacy: @gas_strategy.legacy? }
153
+ end
154
+
155
+ def build_calldata(function_name, *args)
156
+ fun = @contract.function(function_name, args: args.size)
157
+ types = fun.inputs.map(&:type)
158
+ encoded_args = Eth::Abi.encode(types, args)
159
+ "0x" + fun.signature + encoded_args.unpack1("H*")
160
+ end
161
+
162
+ def call_params(function_name, *args)
163
+ {
164
+ from: @key.address.to_s,
165
+ to: @chain_config.contract_address,
166
+ data: build_calldata(function_name, *args)
167
+ }
168
+ end
169
+
170
+ def estimate_gas(function_name, *args)
171
+ params = call_params(function_name, *args)
172
+
173
+ # Dry-run via eth_call first — returns full revert data on failure
174
+ @eth_client.eth_call(params)
175
+
176
+ # If eth_call passed, get gas estimate
177
+ response = @eth_client.eth_estimate_gas(params)
178
+ gas = response["result"].to_i(16)
179
+ (gas * @chain_config.gas_multiplier).to_i
180
+ end
181
+
182
+ def build_tx_result(tx_hash)
183
+ receipt = @eth_client.eth_get_transaction_receipt(tx_hash)
184
+ receipt_data = receipt["result"] || {}
185
+
186
+ Types::TxResult.new(
187
+ tx_hash: tx_hash,
188
+ block_number: receipt_data["blockNumber"]&.to_i(16),
189
+ gas_used: receipt_data["gasUsed"]&.to_i(16),
190
+ status: receipt_data["status"] == "0x1"
191
+ )
192
+ rescue
193
+ Types::TxResult.new(tx_hash: tx_hash, status: true)
194
+ end
195
+
196
+ def hex_block(number)
197
+ return number if number.is_a?(String) && number.start_with?("0x")
198
+ return number if number.is_a?(String) && %w[latest earliest pending].include?(number)
199
+
200
+ "0x" + number.to_i.to_s(16)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolarLoop
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+ class ChainNotRegisteredError < Error; end
8
+ class WalletError < Error; end
9
+
10
+ class RpcError < Error
11
+ attr_reader :code, :data
12
+
13
+ def initialize(message, code: nil, data: nil)
14
+ @code = code
15
+ @data = data
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ class ContractRevertError < Error
21
+ attr_reader :reason
22
+
23
+ def initialize(reason)
24
+ @reason = reason
25
+ super("Contract reverted: #{reason}")
26
+ end
27
+ end
28
+
29
+ class TransactionFailedError < Error
30
+ attr_reader :tx_hash, :receipt
31
+
32
+ def initialize(message, tx_hash: nil, receipt: nil)
33
+ @tx_hash = tx_hash
34
+ @receipt = receipt
35
+ super(message)
36
+ end
37
+ end
38
+
39
+ class TransactionTimeoutError < Error; end
40
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolarLoop
4
+ class EventParser
5
+ def initialize(contract_caller)
6
+ @caller = contract_caller
7
+ end
8
+
9
+ def mandate_created_events(from_block:, to_block: "latest")
10
+ logs = @caller.get_logs(
11
+ topics: [Abi.topic_hex(Abi::MANDATE_CREATED_TOPIC)],
12
+ from_block: from_block,
13
+ to_block: to_block
14
+ )
15
+
16
+ logs.map { |log| parse_mandate_created(log) }
17
+ end
18
+
19
+ def mandate_charged_events(from_block:, to_block: "latest")
20
+ logs = @caller.get_logs(
21
+ topics: [Abi.topic_hex(Abi::MANDATE_CHARGED_TOPIC)],
22
+ from_block: from_block,
23
+ to_block: to_block
24
+ )
25
+
26
+ logs.map { |log| parse_mandate_charged(log) }
27
+ end
28
+
29
+ def mandate_revoked_events(from_block:, to_block: "latest")
30
+ logs = @caller.get_logs(
31
+ topics: [Abi.topic_hex(Abi::MANDATE_REVOKED_TOPIC)],
32
+ from_block: from_block,
33
+ to_block: to_block
34
+ )
35
+
36
+ logs.map { |log| parse_mandate_revoked(log) }
37
+ end
38
+
39
+ def events_for_tx(tx_hash)
40
+ receipt = @caller.get_transaction_receipt(tx_hash)
41
+ logs = receipt.dig("result", "logs") || []
42
+
43
+ created_topic = Abi.topic_hex(Abi::MANDATE_CREATED_TOPIC)
44
+ charged_topic = Abi.topic_hex(Abi::MANDATE_CHARGED_TOPIC)
45
+ revoked_topic = Abi.topic_hex(Abi::MANDATE_REVOKED_TOPIC)
46
+
47
+ logs.filter_map do |log|
48
+ topic0 = log["topics"]&.first
49
+ case topic0
50
+ when created_topic then parse_mandate_created(log)
51
+ when charged_topic then parse_mandate_charged(log)
52
+ when revoked_topic then parse_mandate_revoked(log)
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def parse_mandate_created(log)
60
+ topics = log["topics"]
61
+ data = decode_data(log["data"])
62
+
63
+ # Indexed: mandateId, payer, merchant
64
+ # Non-indexed data: token(address), amount(uint128), interval(uint32), maxTotalAmount(uint128), referenceId(bytes32)
65
+ decoded = Eth::Abi.decode(
66
+ ["address", "uint128", "uint32", "uint128", "bytes32"],
67
+ data
68
+ )
69
+
70
+ Types::MandateCreatedEvent.new(
71
+ mandate_id: topics[1],
72
+ payer: address_from_topic(topics[2]),
73
+ merchant: address_from_topic(topics[3]),
74
+ token: decoded[0],
75
+ amount: decoded[1].to_i,
76
+ interval: decoded[2].to_i,
77
+ max_total_amount: decoded[3].to_i,
78
+ reference_id: bytes32_to_hex(decoded[4]),
79
+ block_number: log["blockNumber"]&.to_i(16),
80
+ tx_hash: log["transactionHash"],
81
+ log_index: log["logIndex"]&.to_i(16)
82
+ )
83
+ end
84
+
85
+ def parse_mandate_charged(log)
86
+ topics = log["topics"]
87
+ data = decode_data(log["data"])
88
+
89
+ # Indexed: mandateId, payer, merchant
90
+ # Non-indexed data: amount(uint128), timestamp(uint48)
91
+ decoded = Eth::Abi.decode(["uint128", "uint48"], data)
92
+
93
+ Types::MandateChargedEvent.new(
94
+ mandate_id: topics[1],
95
+ payer: address_from_topic(topics[2]),
96
+ merchant: address_from_topic(topics[3]),
97
+ amount: decoded[0].to_i,
98
+ timestamp: decoded[1].to_i,
99
+ block_number: log["blockNumber"]&.to_i(16),
100
+ tx_hash: log["transactionHash"],
101
+ log_index: log["logIndex"]&.to_i(16)
102
+ )
103
+ end
104
+
105
+ def parse_mandate_revoked(log)
106
+ topics = log["topics"]
107
+
108
+ # Indexed: mandateId, payer
109
+ # No non-indexed data
110
+ Types::MandateRevokedEvent.new(
111
+ mandate_id: topics[1],
112
+ payer: address_from_topic(topics[2]),
113
+ block_number: log["blockNumber"]&.to_i(16),
114
+ tx_hash: log["transactionHash"],
115
+ log_index: log["logIndex"]&.to_i(16)
116
+ )
117
+ end
118
+
119
+ def address_from_topic(topic)
120
+ return nil unless topic
121
+
122
+ "0x" + topic[-40..]
123
+ end
124
+
125
+ def bytes32_to_hex(binary)
126
+ if binary.is_a?(String) && binary.start_with?("0x")
127
+ binary.downcase
128
+ elsif binary.is_a?(String)
129
+ "0x" + binary.unpack1("H*")
130
+ else
131
+ "0x" + binary.to_s(16).rjust(64, "0")
132
+ end
133
+ end
134
+
135
+ def decode_data(hex_data)
136
+ return "" if hex_data.nil? || hex_data == "0x"
137
+
138
+ [hex_data.sub(/\A0x/, "")].pack("H*")
139
+ end
140
+ end
141
+ end