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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +223 -0
- data/lib/polarloop/abi/polar_loop.json +972 -0
- data/lib/polarloop/abi.rb +91 -0
- data/lib/polarloop/client.rb +159 -0
- data/lib/polarloop/configuration.rb +43 -0
- data/lib/polarloop/contract_caller.rb +203 -0
- data/lib/polarloop/errors.rb +40 -0
- data/lib/polarloop/event_parser.rb +141 -0
- data/lib/polarloop/gas_strategy.rb +40 -0
- data/lib/polarloop/manager.rb +24 -0
- data/lib/polarloop/types/batch_result.rb +32 -0
- data/lib/polarloop/types/charge_ready_result.rb +7 -0
- data/lib/polarloop/types/event.rb +41 -0
- data/lib/polarloop/types/mandate.rb +50 -0
- data/lib/polarloop/types/tx_result.rb +14 -0
- data/lib/polarloop/version.rb +5 -0
- data/lib/polarloop/wallet.rb +41 -0
- data/lib/polarloop.rb +70 -0
- metadata +155 -0
|
@@ -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
|