sibit 0.33.0 → 0.34.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 +4 -4
- data/Gemfile +4 -5
- data/Gemfile.lock +78 -58
- data/README.md +21 -2
- data/Rakefile +1 -1
- data/bin/sibit +6 -4
- data/cross/alpine.df +6 -0
- data/cross/centos.df +8 -0
- data/cross/debian.df +9 -0
- data/cross/fedora.df +7 -0
- data/cross/opensuse.df +17 -0
- data/cross/rocky.df +8 -0
- data/cross/ubuntu.df +9 -0
- data/features/step_definitions/steps.rb +5 -5
- data/features/support/env.rb +1 -1
- data/lib/sibit/base58.rb +3 -6
- data/lib/sibit/bech32.rb +23 -15
- data/lib/sibit/bestof.rb +41 -11
- data/lib/sibit/bin.rb +68 -54
- data/lib/sibit/bitcoinchain.rb +10 -10
- data/lib/sibit/blockchain.rb +10 -20
- data/lib/sibit/blockchair.rb +14 -24
- data/lib/sibit/btc.rb +51 -60
- data/lib/sibit/cex.rb +12 -11
- data/lib/sibit/coins.rb +35 -0
- data/lib/sibit/cryptoapis.rb +35 -30
- data/lib/sibit/firstof.rb +8 -5
- data/lib/sibit/httpproxy.rb +1 -1
- data/lib/sibit/json.rb +8 -9
- data/lib/sibit/key.rb +39 -41
- data/lib/sibit/script.rb +3 -6
- data/lib/sibit/sochain.rb +180 -0
- data/lib/sibit/tx.rb +75 -36
- data/lib/sibit/txbuilder.rb +15 -3
- data/lib/sibit/version.rb +1 -2
- data/lib/sibit.rb +80 -62
- data/sibit.gemspec +11 -11
- metadata +10 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
|
|
4
|
+
# SPDX-License-Identifier: MIT
|
|
5
|
+
|
|
6
|
+
require 'iri'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'loog'
|
|
9
|
+
require 'uri'
|
|
10
|
+
require_relative 'error'
|
|
11
|
+
require_relative 'http'
|
|
12
|
+
require_relative 'json'
|
|
13
|
+
require_relative 'version'
|
|
14
|
+
|
|
15
|
+
# SoChain API (formerly known as Block.io).
|
|
16
|
+
#
|
|
17
|
+
# Documentation: https://sochain.com/api
|
|
18
|
+
#
|
|
19
|
+
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
|
20
|
+
# Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
|
|
21
|
+
# License:: MIT
|
|
22
|
+
class Sibit::Sochain
|
|
23
|
+
# Constructor.
|
|
24
|
+
def initialize(log: Loog::NULL, http: Sibit::Http.new, network: 'BTC')
|
|
25
|
+
@http = http
|
|
26
|
+
@log = log
|
|
27
|
+
@network = network
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Current price of BTC in USD (float returned).
|
|
31
|
+
def price(currency = 'USD')
|
|
32
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
33
|
+
Iri.new('https://sochain.com/api/v2/get_price').append(@network).append(currency)
|
|
34
|
+
)['data']
|
|
35
|
+
raise(Sibit::Error, "No price data returned for #{@network}/#{currency}") if data.nil?
|
|
36
|
+
prices = data['prices']
|
|
37
|
+
if prices.nil? || prices.empty?
|
|
38
|
+
raise(Sibit::Error, "No price quotes for #{@network}/#{currency}")
|
|
39
|
+
end
|
|
40
|
+
price = Float(prices[0]['price'])
|
|
41
|
+
@log.debug("The price of #{@network} is #{price} #{currency}")
|
|
42
|
+
price
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The height of the block, identified by hash.
|
|
46
|
+
def height(hash)
|
|
47
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
48
|
+
Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
|
|
49
|
+
)['data']
|
|
50
|
+
raise(Sibit::Error, "The block #{hash} not found") if data.nil?
|
|
51
|
+
h = data['block_no']
|
|
52
|
+
raise(Sibit::Error, "The block #{hash} found but the height is absent") if h.nil?
|
|
53
|
+
@log.debug("The height of #{hash} is #{h}")
|
|
54
|
+
Integer(h)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get hash of the block after this one.
|
|
58
|
+
def next_of(hash)
|
|
59
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
60
|
+
Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
|
|
61
|
+
)['data']
|
|
62
|
+
raise(Sibit::Error, "The block #{hash} not found") if data.nil?
|
|
63
|
+
nxt = data['next_blockhash']
|
|
64
|
+
nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
|
|
65
|
+
@log.debug("In SoChain the block #{hash} is the latest, there is no next block") if nxt.nil?
|
|
66
|
+
@log.debug("The next block of #{hash} is #{nxt}") unless nxt.nil?
|
|
67
|
+
nxt
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Gets the balance of the address, in satoshi.
|
|
71
|
+
def balance(address)
|
|
72
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
73
|
+
Iri.new('https://sochain.com/api/v2/get_address_balance').append(@network).append(address)
|
|
74
|
+
)['data']
|
|
75
|
+
if data.nil?
|
|
76
|
+
@log.debug("The balance of #{address} is probably zero (not found)")
|
|
77
|
+
return 0
|
|
78
|
+
end
|
|
79
|
+
confirmed = data['confirmed_balance']
|
|
80
|
+
if confirmed.nil?
|
|
81
|
+
@log.debug("The balance of #{address} is probably zero (no confirmed_balance)")
|
|
82
|
+
return 0
|
|
83
|
+
end
|
|
84
|
+
b = Integer((Float(confirmed) * 100_000_000).round)
|
|
85
|
+
@log.debug("The balance of #{address} is #{b} satoshi")
|
|
86
|
+
b
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get recommended fees, in satoshi per byte.
|
|
90
|
+
def fees
|
|
91
|
+
raise(Sibit::NotSupportedError, 'SoChain doesn\'t provide recommended fees')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Gets the hash of the latest block.
|
|
95
|
+
def latest
|
|
96
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
97
|
+
Iri.new('https://sochain.com/api/v2/get_info').append(@network)
|
|
98
|
+
)['data']
|
|
99
|
+
raise(Sibit::Error, 'The latest block info not found') if data.nil?
|
|
100
|
+
hash = data['blockhash']
|
|
101
|
+
raise(Sibit::Error, 'The latest block hash is absent') if hash.nil?
|
|
102
|
+
@log.debug("The latest block hash is #{hash}")
|
|
103
|
+
hash
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fetch all unspent outputs per address. The argument is an array
|
|
107
|
+
# of Bitcoin addresses.
|
|
108
|
+
def utxos(sources)
|
|
109
|
+
out = []
|
|
110
|
+
sources.each do |address|
|
|
111
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
112
|
+
Iri.new('https://sochain.com/api/v2/get_tx_unspent').append(@network).append(address)
|
|
113
|
+
)['data']
|
|
114
|
+
raise(Sibit::Error, "The address #{address} unspent lookup returned no data") if data.nil?
|
|
115
|
+
txs = data['txs']
|
|
116
|
+
raise(Sibit::Error, "The address #{address} unspent lookup returned no txs") if txs.nil?
|
|
117
|
+
txs.each do |u|
|
|
118
|
+
out << {
|
|
119
|
+
value: Integer((Float(u['value']) * 100_000_000).round),
|
|
120
|
+
hash: u['txid'],
|
|
121
|
+
index: u['output_no'],
|
|
122
|
+
confirmations: u['confirmations'],
|
|
123
|
+
script: [u['script_hex']].pack('H*')
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
out
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Push this transaction (in hex format) to the network. SoChain expects a
|
|
131
|
+
# JSON payload with the +tx_hex+ field on POST /send_tx/{network}.
|
|
132
|
+
def push(hex)
|
|
133
|
+
uri = URI(Iri.new('https://sochain.com/api/v2/send_tx').append(@network).to_s)
|
|
134
|
+
res = @http.client(uri).post(
|
|
135
|
+
uri.path,
|
|
136
|
+
JSON.generate(tx_hex: hex),
|
|
137
|
+
{
|
|
138
|
+
'Accept' => 'application/json',
|
|
139
|
+
'Content-Type' => 'application/json',
|
|
140
|
+
'Accept-Charset' => 'UTF-8',
|
|
141
|
+
'Accept-Encoding' => ''
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
unless res.code == '200'
|
|
145
|
+
raise(Sibit::Error, "Failed to push transaction to #{uri}: #{res.code}\n#{res.body}")
|
|
146
|
+
end
|
|
147
|
+
@log.debug("Transaction (#{hex.length} chars in hex) has been pushed to SoChain")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# This method should fetch a block and return it as a hash.
|
|
151
|
+
def block(hash)
|
|
152
|
+
data = Sibit::Json.new(http: @http, log: @log).get(
|
|
153
|
+
Iri.new('https://sochain.com/api/v2/block').append(@network).append(hash)
|
|
154
|
+
)['data']
|
|
155
|
+
raise(Sibit::Error, "The block #{hash} not found") if data.nil?
|
|
156
|
+
nxt = data['next_blockhash']
|
|
157
|
+
nxt = nil if nxt == '0000000000000000000000000000000000000000000000000000000000000000'
|
|
158
|
+
{
|
|
159
|
+
provider: self.class.name,
|
|
160
|
+
hash: data['blockhash'],
|
|
161
|
+
orphan: data['is_orphan'] == true,
|
|
162
|
+
next: nxt,
|
|
163
|
+
previous: data['previous_blockhash'],
|
|
164
|
+
txns: (data['txs'] || []).map do |t|
|
|
165
|
+
unless t.is_a?(Hash)
|
|
166
|
+
raise(Sibit::NotSupportedError, "SoChain returned bare txid #{t} without outputs")
|
|
167
|
+
end
|
|
168
|
+
{
|
|
169
|
+
hash: t['txid'],
|
|
170
|
+
outputs: (t['outputs'] || []).map do |o|
|
|
171
|
+
{
|
|
172
|
+
address: o['address'],
|
|
173
|
+
value: Integer((Float(o['value']) * 100_000_000).round)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/sibit/tx.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
require 'digest'
|
|
7
7
|
require_relative 'base58'
|
|
8
8
|
require_relative 'bech32'
|
|
9
|
+
require_relative 'error'
|
|
9
10
|
require_relative 'key'
|
|
10
11
|
require_relative 'script'
|
|
11
12
|
|
|
@@ -37,12 +38,16 @@ class Sibit
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def hash
|
|
40
|
-
|
|
41
|
+
sign_inputs
|
|
42
|
+
Digest::SHA256.hexdigest(
|
|
43
|
+
Digest::SHA256.digest(serialize(witness: false))
|
|
44
|
+
).scan(/../).reverse.join
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
def payload
|
|
48
|
+
return @payload if @payload
|
|
44
49
|
sign_inputs
|
|
45
|
-
serialize
|
|
50
|
+
@payload = serialize
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
def hex
|
|
@@ -105,7 +110,9 @@ class Sibit
|
|
|
105
110
|
|
|
106
111
|
def script
|
|
107
112
|
return segwit_script if segwit?
|
|
108
|
-
p2pkh_script
|
|
113
|
+
return p2pkh_script if %w[00 6f].include?(version)
|
|
114
|
+
return p2sh_script if %w[05 c4].include?(version)
|
|
115
|
+
raise(Sibit::Error, "Address '#{@address}' has an unsupported version byte 0x#{version}")
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
def segwit?
|
|
@@ -118,17 +125,42 @@ class Sibit
|
|
|
118
125
|
|
|
119
126
|
private
|
|
120
127
|
|
|
128
|
+
def decoded
|
|
129
|
+
return @decoded if @decoded
|
|
130
|
+
hex = Base58.new(@address).decode
|
|
131
|
+
unless hex.length == 50
|
|
132
|
+
raise(Sibit::Error, "Address '#{@address}' does not decode to 25 bytes")
|
|
133
|
+
end
|
|
134
|
+
unless hex[-8..] == Base58.new(hex[0..-9]).check
|
|
135
|
+
raise(Sibit::Error, "Address '#{@address}' fails its Base58 checksum")
|
|
136
|
+
end
|
|
137
|
+
@decoded = hex
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def version
|
|
141
|
+
decoded[0, 2]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def hash160
|
|
145
|
+
[decoded[2, 40]].pack('H*')
|
|
146
|
+
end
|
|
147
|
+
|
|
121
148
|
def p2pkh_script
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
[0x76, 0xa9, 0x14].pack('C*') + hash160 + [0x88, 0xac].pack('C*')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def p2sh_script
|
|
153
|
+
[0xa9, 0x14].pack('C*') + hash160 + [0x87].pack('C*')
|
|
125
154
|
end
|
|
126
155
|
|
|
127
156
|
def segwit_script
|
|
128
157
|
bech = Bech32.new(@address)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
program = bech.witness
|
|
159
|
+
[opcode(bech.version), program.length / 2].pack('C*') + [program].pack('H*')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def opcode(version)
|
|
163
|
+
version.zero? ? 0x00 : 0x50 + version
|
|
132
164
|
end
|
|
133
165
|
end
|
|
134
166
|
|
|
@@ -139,23 +171,23 @@ class Sibit
|
|
|
139
171
|
end
|
|
140
172
|
|
|
141
173
|
def sign_inputs
|
|
174
|
+
return if @signed
|
|
142
175
|
@inputs.each_with_index do |input, idx|
|
|
143
|
-
|
|
144
|
-
sig = sign(input.key, sighash)
|
|
176
|
+
sig = sign(input.key, (input.segwit? ? segwit_sighash(idx) : legacy_sighash(idx)))
|
|
145
177
|
pubkey = [input.key.pub].pack('H*')
|
|
146
178
|
if input.segwit?
|
|
147
|
-
|
|
148
|
-
input.witness = [witness_sig.bytes, pubkey.bytes]
|
|
179
|
+
input.witness = [(sig + [SIGHASH_ALL].pack('C')).bytes, pubkey.bytes]
|
|
149
180
|
else
|
|
150
181
|
input.script_sig = der_sig(sig) + pubkey_script(pubkey)
|
|
151
182
|
end
|
|
152
183
|
end
|
|
184
|
+
@signed = true
|
|
153
185
|
end
|
|
154
186
|
|
|
155
187
|
def legacy_sighash(idx)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
Digest::SHA256.digest(
|
|
189
|
+
Digest::SHA256.digest(serialize_for_signing(idx) + [SIGHASH_ALL].pack('V'))
|
|
190
|
+
)
|
|
159
191
|
end
|
|
160
192
|
|
|
161
193
|
def segwit_sighash(idx)
|
|
@@ -175,48 +207,55 @@ class Sibit
|
|
|
175
207
|
end
|
|
176
208
|
|
|
177
209
|
def hash_prevouts
|
|
178
|
-
|
|
179
|
-
|
|
210
|
+
Digest::SHA256.digest(
|
|
211
|
+
Digest::SHA256.digest(
|
|
212
|
+
@inputs.map do |i|
|
|
213
|
+
[i.hash].pack('H*').reverse + [i.index].pack('V')
|
|
214
|
+
end.join
|
|
215
|
+
)
|
|
216
|
+
)
|
|
180
217
|
end
|
|
181
218
|
|
|
182
219
|
def hash_sequence
|
|
183
|
-
|
|
184
|
-
Digest::SHA256.digest(Digest::SHA256.digest(data))
|
|
220
|
+
Digest::SHA256.digest(Digest::SHA256.digest(@inputs.map { [SEQUENCE].pack('V') }.join))
|
|
185
221
|
end
|
|
186
222
|
|
|
187
223
|
def hash_outputs
|
|
188
|
-
|
|
189
|
-
|
|
224
|
+
Digest::SHA256.digest(
|
|
225
|
+
Digest::SHA256.digest(
|
|
226
|
+
@outputs.map do |o|
|
|
227
|
+
[o.value].pack('Q<') + varint(o.script.length) + o.script
|
|
228
|
+
end.join
|
|
229
|
+
)
|
|
230
|
+
)
|
|
190
231
|
end
|
|
191
232
|
|
|
192
233
|
def script_code(input)
|
|
193
|
-
|
|
194
|
-
|
|
234
|
+
code = [
|
|
235
|
+
0x76, 0xa9,
|
|
236
|
+
0x14
|
|
237
|
+
].pack('C*') + [input.prev_script[4..]].pack('H*') + [0x88, 0xac].pack('C*')
|
|
195
238
|
varint(code.length) + code
|
|
196
239
|
end
|
|
197
240
|
|
|
198
241
|
def sign(key, hash)
|
|
199
|
-
|
|
200
|
-
repack(der)
|
|
242
|
+
repack(key.sign(hash))
|
|
201
243
|
end
|
|
202
244
|
|
|
203
245
|
def repack(der)
|
|
204
246
|
return der if low_s?(der)
|
|
205
247
|
seq = OpenSSL::ASN1.decode(der)
|
|
206
|
-
|
|
207
|
-
s = seq.value[1].value.to_i
|
|
248
|
+
s = Integer(seq.value[1].value)
|
|
208
249
|
order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
209
250
|
s = order - s if s > order / 2
|
|
210
251
|
OpenSSL::ASN1::Sequence.new(
|
|
211
|
-
[OpenSSL::ASN1::Integer.new(
|
|
252
|
+
[OpenSSL::ASN1::Integer.new(Integer(seq.value[0].value)), OpenSSL::ASN1::Integer.new(s)]
|
|
212
253
|
).to_der
|
|
213
254
|
end
|
|
214
255
|
|
|
215
256
|
def low_s?(der)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
219
|
-
s <= order / 2
|
|
257
|
+
Integer(OpenSSL::ASN1.decode(der).value[1].value) <=
|
|
258
|
+
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 / 2
|
|
220
259
|
end
|
|
221
260
|
|
|
222
261
|
def der_sig(sig)
|
|
@@ -228,9 +267,9 @@ class Sibit
|
|
|
228
267
|
[pubkey.length].pack('C') + pubkey
|
|
229
268
|
end
|
|
230
269
|
|
|
231
|
-
def serialize
|
|
270
|
+
def serialize(witness: witness?)
|
|
232
271
|
result = [VERSION].pack('V')
|
|
233
|
-
result += [0x00, 0x01].pack('CC') if witness
|
|
272
|
+
result += [0x00, 0x01].pack('CC') if witness
|
|
234
273
|
result += varint(@inputs.length)
|
|
235
274
|
@inputs.each do |input|
|
|
236
275
|
result += [input.hash].pack('H*').reverse
|
|
@@ -246,7 +285,7 @@ class Sibit
|
|
|
246
285
|
result += varint(script.length)
|
|
247
286
|
result += script
|
|
248
287
|
end
|
|
249
|
-
result += serialize_witness if witness
|
|
288
|
+
result += serialize_witness if witness
|
|
250
289
|
result += [0].pack('V')
|
|
251
290
|
result
|
|
252
291
|
end
|
data/lib/sibit/txbuilder.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# SPDX-FileCopyrightText: Copyright (c) 2019-2026 Yegor Bugayenko
|
|
4
4
|
# SPDX-License-Identifier: MIT
|
|
5
5
|
|
|
6
|
+
require_relative 'error'
|
|
6
7
|
require_relative 'key'
|
|
7
8
|
require_relative 'tx'
|
|
8
9
|
|
|
@@ -17,6 +18,8 @@ class Sibit
|
|
|
17
18
|
# Copyright:: Copyright (c) 2019-2026 Yegor Bugayenko
|
|
18
19
|
# License:: MIT
|
|
19
20
|
class TxBuilder
|
|
21
|
+
DUST = 546
|
|
22
|
+
|
|
20
23
|
def initialize
|
|
21
24
|
@inputs = []
|
|
22
25
|
@outputs = []
|
|
@@ -24,7 +27,7 @@ class Sibit
|
|
|
24
27
|
|
|
25
28
|
def input
|
|
26
29
|
inp = Input.new
|
|
27
|
-
yield
|
|
30
|
+
yield(inp)
|
|
28
31
|
@inputs << inp
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -43,10 +46,19 @@ class Sibit
|
|
|
43
46
|
value: inp.amount
|
|
44
47
|
)
|
|
45
48
|
end
|
|
46
|
-
|
|
49
|
+
total = @outputs.sum { |o| o[:value] }
|
|
47
50
|
@outputs.each { |o| txn.add_output(o[:value], o[:address]) }
|
|
48
51
|
if leave_fee
|
|
49
|
-
change = input_value -
|
|
52
|
+
change = input_value - total - extra_fee
|
|
53
|
+
if change.negative?
|
|
54
|
+
raise(
|
|
55
|
+
Sibit::Error,
|
|
56
|
+
"The inputs of #{input_value} cannot cover outputs of #{total} plus fee of #{extra_fee}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
if change.positive? && change < DUST
|
|
60
|
+
raise(Sibit::Error, "The change of #{change} is below the dust limit of #{DUST}")
|
|
61
|
+
end
|
|
50
62
|
txn.add_output(change, change_address) if change.positive?
|
|
51
63
|
end
|
|
52
64
|
Built.new(txn, @inputs, @outputs)
|