dpay-ruby 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.
- checksums.yaml +7 -0
- data/.gitignore +54 -0
- data/CONTRIBUTING.md +79 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +73 -0
- data/LICENSE +22 -0
- data/README.md +250 -0
- data/Rakefile +332 -0
- data/dpay-ruby.gemspec +37 -0
- data/gource.sh +6 -0
- data/images/Anthony Martin.png +0 -0
- data/lib/dpay.rb +37 -0
- data/lib/dpay/api.rb +229 -0
- data/lib/dpay/base_error.rb +216 -0
- data/lib/dpay/block_api.rb +78 -0
- data/lib/dpay/broadcast.rb +1171 -0
- data/lib/dpay/chain_config.rb +21 -0
- data/lib/dpay/formatter.rb +14 -0
- data/lib/dpay/jsonrpc.rb +108 -0
- data/lib/dpay/mixins/retriable.rb +58 -0
- data/lib/dpay/rpc/base_client.rb +166 -0
- data/lib/dpay/rpc/http_client.rb +127 -0
- data/lib/dpay/rpc/thread_safe_http_client.rb +35 -0
- data/lib/dpay/stream.rb +377 -0
- data/lib/dpay/transaction_builder.rb +386 -0
- data/lib/dpay/type/amount.rb +101 -0
- data/lib/dpay/type/base_type.rb +10 -0
- data/lib/dpay/utils.rb +17 -0
- data/lib/dpay/version.rb +4 -0
- metadata +392 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module DPay
|
|
2
|
+
module RPC
|
|
3
|
+
# {ThreadSafeHttpClient} is the default RPC Client used by `dpay-ruby.`
|
|
4
|
+
# It's perfect for simple requests. But for higher performance, it's better
|
|
5
|
+
# to override {HttpClient} and implement something other than {Net::HTTP}.
|
|
6
|
+
#
|
|
7
|
+
# It performs http requests in a {Mutex} critical section because {Net::HTTP}
|
|
8
|
+
# is not thread safe. This is the very minimum level thread safety
|
|
9
|
+
# available.
|
|
10
|
+
class ThreadSafeHttpClient < HttpClient
|
|
11
|
+
SEMAPHORE = Mutex.new.freeze
|
|
12
|
+
|
|
13
|
+
# Same as #{HttpClient#http_post}, but scoped to each thread so it is
|
|
14
|
+
# thread safe.
|
|
15
|
+
def http_post
|
|
16
|
+
thread = Thread.current
|
|
17
|
+
http_post = thread.thread_variable_get(:http_post)
|
|
18
|
+
http_post ||= Net::HTTP::Post.new(uri.request_uri, POST_HEADERS)
|
|
19
|
+
thread.thread_variable_set(:http_post, http_post)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def http_request(request); SEMAPHORE.synchronize{super}; end
|
|
23
|
+
|
|
24
|
+
# Same as #{BaseClient#rpc_id}, auto-increment, but scoped to each thread
|
|
25
|
+
# so it is thread safe.
|
|
26
|
+
def rpc_id
|
|
27
|
+
thread = Thread.current
|
|
28
|
+
rpc_id = thread.thread_variable_get(:rpc_id)
|
|
29
|
+
rpc_id ||= 0
|
|
30
|
+
rpc_id += 1
|
|
31
|
+
thread.thread_variable_set(:rpc_id, rpc_id)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/dpay/stream.rb
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
module DPay
|
|
2
|
+
# DPay::Stream allows a live view of the dPay blockchain.
|
|
3
|
+
#
|
|
4
|
+
# Example streaming blocks:
|
|
5
|
+
#
|
|
6
|
+
# stream = DPay::Stream.new
|
|
7
|
+
#
|
|
8
|
+
# stream.blocks do |block, block_num|
|
|
9
|
+
# puts "#{block_num} :: #{block.witness}"
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Example streaming transactions:
|
|
13
|
+
#
|
|
14
|
+
# stream = DPay::Stream.new
|
|
15
|
+
#
|
|
16
|
+
# stream.transactions do |trx, trx_id, block_num|
|
|
17
|
+
# puts "#{block_num} :: #{trx_id} :: operations: #{trx.operations.size}"
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Example streaming operations:
|
|
21
|
+
#
|
|
22
|
+
# stream = DPay::Stream.new
|
|
23
|
+
#
|
|
24
|
+
# stream.operations do |op, trx_id, block_num|
|
|
25
|
+
# puts "#{block_num} :: #{trx_id} :: #{op.type}: #{op.value.to_json}"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Allows streaming of block headers, full blocks, transactions, operations and
|
|
29
|
+
# virtual operations.
|
|
30
|
+
class Stream
|
|
31
|
+
attr_reader :database_api, :block_api, :account_history_api, :mode
|
|
32
|
+
|
|
33
|
+
BLOCK_INTERVAL = 3
|
|
34
|
+
MAX_BACKOFF_BLOCK_INTERVAL = 30
|
|
35
|
+
MAX_RETRY_COUNT = 10
|
|
36
|
+
|
|
37
|
+
VOP_TRX_ID = ('0' * 40).freeze
|
|
38
|
+
|
|
39
|
+
# @param options [Hash] additional options
|
|
40
|
+
# @option options [DPay::DatabaseApi] :database_api
|
|
41
|
+
# @option options [DPay::BlockApi] :block_api
|
|
42
|
+
# @option options [DPay::AccountHistoryApi || DPay::CondenserApi] :account_history_api
|
|
43
|
+
# @option options [Symbol] :mode we have the choice between
|
|
44
|
+
# * :head the last block
|
|
45
|
+
# * :irreversible the block that is confirmed by 2/3 of all block producers and is thus irreversible!
|
|
46
|
+
# @option options [Boolean] :no_warn do not generate warnings
|
|
47
|
+
def initialize(options = {mode: :irreversible})
|
|
48
|
+
@instance_options = options
|
|
49
|
+
@database_api = options[:database_api] || DPay::DatabaseApi.new(options)
|
|
50
|
+
@block_api = options[:block_api] || DPay::BlockApi.new(options)
|
|
51
|
+
@account_history_api = options[:account_history_api]
|
|
52
|
+
@mode = options[:mode] || :irreversible
|
|
53
|
+
@no_warn = !!options[:no_warn]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Use this method to stream block numbers. This is significantly faster
|
|
57
|
+
# than requesting full blocks and even block headers. Basically, the only
|
|
58
|
+
# thing this method does is call {DPay::Database#get_dynamic_global_properties} at 3 second
|
|
59
|
+
# intervals.
|
|
60
|
+
#
|
|
61
|
+
# @param options [Hash] additional options
|
|
62
|
+
# @option options [Integer] :at_block_num Starts the stream at the given block number. Default: nil.
|
|
63
|
+
# @option options [Integer] :until_block_num Ends the stream at the given block number. Default: nil.
|
|
64
|
+
def block_numbers(options = {}, &block)
|
|
65
|
+
block_objects(options.merge(object: :block_numbers), block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Use this method to stream block headers. This is quite a bit faster than
|
|
69
|
+
# requesting full blocks.
|
|
70
|
+
#
|
|
71
|
+
# @param options [Hash] additional options
|
|
72
|
+
# @option options [Integer] :at_block_num Starts the stream at the given block number. Default: nil.
|
|
73
|
+
# @option options [Integer] :until_block_num Ends the stream at the given block number. Default: nil.
|
|
74
|
+
def block_headers(options = {}, &block)
|
|
75
|
+
block_objects(options.merge(object: :block_headers), block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Use this method to stream full blocks.
|
|
79
|
+
#
|
|
80
|
+
# @param options [Hash] additional options
|
|
81
|
+
# @option options [Integer] :at_block_num Starts the stream at the given block number. Default: nil.
|
|
82
|
+
# @option options [Integer] :until_block_num Ends the stream at the given block number. Default: nil.
|
|
83
|
+
def blocks(options = {}, &block)
|
|
84
|
+
block_objects(options.merge(object: :blocks), block)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Use this method to stream each transaction.
|
|
88
|
+
#
|
|
89
|
+
# @param options [Hash] additional options
|
|
90
|
+
# @option options [Integer] :at_block_num Starts the stream at the given block number. Default: nil.
|
|
91
|
+
# @option options [Integer] :until_block_num Ends the stream at the given block number. Default: nil.
|
|
92
|
+
def transactions(options = {}, &block)
|
|
93
|
+
blocks(options) do |block, block_num|
|
|
94
|
+
block.transactions.each_with_index do |transaction, index|
|
|
95
|
+
trx_id = block.transaction_ids[index]
|
|
96
|
+
|
|
97
|
+
yield transaction, trx_id, block_num
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the latest operations from the blockchain.
|
|
103
|
+
#
|
|
104
|
+
# stream = DPay::Stream.new
|
|
105
|
+
# stream.operations do |op|
|
|
106
|
+
# puts op.to_json
|
|
107
|
+
# end
|
|
108
|
+
#
|
|
109
|
+
# If symbol are passed to `types` option, then only that operation is
|
|
110
|
+
# returned. Expected symbols are:
|
|
111
|
+
#
|
|
112
|
+
# account_create_operation
|
|
113
|
+
# account_create_with_delegation_operation
|
|
114
|
+
# account_update_operation
|
|
115
|
+
# account_witness_proxy_operation
|
|
116
|
+
# account_witness_vote_operation
|
|
117
|
+
# cancel_transfer_from_savings_operation
|
|
118
|
+
# change_recovery_account_operation
|
|
119
|
+
# claim_reward_balance_operation
|
|
120
|
+
# comment_operation
|
|
121
|
+
# comment_options_operation
|
|
122
|
+
# convert_operation
|
|
123
|
+
# custom_operation
|
|
124
|
+
# custom_json_operation
|
|
125
|
+
# decline_voting_rights_operation
|
|
126
|
+
# delegate_vesting_shares_operation
|
|
127
|
+
# delete_comment_operation
|
|
128
|
+
# escrow_approve_operation
|
|
129
|
+
# escrow_dispute_operation
|
|
130
|
+
# escrow_release_operation
|
|
131
|
+
# escrow_transfer_operation
|
|
132
|
+
# feed_publish_operation
|
|
133
|
+
# limit_order_cancel_operation
|
|
134
|
+
# limit_order_create_operation
|
|
135
|
+
# limit_order_create2_operation
|
|
136
|
+
# pow_operation
|
|
137
|
+
# pow2_operation
|
|
138
|
+
# recover_account_operation
|
|
139
|
+
# request_account_recovery_operation
|
|
140
|
+
# set_withdraw_vesting_route_operation
|
|
141
|
+
# transfer_operation
|
|
142
|
+
# transfer_from_savings_operation
|
|
143
|
+
# transfer_to_savings_operation
|
|
144
|
+
# transfer_to_vesting_operation
|
|
145
|
+
# vote_operation
|
|
146
|
+
# withdraw_vesting_operation
|
|
147
|
+
# witness_update_operation
|
|
148
|
+
#
|
|
149
|
+
# For example, to stream only votes:
|
|
150
|
+
#
|
|
151
|
+
# stream = DPay::Stream.new
|
|
152
|
+
# stream.operations(types: :vote_operation) do |vote|
|
|
153
|
+
# puts vote.to_json
|
|
154
|
+
# end
|
|
155
|
+
#
|
|
156
|
+
# ... Or ...
|
|
157
|
+
#
|
|
158
|
+
# stream = DPay::Stream.new
|
|
159
|
+
# stream.operations(:vote_operation) do |vote|
|
|
160
|
+
# puts vote.to_json
|
|
161
|
+
# end
|
|
162
|
+
#
|
|
163
|
+
# You can also stream virtual operations:
|
|
164
|
+
#
|
|
165
|
+
# stream = DPay::Stream.new
|
|
166
|
+
# stream.operations(types: :author_reward_operation, only_virtual: true) do |vop|
|
|
167
|
+
# v = vop.value
|
|
168
|
+
# puts "#{v.author} got paid for #{v.permlink}: #{[v.bbd_payout, v.dpay_payout, v.vesting_payout]}"
|
|
169
|
+
# end
|
|
170
|
+
#
|
|
171
|
+
# ... or multiple virtual operation types;
|
|
172
|
+
#
|
|
173
|
+
# stream = DPay::Stream.new
|
|
174
|
+
# stream.operations(types: [:producer_reward_operation, :author_reward_operation], only_virtual: true) do |vop|
|
|
175
|
+
# puts vop.to_json
|
|
176
|
+
# end
|
|
177
|
+
#
|
|
178
|
+
# ... or all types, including virtual operation types from the head block number:
|
|
179
|
+
#
|
|
180
|
+
# stream = DPay::Stream.new(mode: :head)
|
|
181
|
+
# stream.operations(include_virtual: true) do |op|
|
|
182
|
+
# puts op.to_json
|
|
183
|
+
# end
|
|
184
|
+
#
|
|
185
|
+
# Expected virtual operation types:
|
|
186
|
+
#
|
|
187
|
+
# producer_reward_operation
|
|
188
|
+
# author_reward_operation
|
|
189
|
+
# curation_reward_operation
|
|
190
|
+
# fill_convert_request_operation
|
|
191
|
+
# fill_order_operation
|
|
192
|
+
# fill_vesting_withdraw_operation
|
|
193
|
+
# interest_operation
|
|
194
|
+
# shutdown_witness_operation
|
|
195
|
+
#
|
|
196
|
+
# @param args [Symbol || Array<Symbol> || Hash] the type(s) of operation or hash of expanded options, optional.
|
|
197
|
+
# @option args [Integer] :at_block_num Starts the stream at the given block number. Default: nil.
|
|
198
|
+
# @option args [Integer] :until_block_num Ends the stream at the given block number. Default: nil.
|
|
199
|
+
# @option args [Symbol || Array<Symbol>] :types the type(s) of operation, optional.
|
|
200
|
+
# @option args [Boolean] :only_virtual Only stream virtual options. Setting this true will improve performance because the stream only needs block numbers to then retrieve virtual operations. Default: false.
|
|
201
|
+
# @option args [Boolean] :include_virtual Also stream virtual options. Setting this true will impact performance. Default: false.
|
|
202
|
+
# @param block the block to execute for each result. Yields: |op, trx_id, block_num|
|
|
203
|
+
def operations(*args, &block)
|
|
204
|
+
options = {}
|
|
205
|
+
types = []
|
|
206
|
+
only_virtual = false
|
|
207
|
+
include_virtual = false
|
|
208
|
+
last_block_num = nil
|
|
209
|
+
|
|
210
|
+
case args.first
|
|
211
|
+
when Hash
|
|
212
|
+
options = args.first
|
|
213
|
+
types = transform_types(options[:types])
|
|
214
|
+
only_virtual = !!options[:only_virtual] || false
|
|
215
|
+
include_virtual = !!options[:include_virtual] || only_virtual || false
|
|
216
|
+
when Symbol, Array then types = transform_types(args)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
if only_virtual
|
|
220
|
+
block_numbers(options) do |block_num|
|
|
221
|
+
get_virtual_ops(types, block_num, block)
|
|
222
|
+
end
|
|
223
|
+
else
|
|
224
|
+
transactions(options) do |transaction, trx_id, block_num|
|
|
225
|
+
transaction.operations.each do |op|
|
|
226
|
+
yield op, trx_id, block_num if types.none? || types.include?(op.type)
|
|
227
|
+
|
|
228
|
+
next unless last_block_num != block_num
|
|
229
|
+
|
|
230
|
+
last_block_num = block_num
|
|
231
|
+
|
|
232
|
+
get_virtual_ops(types, block_num, block) if include_virtual
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def account_history_api
|
|
239
|
+
@account_history_api ||= begin
|
|
240
|
+
DPay::AccountHistoryApi.new(@instance_options)
|
|
241
|
+
rescue DPay::UnknownApiError => e
|
|
242
|
+
warn "#{e.inspect}, falling back to DPay::CondenserApi." unless @no_warn
|
|
243
|
+
DPay::CondenserApi.new(@instance_options)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
private
|
|
247
|
+
# @private
|
|
248
|
+
def block_objects(options = {}, block)
|
|
249
|
+
object = options[:object]
|
|
250
|
+
object_method = "get_#{object}".to_sym
|
|
251
|
+
block_interval = BLOCK_INTERVAL
|
|
252
|
+
|
|
253
|
+
at_block_num, until_block_num = if !!block_range = options[:block_range]
|
|
254
|
+
[block_range.first, block_range.last]
|
|
255
|
+
else
|
|
256
|
+
[options[:at_block_num], options[:until_block_num]]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
loop do
|
|
260
|
+
break if !!until_block_num && !!at_block_num && until_block_num < at_block_num
|
|
261
|
+
|
|
262
|
+
database_api.get_dynamic_global_properties do |properties|
|
|
263
|
+
current_block_num = find_block_number(properties)
|
|
264
|
+
current_block_num = [current_block_num, until_block_num].compact.min
|
|
265
|
+
at_block_num ||= current_block_num
|
|
266
|
+
|
|
267
|
+
if current_block_num >= at_block_num
|
|
268
|
+
range = at_block_num..current_block_num
|
|
269
|
+
|
|
270
|
+
if object == :block_numbers
|
|
271
|
+
range.each do |n|
|
|
272
|
+
block.call n
|
|
273
|
+
block_interval = BLOCK_INTERVAL
|
|
274
|
+
end
|
|
275
|
+
else
|
|
276
|
+
block_api.send(object_method, block_range: range) do |b, n|
|
|
277
|
+
block.call b, n
|
|
278
|
+
block_interval = BLOCK_INTERVAL
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
at_block_num = range.max + 1
|
|
283
|
+
else
|
|
284
|
+
# The stream has stalled, so let's back off and let the node sync
|
|
285
|
+
# up. We'll catch up with a bigger batch in the next cycle.
|
|
286
|
+
block_interval = [block_interval * 2, MAX_BACKOFF_BLOCK_INTERVAL].min
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
sleep block_interval
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @private
|
|
295
|
+
def find_block_number(properties)
|
|
296
|
+
block_num = case mode
|
|
297
|
+
when :head then properties.head_block_number
|
|
298
|
+
when :irreversible then properties.last_irreversible_block_num
|
|
299
|
+
else; raise DPay::ArgumentError, "Unknown mode: #{mode}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
block_num
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @private
|
|
306
|
+
def transform_types(types)
|
|
307
|
+
[types].compact.flatten.map do |type|
|
|
308
|
+
type = type.to_s
|
|
309
|
+
|
|
310
|
+
unless type.end_with? '_operation'
|
|
311
|
+
warn "Op type #{type} is deprecated. Use #{type}_operation instead." unless @no_warn
|
|
312
|
+
type += '_operation'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
type
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @private
|
|
320
|
+
def get_virtual_ops(types, block_num, block)
|
|
321
|
+
retries = 0
|
|
322
|
+
|
|
323
|
+
loop do
|
|
324
|
+
get_ops_in_block_options = case account_history_api
|
|
325
|
+
when DPay::CondenserApi
|
|
326
|
+
[block_num, true]
|
|
327
|
+
when DPay::AccountHistoryApi
|
|
328
|
+
{
|
|
329
|
+
block_num: block_num,
|
|
330
|
+
only_virtual: true
|
|
331
|
+
}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
response = account_history_api.get_ops_in_block(*get_ops_in_block_options)
|
|
335
|
+
result = response.result
|
|
336
|
+
|
|
337
|
+
if result.nil?
|
|
338
|
+
if retries < MAX_RETRY_COUNT
|
|
339
|
+
warn "Retrying get_ops_in_block on block #{block_num}" unless @no_warn
|
|
340
|
+
retries = retries + 1
|
|
341
|
+
sleep 9
|
|
342
|
+
redo
|
|
343
|
+
else
|
|
344
|
+
raise TooManyRetriesError, "unable to get valid result while finding virtual operations for block: #{block_num}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
ops = case account_history_api
|
|
349
|
+
when DPay::CondenserApi
|
|
350
|
+
result.map do |trx|
|
|
351
|
+
op = {type: trx.op[0] + '_operation', value: trx.op[1]}
|
|
352
|
+
op = Hashie::Mash.new(op)
|
|
353
|
+
end
|
|
354
|
+
when DPay::AccountHistoryApi then result.ops.map { |trx| trx.op }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if ops.empty?
|
|
358
|
+
if retries < MAX_RETRY_COUNT
|
|
359
|
+
sleep 3
|
|
360
|
+
retries = retries + 1
|
|
361
|
+
redo
|
|
362
|
+
else
|
|
363
|
+
raise TooManyRetriesError, "unable to find virtual operations for block: #{block_num}"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
ops.each do |op|
|
|
368
|
+
next if types.any? && !types.include?(op.type)
|
|
369
|
+
|
|
370
|
+
block.call op, VOP_TRX_ID, block_num
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
break
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
module DPay
|
|
2
|
+
# {TransactionBuilder} can be used to create a transaction that the
|
|
3
|
+
# {NetworkBroadcastApi} can broadcast to the rest of the platform. The main
|
|
4
|
+
# feature of this class is the ability to cryptographically sign the
|
|
5
|
+
# transaction so that it conforms to the consensus rules that are required by
|
|
6
|
+
# the blockchain.
|
|
7
|
+
#
|
|
8
|
+
# wif = '5JrvPrQeBBvCRdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC'
|
|
9
|
+
# builder = DPay::TransactionBuilder.new(wif: wif)
|
|
10
|
+
# builder.put(vote: {
|
|
11
|
+
# voter: 'alice',
|
|
12
|
+
# author: 'bob',
|
|
13
|
+
# permlink: 'my-burgers',
|
|
14
|
+
# weight: 10000
|
|
15
|
+
# })
|
|
16
|
+
#
|
|
17
|
+
# trx = builder.transaction
|
|
18
|
+
# network_broadcast_api = DPay::CondenserApi.new
|
|
19
|
+
# network_broadcast_api.broadcast_transaction_synchronous(trx: trx)
|
|
20
|
+
#
|
|
21
|
+
#
|
|
22
|
+
# The `wif` value may also be an array, when signing with multiple signatures
|
|
23
|
+
# (multisig).
|
|
24
|
+
class TransactionBuilder
|
|
25
|
+
include Retriable
|
|
26
|
+
include ChainConfig
|
|
27
|
+
include Utils
|
|
28
|
+
|
|
29
|
+
attr_accessor :app_base, :database_api, :block_api, :expiration, :operations
|
|
30
|
+
attr_writer :wif
|
|
31
|
+
attr_reader :signed
|
|
32
|
+
|
|
33
|
+
alias app_base? app_base
|
|
34
|
+
|
|
35
|
+
def initialize(options = {})
|
|
36
|
+
@app_base = !!options[:app_base] # default false
|
|
37
|
+
@database_api = options[:database_api]
|
|
38
|
+
@block_api = options[:block_api]
|
|
39
|
+
|
|
40
|
+
if app_base?
|
|
41
|
+
@database_api ||= DPay::DatabaseApi.new(options)
|
|
42
|
+
@block_api ||= DPay::BlockApi.new(options)
|
|
43
|
+
else
|
|
44
|
+
@database_api ||= DPay::CondenserApi.new(options)
|
|
45
|
+
@block_api ||= DPay::CondenserApi.new(options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@wif = [options[:wif]].flatten
|
|
49
|
+
@signed = false
|
|
50
|
+
|
|
51
|
+
if !!(trx = options[:trx])
|
|
52
|
+
trx = case trx
|
|
53
|
+
when String then JSON[trx]
|
|
54
|
+
else; trx
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
trx_options = {
|
|
58
|
+
ref_block_num: trx['ref_block_num'],
|
|
59
|
+
ref_block_prefix: trx['ref_block_prefix'],
|
|
60
|
+
extensions: (trx['extensions']),
|
|
61
|
+
operations: trx['operations'],
|
|
62
|
+
signatures: (trx['signatures']),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
trx_options[:expiration] = case trx['expiration']
|
|
66
|
+
when String then Time.parse(trx['expiration'] + 'Z')
|
|
67
|
+
else; trx['expiration']
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
options = options.merge(trx_options)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@ref_block_num = options[:ref_block_num]
|
|
74
|
+
@ref_block_prefix = options[:ref_block_prefix]
|
|
75
|
+
@operations = options[:operations] || []
|
|
76
|
+
@expiration = options[:expiration]
|
|
77
|
+
@extensions = options[:extensions] || []
|
|
78
|
+
@signatures = options[:signatures] || []
|
|
79
|
+
@chain = options[:chain] || :dpay
|
|
80
|
+
@error_pipe = options[:error_pipe] || STDERR
|
|
81
|
+
@chain_id = case @chain
|
|
82
|
+
when :dpay then NETWORKS_DPAY_CHAIN_ID
|
|
83
|
+
when :test then NETWORKS_TEST_CHAIN_ID
|
|
84
|
+
else; raise UnsupportedChainError, "Unsupported chain: #{@chain}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inspect
|
|
89
|
+
properties = %w(
|
|
90
|
+
ref_block_num ref_block_prefix expiration operations extensions
|
|
91
|
+
signatures
|
|
92
|
+
).map do |prop|
|
|
93
|
+
if !!(v = instance_variable_get("@#{prop}"))
|
|
94
|
+
"@#{prop}=#{v}"
|
|
95
|
+
end
|
|
96
|
+
end.compact.join(', ')
|
|
97
|
+
|
|
98
|
+
"#<#{self.class.name} [#{properties}]>"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reset
|
|
102
|
+
@ref_block_num = nil
|
|
103
|
+
@ref_block_prefix = nil
|
|
104
|
+
@expiration = nil
|
|
105
|
+
@operations = []
|
|
106
|
+
@extensions = []
|
|
107
|
+
@signatures = []
|
|
108
|
+
@signed = false
|
|
109
|
+
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def expired?
|
|
114
|
+
@expiration.nil? || @expiration < Time.now
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# If the transaction can be prepared, this method will do so and set the
|
|
118
|
+
# expiration. Once the expiration is set, it will not re-prepare. If you
|
|
119
|
+
# call {#put}, the expiration is set {::Nil} so that it can be re-prepared.
|
|
120
|
+
#
|
|
121
|
+
# Usually, this method is called automatically by {#put} and/or {#transaction}.
|
|
122
|
+
#
|
|
123
|
+
# @return {TransactionBuilder}
|
|
124
|
+
def prepare
|
|
125
|
+
if expired?
|
|
126
|
+
catch :prepare_header do; begin
|
|
127
|
+
@database_api.get_dynamic_global_properties do |properties|
|
|
128
|
+
block_number = properties.last_irreversible_block_num
|
|
129
|
+
block_header_args = if app_base?
|
|
130
|
+
{block_num: block_number}
|
|
131
|
+
else
|
|
132
|
+
block_number
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@block_api.get_block_header(block_header_args) do |result|
|
|
136
|
+
header = if app_base?
|
|
137
|
+
result.header
|
|
138
|
+
else
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@ref_block_num = (block_number - 1) & 0xFFFF
|
|
143
|
+
@ref_block_prefix = unhexlify(header.previous[8..-1]).unpack('V*')[0]
|
|
144
|
+
@expiration ||= (Time.parse(properties.time + 'Z') + EXPIRE_IN_SECS).utc
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
rescue => e
|
|
148
|
+
if can_retry? e
|
|
149
|
+
@error_pipe.puts "#{e} ... retrying."
|
|
150
|
+
throw :prepare_header
|
|
151
|
+
else
|
|
152
|
+
raise e
|
|
153
|
+
end
|
|
154
|
+
end; end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Sets operations all at once, then prepares.
|
|
161
|
+
def operations=(operations)
|
|
162
|
+
@operations = operations.map{ |op| normalize_operation(op) }
|
|
163
|
+
prepare
|
|
164
|
+
@operations
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A quick and flexible way to append a new operation to the transaction.
|
|
168
|
+
# This method uses ducktyping to figure out how to form the operation.
|
|
169
|
+
#
|
|
170
|
+
# There are three main ways you can call this method. These assume that
|
|
171
|
+
# `op_type` is a {::Symbol} (or {::String}) representing the type of operation and `op` is the
|
|
172
|
+
# operation {::Hash}.
|
|
173
|
+
#
|
|
174
|
+
# put(op_type, op)
|
|
175
|
+
#
|
|
176
|
+
# ... or ...
|
|
177
|
+
#
|
|
178
|
+
# put(op_type => op)
|
|
179
|
+
#
|
|
180
|
+
# ... or ...
|
|
181
|
+
#
|
|
182
|
+
# put([op_type, op])
|
|
183
|
+
#
|
|
184
|
+
# You can also chain multiple operations:
|
|
185
|
+
#
|
|
186
|
+
# builder = DPay::TransactionBuilder.new
|
|
187
|
+
# builder.put(vote: vote1).put(vote: vote2)
|
|
188
|
+
# @return {TransactionBuilder}
|
|
189
|
+
def put(type, op = nil)
|
|
190
|
+
@expiration = nil
|
|
191
|
+
@operations << normalize_operation(type, op)
|
|
192
|
+
prepare
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# If all of the required values are set, this returns a fully formed
|
|
197
|
+
# transaction that is ready to broadcast.
|
|
198
|
+
#
|
|
199
|
+
# @return
|
|
200
|
+
# {
|
|
201
|
+
# :ref_block_num => 18912,
|
|
202
|
+
# :ref_block_prefix => 575781536,
|
|
203
|
+
# :expiration => "2018-04-26T15:26:12",
|
|
204
|
+
# :extensions => [],
|
|
205
|
+
# :operations => [[:vote, {
|
|
206
|
+
# :voter => "alice",
|
|
207
|
+
# :author => "bob",
|
|
208
|
+
# :permlink => "my-burgers",
|
|
209
|
+
# :weight => 10000
|
|
210
|
+
# }
|
|
211
|
+
# ]],
|
|
212
|
+
# :signatures => ["1c45b65740b4b2c17c4bcf6bcc3f8d90ddab827d50532729fc3b8f163f2c465a532b0112ae4bf388ccc97b7c2e0bc570caadda78af48cf3c261037e65eefcd941e"]
|
|
213
|
+
# }
|
|
214
|
+
def transaction
|
|
215
|
+
prepare
|
|
216
|
+
sign
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Appends to the `signatures` array of the transaction, built from a
|
|
220
|
+
# serialized digest.
|
|
221
|
+
#
|
|
222
|
+
# @return {Hash | TransactionBuilder} The fully signed transaction if a `wif` is provided or the instance of the {TransactionBuilder} if a `wif` has not yet been provided.
|
|
223
|
+
def sign
|
|
224
|
+
return self if @wif.empty?
|
|
225
|
+
return self if expired?
|
|
226
|
+
|
|
227
|
+
trx = {
|
|
228
|
+
ref_block_num: @ref_block_num,
|
|
229
|
+
ref_block_prefix: @ref_block_prefix,
|
|
230
|
+
expiration: @expiration.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
231
|
+
operations: @operations,
|
|
232
|
+
extensions: @extensions,
|
|
233
|
+
signatures: @signatures
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
unless @signed
|
|
237
|
+
catch :serialize do; begin
|
|
238
|
+
transaction_hex_args = if app_base?
|
|
239
|
+
{trx: trx}
|
|
240
|
+
else
|
|
241
|
+
trx
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@database_api.get_transaction_hex(transaction_hex_args) do |result|
|
|
245
|
+
hex = if app_base?
|
|
246
|
+
result.hex
|
|
247
|
+
else
|
|
248
|
+
result
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
hex = @chain_id + hex[0..-4] # Why do we have to chop the last two bytes?
|
|
252
|
+
digest = unhexlify(hex)
|
|
253
|
+
digest_hex = Digest::SHA256.digest(digest)
|
|
254
|
+
private_keys = @wif.map{ |wif| Bitcoin::Key.from_base58 wif }
|
|
255
|
+
ec = Bitcoin::OpenSSL_EC
|
|
256
|
+
count = 0
|
|
257
|
+
sigs = []
|
|
258
|
+
|
|
259
|
+
private_keys.each do |private_key|
|
|
260
|
+
sig = nil
|
|
261
|
+
|
|
262
|
+
loop do
|
|
263
|
+
count += 1
|
|
264
|
+
@error_pipe.puts "#{count} attempts to find canonical signature" if count % 40 == 0
|
|
265
|
+
public_key_hex = private_key.pub
|
|
266
|
+
sig = ec.sign_compact(digest_hex, private_key.priv, public_key_hex, false)
|
|
267
|
+
|
|
268
|
+
next if public_key_hex != ec.recover_compact(digest_hex, sig)
|
|
269
|
+
break if canonical? sig
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
@signatures << hexlify(sig)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
@signed = true
|
|
276
|
+
trx[:signatures] = @signatures
|
|
277
|
+
end
|
|
278
|
+
rescue => e
|
|
279
|
+
if can_retry? e
|
|
280
|
+
@error_pipe.puts "#{e} ... retrying."
|
|
281
|
+
throw :serialize
|
|
282
|
+
else
|
|
283
|
+
raise e
|
|
284
|
+
end
|
|
285
|
+
end; end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
Hashie::Mash.new trx
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @return [Array] All public keys that could possibly sign for a given transaction.
|
|
292
|
+
def potential_signatures
|
|
293
|
+
potential_signatures_args = if app_base?
|
|
294
|
+
{trx: transaction}
|
|
295
|
+
else
|
|
296
|
+
transaction
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
@database_api.get_potential_signatures(potential_signatures_args) do |result|
|
|
300
|
+
if app_base?
|
|
301
|
+
result[:keys]
|
|
302
|
+
else
|
|
303
|
+
result
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# This API will take a partially signed transaction and a set of public keys
|
|
309
|
+
# that the owner has the ability to sign for and return the minimal subset
|
|
310
|
+
# of public keys that should add signatures to the transaction.
|
|
311
|
+
#
|
|
312
|
+
# @return [Array] The minimal subset of public keys that should add signatures to the transaction.
|
|
313
|
+
def required_signatures
|
|
314
|
+
required_signatures_args = if app_base?
|
|
315
|
+
{trx: transaction}
|
|
316
|
+
else
|
|
317
|
+
[transaction, []]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
@database_api.get_required_signatures(*required_signatures_args) do |result|
|
|
321
|
+
if app_base?
|
|
322
|
+
result[:keys]
|
|
323
|
+
else
|
|
324
|
+
result
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# @return [Boolean] True if the transaction has all of the required signatures.
|
|
330
|
+
def valid?
|
|
331
|
+
verify_authority_args = if app_base?
|
|
332
|
+
{trx: transaction}
|
|
333
|
+
else
|
|
334
|
+
transaction
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
@database_api.verify_authority(verify_authority_args) do |result|
|
|
338
|
+
if app_base?
|
|
339
|
+
result.valid
|
|
340
|
+
else
|
|
341
|
+
result
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
private
|
|
346
|
+
# See: https://github.com/dpays/dpay/pull/2500
|
|
347
|
+
# @private
|
|
348
|
+
def canonical?(sig)
|
|
349
|
+
sig = sig.unpack('C*')
|
|
350
|
+
|
|
351
|
+
!(
|
|
352
|
+
((sig[0] & 0x80 ) != 0) || ( sig[0] == 0 ) ||
|
|
353
|
+
((sig[1] & 0x80 ) != 0) ||
|
|
354
|
+
((sig[32] & 0x80 ) != 0) || ( sig[32] == 0 ) ||
|
|
355
|
+
((sig[33] & 0x80 ) != 0)
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def normalize_operation(type, op = nil)
|
|
360
|
+
if app_base?
|
|
361
|
+
case type
|
|
362
|
+
when Symbol, String
|
|
363
|
+
type_value = "#{type}_operation"
|
|
364
|
+
{type: type_value, value: op}
|
|
365
|
+
when Hash
|
|
366
|
+
type_value = "#{type.keys.first}_operation"
|
|
367
|
+
{type: type_value, value: type.values.first}
|
|
368
|
+
when Array
|
|
369
|
+
type_value = "#{type[0]}_operation"
|
|
370
|
+
{type: type_value, value: type[1]}
|
|
371
|
+
else
|
|
372
|
+
raise DPay::ArgumentError, "Don't know what to do with operation type #{type.class}: #{type} (#{op})"
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
case type
|
|
376
|
+
when Symbol then [type, op]
|
|
377
|
+
when String then [type.to_sym, op]
|
|
378
|
+
when Hash then [type.keys.first.to_sym, type.values.first]
|
|
379
|
+
when Array then type
|
|
380
|
+
else
|
|
381
|
+
raise DPay::ArgumentError, "Don't know what to do with operation type #{type.class}: #{type} (#{op})"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|