pwn 0.5.749 → 0.5.751
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 +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
data/lib/pwn/blockchain/btc.rb
CHANGED
|
@@ -1,284 +1,582 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'base64'
|
|
4
3
|
require 'json'
|
|
5
|
-
require '
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'bigdecimal'
|
|
6
7
|
require 'date'
|
|
8
|
+
require 'timeout'
|
|
7
9
|
|
|
8
10
|
module PWN
|
|
9
11
|
module Blockchain
|
|
10
|
-
#
|
|
12
|
+
# Read-only Bitcoin Core intelligence. No wallet, signing or broadcast RPCs.
|
|
11
13
|
module BTC
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
# Deliberately excludes remote messages/bodies, URLs and credentials.
|
|
15
|
+
class RPCError < StandardError
|
|
16
|
+
attr_reader :code, :http_status, :rpc_method
|
|
17
|
+
|
|
18
|
+
def initialize(opts = {})
|
|
19
|
+
@code = opts[:code]
|
|
20
|
+
@http_status = opts[:http_status]
|
|
21
|
+
@rpc_method = opts[:rpc_method]
|
|
22
|
+
super("Bitcoin RPC #{opts[:kind] || 'failed'} (method=#{@rpc_method}, code=#{@code}, HTTP=#{@http_status})")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
blockchain = PWN::Env[:plugins][:blockchain][:bitcoin]
|
|
24
|
-
raise 'ERROR: Jira Server Hash not found in PWN::Env. Run i`pwn -Y default.yaml`, then `PWN::Env` for usage.' if blockchain.nil?
|
|
26
|
+
READ_METHODS = %w[getblockchaininfo getblockhash getblock getblockheader getrawtransaction gettxout getmempoolinfo validateaddress].freeze
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
private_class_method def self.integer_option(opts = {})
|
|
29
|
+
value = opts[:value]
|
|
30
|
+
range = opts[:range]
|
|
31
|
+
raise ArgumentError, "#{opts[:name]} must be an integer in #{range}" unless value.is_a?(Integer) && range.cover?(value)
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
value
|
|
34
|
+
end
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
private_class_method def self.hash_option(opts = {})
|
|
37
|
+
value = opts[:value]
|
|
38
|
+
raise ArgumentError, "#{opts[:name]} must be a 64-character hexadecimal string" unless value.is_a?(String) && value.match?(/\A[0-9a-fA-F]{64}\z/)
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
value
|
|
41
|
+
end
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
private_class_method def self.btc_rpc_call(opts = {})
|
|
44
|
+
method = opts[:method]
|
|
45
|
+
raise ArgumentError, 'Unsupported read-only RPC method' unless READ_METHODS.include?(method)
|
|
46
|
+
|
|
47
|
+
config = PWN::Env.dig(:plugins, :blockchain, :bitcoin)
|
|
48
|
+
raise ArgumentError, 'Bitcoin RPC configuration is required' unless config.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
host = config.fetch(:rpc_host, '127.0.0.1')
|
|
51
|
+
scheme = config.fetch(:rpc_scheme, 'http')
|
|
52
|
+
raise ArgumentError, 'Invalid Bitcoin RPC host or scheme' unless host.is_a?(String) && host.match?(/\A[a-zA-Z0-9.:-]+\z/) && %w[http https].include?(scheme)
|
|
53
|
+
|
|
54
|
+
port = config.fetch(:rpc_port, 8332)
|
|
55
|
+
port = port.to_i if port.is_a?(String) && port.match?(/\A[0-9]+\z/)
|
|
56
|
+
integer_option(value: port, name: 'rpc_port', range: 1..65_535)
|
|
57
|
+
user = config[:rpc_user]
|
|
58
|
+
password = config[:rpc_pass]
|
|
59
|
+
raise ArgumentError, 'Bitcoin RPC credentials are required and must be valid strings' unless user.is_a?(String) && !user.empty? && !user.match?(/[:\r\n]/) && password.is_a?(String) && !password.empty? && !password.match?(/[\r\n]/)
|
|
60
|
+
|
|
61
|
+
timeout = integer_option(value: opts.fetch(:timeout, 30), name: 'timeout', range: 1..300)
|
|
62
|
+
begin
|
|
63
|
+
http = Net::HTTP.new(host, port, nil) # Never inherit proxy environment or forward credentials.
|
|
64
|
+
http.use_ssl = scheme == 'https'
|
|
65
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
66
|
+
http.open_timeout = [timeout, 10].min
|
|
67
|
+
http.read_timeout = timeout
|
|
68
|
+
http.write_timeout = timeout
|
|
69
|
+
request = Net::HTTP::Post.new('/')
|
|
70
|
+
request.basic_auth(user, password)
|
|
71
|
+
request['Content-Type'] = 'application/json'
|
|
72
|
+
request.body = JSON.generate(jsonrpc: '1.0', id: 'pwn-btc', method: method, params: opts.fetch(:params, []))
|
|
73
|
+
response = Timeout.timeout(timeout) { http.request(request) }
|
|
74
|
+
status = response.code.to_i
|
|
75
|
+
body = JSON.parse(response.body, symbolize_names: true, decimal_class: BigDecimal)
|
|
76
|
+
raise RPCError.new(rpc_method: method, http_status: status, kind: 'invalid response'), cause: nil unless body.is_a?(Hash) && body[:id] == 'pwn-btc' && (body.key?(:result) || body[:error].is_a?(Hash))
|
|
77
|
+
|
|
78
|
+
if body[:error]
|
|
79
|
+
code = body[:error].is_a?(Hash) && body[:error][:code].is_a?(Integer) ? body[:error][:code] : nil
|
|
80
|
+
raise RPCError.new(rpc_method: method, http_status: status, code: code), cause: nil
|
|
81
|
+
end
|
|
82
|
+
raise RPCError.new(rpc_method: method, http_status: status, kind: 'HTTP failure'), cause: nil unless status == 200
|
|
39
83
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
body
|
|
85
|
+
rescue RPCError
|
|
86
|
+
raise
|
|
87
|
+
rescue StandardError
|
|
88
|
+
raise RPCError.new(rpc_method: method, http_status: status, kind: 'transport or response failure'), cause: nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
44
91
|
|
|
45
|
-
|
|
46
|
-
|
|
92
|
+
# Supported Method Parameters::
|
|
93
|
+
# Return the legacy JSON-RPC envelope with blockchain status, without AI.
|
|
94
|
+
# PWN::Blockchain::BTC.get_latest_block
|
|
95
|
+
public_class_method def self.get_latest_block
|
|
96
|
+
btc_rpc_call(method: 'getblockchaininfo')
|
|
97
|
+
end
|
|
47
98
|
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
# Supported Method Parameters::
|
|
100
|
+
# Return blockchain synchronization, pruning and active chain status.
|
|
101
|
+
# PWN::Blockchain::BTC.chain_status(timeout: 'optional - RPC timeout seconds, 1..300; default 30')
|
|
102
|
+
public_class_method def self.chain_status(opts = {})
|
|
103
|
+
timeout = opts.key?(:timeout) ? opts[:timeout] : 30
|
|
104
|
+
btc_rpc_call(method: 'getblockchaininfo', timeout: timeout)[:result]
|
|
105
|
+
end
|
|
50
106
|
|
|
51
|
-
|
|
107
|
+
# Supported Method Parameters::
|
|
108
|
+
# Read a block at a height with selectable Core verbosity.
|
|
109
|
+
# PWN::Blockchain::BTC.get_block_details(
|
|
110
|
+
# height: 'optional - nonnegative integer height; default current tip',
|
|
111
|
+
# verbosity: 'optional - integer 0..3; default 1; 3 requires node undo data',
|
|
112
|
+
# timeout: 'optional - RPC timeout seconds, 1..300; default 30'
|
|
113
|
+
# )
|
|
114
|
+
public_class_method def self.get_block_details(opts = {})
|
|
115
|
+
timeout = opts.fetch(:timeout, 30)
|
|
116
|
+
verbosity = integer_option(value: opts.fetch(:verbosity, 1), name: 'verbosity', range: 0..3)
|
|
117
|
+
height = opts[:height]
|
|
118
|
+
height = chain_status(timeout: timeout)[:blocks] if height.nil?
|
|
119
|
+
integer_option(value: height, name: 'height', range: 0..2_147_483_647)
|
|
120
|
+
hash = btc_rpc_call(method: 'getblockhash', params: [height], timeout: timeout)[:result]
|
|
121
|
+
btc_rpc_call(method: 'getblock', params: [hash, verbosity], timeout: timeout)[:result]
|
|
122
|
+
end
|
|
52
123
|
|
|
53
|
-
|
|
54
|
-
|
|
124
|
+
private_class_method def self.satoshis(opts = {})
|
|
125
|
+
amount = BigDecimal(opts[:value].to_s, exception: false)
|
|
126
|
+
raise RPCError.new(kind: 'invalid monetary amount'), cause: nil unless amount&.finite? && amount >= 0 && amount <= 21_000_000 && (amount * 100_000_000).frac.zero?
|
|
55
127
|
|
|
56
|
-
|
|
128
|
+
(amount * 100_000_000).to_i
|
|
129
|
+
end
|
|
57
130
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
method: http_method,
|
|
64
|
-
url: "#{base_uri}#{rest_call}",
|
|
65
|
-
headers: headers,
|
|
66
|
-
verify_ssl: false,
|
|
67
|
-
timeout: timeout
|
|
68
|
-
)
|
|
131
|
+
private_class_method def self.raw_transaction(opts = {})
|
|
132
|
+
params = [opts[:txid], true]
|
|
133
|
+
params << opts[:blockhash] if opts[:blockhash]
|
|
134
|
+
result = btc_rpc_call(method: 'getrawtransaction', params: params, timeout: opts[:timeout])[:result]
|
|
135
|
+
raise RPCError.new(rpc_method: 'getrawtransaction', kind: 'invalid transaction'), cause: nil unless result.is_a?(Hash) && result[:txid]&.downcase == opts[:txid].downcase && result[:vin].is_a?(Array) && result[:vout].is_a?(Array)
|
|
69
136
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
headers[:content_type] = 'multipart/form-data'
|
|
73
|
-
|
|
74
|
-
response = rest_client.execute(
|
|
75
|
-
method: http_method,
|
|
76
|
-
url: "#{base_uri}#{rest_call}",
|
|
77
|
-
headers: headers,
|
|
78
|
-
payload: http_body,
|
|
79
|
-
verify_ssl: false,
|
|
80
|
-
timeout: timeout
|
|
81
|
-
)
|
|
82
|
-
else
|
|
83
|
-
response = rest_client.execute(
|
|
84
|
-
method: http_method,
|
|
85
|
-
url: "#{base_uri}#{rest_call}",
|
|
86
|
-
headers: headers,
|
|
87
|
-
payload: http_body.to_json,
|
|
88
|
-
verify_ssl: false,
|
|
89
|
-
timeout: timeout
|
|
90
|
-
)
|
|
91
|
-
end
|
|
137
|
+
result
|
|
138
|
+
end
|
|
92
139
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
140
|
+
private_class_method def self.resolve_prevout(opts = {})
|
|
141
|
+
input = opts[:input]
|
|
142
|
+
return [input[:prevout], nil] if input[:prevout].is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
cache = opts[:cache]
|
|
145
|
+
budget = opts[:budget]
|
|
146
|
+
unless cache.key?(input[:txid])
|
|
147
|
+
return [nil, :lookup_limit] if budget[:remaining].zero?
|
|
148
|
+
|
|
149
|
+
budget[:remaining] -= 1
|
|
150
|
+
begin
|
|
151
|
+
cache[input[:txid]] = raw_transaction(txid: input[:txid], timeout: opts[:timeout])
|
|
152
|
+
rescue RPCError => e
|
|
153
|
+
raise unless [-5, -8, -1].include?(e.code) && [200, 500].include?(e.http_status)
|
|
96
154
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
case e.http_code
|
|
100
|
-
when 400, 404
|
|
101
|
-
"#{e.http_code} #{e.message}: #{e.response.body}"
|
|
102
|
-
else
|
|
103
|
-
raise e
|
|
155
|
+
cache[input[:txid]] = nil
|
|
156
|
+
end
|
|
104
157
|
end
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
ensure
|
|
108
|
-
PWN::Plugins::TTYSpinner.stop(spin: spin)
|
|
158
|
+
prevout = cache[input[:txid]]&.fetch(:vout)&.find { |output| output[:n] == input[:vout] }
|
|
159
|
+
[prevout, prevout ? nil : :unavailable]
|
|
109
160
|
end
|
|
110
161
|
|
|
111
|
-
private_class_method def self.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
162
|
+
private_class_method def self.transaction_details(opts = {})
|
|
163
|
+
tx = opts[:transaction]
|
|
164
|
+
missing = []
|
|
165
|
+
inputs = tx[:vin].map do |input|
|
|
166
|
+
next { coinbase: input[:coinbase], value_sats: nil } if input.key?(:coinbase)
|
|
167
|
+
|
|
168
|
+
prevout, reason = resolve_prevout(input: input, cache: opts[:cache], budget: opts[:budget], timeout: opts[:timeout])
|
|
169
|
+
reference = { txid: input[:txid], vout: input[:vout] }
|
|
170
|
+
missing << reference.merge(reason: reason) unless prevout
|
|
171
|
+
reference.merge(value_sats: prevout ? satoshis(value: prevout[:value]) : nil, script_pub_key: prevout&.dig(:scriptPubKey))
|
|
172
|
+
end
|
|
173
|
+
outputs = tx[:vout].map { |output| { n: output[:n], value_sats: satoshis(value: output[:value]), script_pub_key: output[:scriptPubKey] } }
|
|
174
|
+
coinbase = tx[:vin].any? { |input| input.key?(:coinbase) }
|
|
175
|
+
output_total = outputs.sum { |output| output[:value_sats] }
|
|
176
|
+
input_total = !coinbase && missing.empty? ? inputs.sum { |input| input[:value_sats] } : nil
|
|
177
|
+
{
|
|
178
|
+
txid: tx[:txid], blockhash: tx[:blockhash], confirmations: tx[:confirmations],
|
|
179
|
+
coinbase: coinbase, inputs: inputs, outputs: outputs,
|
|
180
|
+
input_sats: input_total, output_sats: output_total,
|
|
181
|
+
fee_sats: input_total ? input_total - output_total : nil,
|
|
182
|
+
prevouts_complete: missing.empty?, missing_prevouts: missing
|
|
119
183
|
}
|
|
184
|
+
end
|
|
120
185
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
186
|
+
# Supported Method Parameters::
|
|
187
|
+
# Inspect transaction amounts and proven input fees; unavailable prevouts remain explicit.
|
|
188
|
+
# PWN::Blockchain::BTC.inspect_transaction(
|
|
189
|
+
# txid: 'required - 64-character transaction hash',
|
|
190
|
+
# blockhash: 'optional - containing block hash for nodes without txindex',
|
|
191
|
+
# max_prevouts: 'optional - maximum parent RPC lookups, 0..1000; default 100',
|
|
192
|
+
# timeout: 'optional - RPC timeout seconds, 1..300; default 30'
|
|
193
|
+
# )
|
|
194
|
+
public_class_method def self.inspect_transaction(opts = {})
|
|
195
|
+
txid = hash_option(value: opts[:txid], name: 'txid')
|
|
196
|
+
blockhash = opts[:blockhash]
|
|
197
|
+
hash_option(value: blockhash, name: 'blockhash') unless blockhash.nil?
|
|
198
|
+
budget = { remaining: integer_option(value: opts.fetch(:max_prevouts, 100), name: 'max_prevouts', range: 0..1000) }
|
|
199
|
+
timeout = opts.fetch(:timeout, 30)
|
|
200
|
+
tx = raw_transaction(txid: txid, blockhash: blockhash, timeout: timeout)
|
|
201
|
+
transaction_details(transaction: tx, budget: budget, cache: {}, timeout: timeout)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Supported Method Parameters::
|
|
205
|
+
# Inspect an outpoint in the current UTXO view; null means spent or unknown, not proven spent.
|
|
206
|
+
# PWN::Blockchain::BTC.inspect_outpoint(
|
|
207
|
+
# txid: 'required - 64-character transaction hash',
|
|
208
|
+
# vout: 'required - nonnegative integer output index',
|
|
209
|
+
# include_mempool: 'optional - boolean include mempool effects; default true',
|
|
210
|
+
# timeout: 'optional - RPC timeout seconds, 1..300; default 30'
|
|
211
|
+
# )
|
|
212
|
+
public_class_method def self.inspect_outpoint(opts = {})
|
|
213
|
+
txid = hash_option(value: opts[:txid], name: 'txid')
|
|
214
|
+
vout = integer_option(value: opts[:vout], name: 'vout', range: 0..4_294_967_295)
|
|
215
|
+
include_mempool = opts.fetch(:include_mempool, true)
|
|
216
|
+
raise ArgumentError, 'include_mempool must be boolean' unless [true, false].include?(include_mempool)
|
|
125
217
|
|
|
126
|
-
|
|
127
|
-
|
|
218
|
+
result = btc_rpc_call(method: 'gettxout', params: [txid, vout, include_mempool], timeout: opts.fetch(:timeout, 30))[:result]
|
|
219
|
+
base = { txid: txid, vout: vout, include_mempool: include_mempool, unspent: !result.nil? }
|
|
220
|
+
return base.merge(status: :spent_or_unknown) if result.nil?
|
|
128
221
|
|
|
129
|
-
|
|
130
|
-
rescue StandardError => e
|
|
131
|
-
raise e
|
|
222
|
+
base.merge(status: :unspent, value_sats: satoshis(value: result[:value]), evidence: result)
|
|
132
223
|
end
|
|
133
224
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
225
|
+
# Supported Method Parameters::
|
|
226
|
+
# Read aggregate mempool statistics without enumerating transactions.
|
|
227
|
+
# PWN::Blockchain::BTC.mempool_summary(timeout: 'optional - RPC timeout seconds, 1..300; default 30')
|
|
228
|
+
public_class_method def self.mempool_summary(opts = {})
|
|
229
|
+
timeout = opts.key?(:timeout) ? opts[:timeout] : 30
|
|
230
|
+
btc_rpc_call(method: 'getmempoolinfo', timeout: timeout)[:result]
|
|
231
|
+
end
|
|
138
232
|
|
|
139
|
-
|
|
140
|
-
|
|
233
|
+
# Supported Method Parameters::
|
|
234
|
+
# Trace only transaction ancestors using input references, never ownership or change heuristics.
|
|
235
|
+
# PWN::Blockchain::BTC.trace_transaction(
|
|
236
|
+
# txid: 'required - root transaction hash, 64 hexadecimal characters',
|
|
237
|
+
# blockhash: 'optional - containing block hash for root lookup without txindex',
|
|
238
|
+
# max_depth: 'optional - ancestor depth 0..100; default 3',
|
|
239
|
+
# max_transactions: 'optional - total transaction RPC budget 1..1000; default 100',
|
|
240
|
+
# max_edges: 'optional - maximum evidence edges 1..10000; default 1000',
|
|
241
|
+
# timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
242
|
+
# )
|
|
243
|
+
public_class_method def self.trace_transaction(opts = {})
|
|
244
|
+
txid = hash_option(value: opts[:txid], name: 'txid')
|
|
245
|
+
blockhash = opts[:blockhash]
|
|
246
|
+
hash_option(value: blockhash, name: 'blockhash') unless blockhash.nil?
|
|
247
|
+
limits = {
|
|
248
|
+
depth: integer_option(value: opts.fetch(:max_depth, 3), name: 'max_depth', range: 0..100),
|
|
249
|
+
transactions: integer_option(value: opts.fetch(:max_transactions, 100), name: 'max_transactions', range: 1..1000),
|
|
250
|
+
edges: integer_option(value: opts.fetch(:max_edges, 1000), name: 'max_edges', range: 1..10_000)
|
|
251
|
+
}
|
|
252
|
+
timeout = opts.fetch(:timeout, 30)
|
|
253
|
+
state = { queue: [[txid, 0]], scheduled: { txid.downcase => true }, cache: {}, edges: [], reasons: [], missing: [] }
|
|
254
|
+
until state[:queue].empty?
|
|
255
|
+
current, depth = state[:queue].shift
|
|
256
|
+
begin
|
|
257
|
+
tx = raw_transaction(txid: current, blockhash: current == txid ? blockhash : nil, timeout: timeout)
|
|
258
|
+
rescue RPCError => e
|
|
259
|
+
raise unless current != txid && [-5, -8, -1].include?(e.code) && [200, 500].include?(e.http_status)
|
|
260
|
+
|
|
261
|
+
state[:cache][current.downcase] = nil
|
|
262
|
+
state[:missing] << { txid: current, code: e.code }
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
state[:cache][current.downcase] = tx
|
|
266
|
+
trace_inputs(transaction: tx, depth: depth, state: state, limits: limits)
|
|
267
|
+
end
|
|
268
|
+
finish_trace(state: state, txid: txid)
|
|
141
269
|
end
|
|
142
270
|
|
|
143
|
-
private_class_method def self.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
271
|
+
private_class_method def self.trace_inputs(opts = {})
|
|
272
|
+
tx = opts[:transaction]
|
|
273
|
+
state = opts[:state]
|
|
274
|
+
limits = opts[:limits]
|
|
275
|
+
depth = opts[:depth]
|
|
276
|
+
tx[:vin].each_with_index do |input, index|
|
|
277
|
+
next if input.key?(:coinbase)
|
|
278
|
+
|
|
279
|
+
if state[:edges].length >= limits[:edges]
|
|
280
|
+
state[:reasons] << :max_edges
|
|
281
|
+
break
|
|
282
|
+
end
|
|
283
|
+
state[:edges] << { from_txid: input[:txid], from_vout: input[:vout], to_txid: tx[:txid], to_vin: index, value_sats: nil, evidence: :vin_reference }
|
|
284
|
+
next if state[:scheduled].key?(input[:txid])
|
|
285
|
+
|
|
286
|
+
if depth >= limits[:depth]
|
|
287
|
+
state[:reasons] << :max_depth
|
|
288
|
+
elsif state[:scheduled].length >= limits[:transactions]
|
|
289
|
+
state[:reasons] << :max_transactions
|
|
155
290
|
else
|
|
156
|
-
|
|
291
|
+
state[:scheduled][input[:txid]] = true
|
|
292
|
+
state[:queue] << [input[:txid], depth + 1]
|
|
157
293
|
end
|
|
158
294
|
end
|
|
159
|
-
result
|
|
160
295
|
end
|
|
161
296
|
|
|
162
|
-
private_class_method def self.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
ts = get_block_timestamp(height: mid)
|
|
171
|
-
if ts <= target_ts
|
|
172
|
-
result = mid
|
|
173
|
-
low = mid + 1
|
|
297
|
+
private_class_method def self.finish_trace(opts = {})
|
|
298
|
+
state = opts[:state]
|
|
299
|
+
missing = []
|
|
300
|
+
state[:edges].each do |edge|
|
|
301
|
+
input = state[:cache][edge[:to_txid]][:vin][edge[:to_vin]]
|
|
302
|
+
prevout, reason = resolve_prevout(input: input, cache: state[:cache], budget: { remaining: 0 })
|
|
303
|
+
if prevout
|
|
304
|
+
edge[:value_sats] = satoshis(value: prevout[:value])
|
|
174
305
|
else
|
|
175
|
-
|
|
306
|
+
missing << { txid: input[:txid], vout: input[:vout], spending_txid: edge[:to_txid], reason: reason }
|
|
176
307
|
end
|
|
177
308
|
end
|
|
178
|
-
|
|
309
|
+
reasons = state[:reasons].uniq
|
|
310
|
+
{
|
|
311
|
+
txid: opts[:txid], direction: :ancestors, evidence: :transaction_input_references,
|
|
312
|
+
nodes: state[:cache].values.compact.map { |tx| tx.slice(:txid, :blockhash, :confirmations) },
|
|
313
|
+
edges: state[:edges], missing_transactions: state[:missing], missing_prevouts: missing,
|
|
314
|
+
truncated: !reasons.empty?, truncation_reasons: reasons,
|
|
315
|
+
complete: reasons.empty? && state[:missing].empty? && missing.empty?,
|
|
316
|
+
limitations: 'Ancestors only; edges prove spends, not ownership or allocation of value to later outputs. No atomic chain snapshot.'
|
|
317
|
+
}
|
|
179
318
|
end
|
|
180
319
|
|
|
181
|
-
|
|
182
|
-
|
|
320
|
+
private_class_method def self.scan_bounds(opts = {})
|
|
321
|
+
first = integer_option(value: opts[:start_height], name: 'start_height', range: 0..2_147_483_647)
|
|
322
|
+
last = integer_option(value: opts[:end_height], name: 'end_height', range: first..2_147_483_647)
|
|
323
|
+
limit = integer_option(value: opts.fetch(:max_blocks, 100), name: 'max_blocks', range: 1..1000)
|
|
324
|
+
{ first: first, last: last, page_end: [last, first + limit - 1].min }
|
|
325
|
+
end
|
|
183
326
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
327
|
+
# Only parameter rejection permits an older-node fallback. Missing undo/block
|
|
328
|
+
# data and transport/authentication failures must retain their evidence gaps.
|
|
329
|
+
private_class_method def self.scan_block(opts = {})
|
|
330
|
+
get_block_details(opts)
|
|
331
|
+
rescue RPCError => e
|
|
332
|
+
raise unless opts[:verbosity] == 3 && e.rpc_method == 'getblock' && [-8, -32_602].include?(e.code) && [200, 500].include?(e.http_status)
|
|
190
333
|
|
|
191
|
-
|
|
192
|
-
rescue StandardError => e
|
|
193
|
-
raise e
|
|
334
|
+
get_block_details(opts.merge(verbosity: 2))
|
|
194
335
|
end
|
|
195
336
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
337
|
+
private_class_method def self.scan_page(opts = {})
|
|
338
|
+
bounds = opts[:bounds]
|
|
339
|
+
timeout = opts[:timeout]
|
|
340
|
+
info = chain_status(timeout: timeout)
|
|
341
|
+
raise ArgumentError, 'end_height exceeds current chain tip' if bounds[:last] > info[:blocks]
|
|
342
|
+
|
|
343
|
+
anchor = btc_rpc_call(method: 'getblockhash', params: [bounds[:last]], timeout: timeout)[:result]
|
|
344
|
+
blocks = []
|
|
345
|
+
missing = []
|
|
346
|
+
reorg = false
|
|
347
|
+
(bounds[:first]..bounds[:page_end]).each do |height|
|
|
348
|
+
begin
|
|
349
|
+
block = scan_block(height: height, verbosity: opts[:verbosity], timeout: timeout)
|
|
350
|
+
rescue RPCError => e
|
|
351
|
+
raise unless [-1, -5, -8].include?(e.code) && [200, 500].include?(e.http_status)
|
|
352
|
+
|
|
353
|
+
missing << { height: height, code: e.code }
|
|
354
|
+
next
|
|
355
|
+
end
|
|
356
|
+
prior = blocks.last
|
|
357
|
+
reorg = true if prior && prior[:height] == height - 1 && block[:previousblockhash] != prior[:hash]
|
|
358
|
+
blocks << block.slice(:height, :hash, :time)
|
|
359
|
+
opts[:on_block]&.call(block)
|
|
360
|
+
end
|
|
361
|
+
after = btc_rpc_call(method: 'getblockhash', params: [bounds[:last]], timeout: timeout)[:result]
|
|
362
|
+
reorg ||= anchor != after
|
|
363
|
+
unless blocks.empty?
|
|
364
|
+
active = btc_rpc_call(method: 'getblockhash', params: [blocks.last[:height]], timeout: timeout)[:result]
|
|
365
|
+
reorg ||= active != blocks.last[:hash]
|
|
366
|
+
end
|
|
367
|
+
truncated = bounds[:page_end] < bounds[:last]
|
|
368
|
+
{
|
|
369
|
+
scope: :explicit_height_range, start_height: bounds[:first], end_height: bounds[:last],
|
|
370
|
+
scanned_through: bounds[:page_end], next_height: truncated ? bounds[:page_end] + 1 : nil,
|
|
371
|
+
truncated: truncated, complete: !truncated && missing.empty? && !reorg,
|
|
372
|
+
reorg_detected: reorg, missing_blocks: missing,
|
|
373
|
+
blocks: blocks.map { |block| block.slice(:height, :hash, :time) },
|
|
374
|
+
anchor: { chain: info[:chain], height: bounds[:last], hash: anchor, hash_after: after },
|
|
375
|
+
limitations: 'Completeness applies only to requested heights, not all history. Non-atomic RPC scan; recheck anchors across pages and retry on reorg.'
|
|
376
|
+
}
|
|
377
|
+
end
|
|
200
378
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
379
|
+
private_class_method def self.date_timestamp(opts = {})
|
|
380
|
+
value = opts[:value]
|
|
381
|
+
raise ArgumentError, 'Dates must be valid YYYY-MM-DD strings' unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
204
382
|
|
|
205
|
-
|
|
383
|
+
date = Date.iso8601(value)
|
|
384
|
+
Time.utc(date.year, date.month, date.day).to_i
|
|
385
|
+
rescue Date::Error
|
|
386
|
+
raise ArgumentError, 'Dates must be valid YYYY-MM-DD strings', cause: nil
|
|
387
|
+
end
|
|
206
388
|
|
|
207
|
-
|
|
208
|
-
|
|
389
|
+
# Supported Method Parameters::
|
|
390
|
+
# Scan a bounded page by UTC block-header dates without assuming monotonic timestamps.
|
|
391
|
+
# PWN::Blockchain::BTC.scan_transactions(
|
|
392
|
+
# from: 'required - inclusive UTC date YYYY-MM-DD',
|
|
393
|
+
# to: 'required - inclusive UTC date YYYY-MM-DD',
|
|
394
|
+
# start_height: 'required - first nonnegative integer height; use next_height for continuation',
|
|
395
|
+
# end_height: 'required - final inclusive integer height; keep fixed across pages',
|
|
396
|
+
# max_blocks: 'optional - blocks examined per page, 1..1000; default 100',
|
|
397
|
+
# timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
398
|
+
# )
|
|
399
|
+
public_class_method def self.scan_transactions(opts = {})
|
|
400
|
+
from = date_timestamp(value: opts[:from])
|
|
401
|
+
to = date_timestamp(value: opts[:to])
|
|
402
|
+
raise ArgumentError, 'from must not follow to' if from > to
|
|
209
403
|
|
|
210
|
-
|
|
211
|
-
|
|
404
|
+
bounds = scan_bounds(opts)
|
|
405
|
+
transactions = []
|
|
406
|
+
on_block = lambda do |block|
|
|
407
|
+
next unless block[:time] >= from && block[:time] < to + 86_400
|
|
212
408
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
409
|
+
block[:tx].each { |txid| transactions << { txid: txid, height: block[:height], blockhash: block[:hash], time: block[:time] } }
|
|
410
|
+
end
|
|
411
|
+
scan_page(bounds: bounds, verbosity: 1, timeout: opts.fetch(:timeout, 30), on_block: on_block).merge(transactions: transactions)
|
|
216
412
|
end
|
|
217
413
|
|
|
218
414
|
# Supported Method Parameters::
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
415
|
+
# Return legacy transaction IDs only when the entire explicit height range was scanned.
|
|
416
|
+
# PWN::Blockchain::BTC.get_transactions(
|
|
417
|
+
# from: 'required - inclusive UTC date YYYY-MM-DD',
|
|
418
|
+
# to: 'required - inclusive UTC date YYYY-MM-DD',
|
|
419
|
+
# start_height: 'required - first nonnegative integer height',
|
|
420
|
+
# end_height: 'required - final inclusive integer height',
|
|
421
|
+
# max_blocks: 'optional - full-range budget 1..1000; default 100; use scan_transactions for pages',
|
|
422
|
+
# timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
222
423
|
# )
|
|
223
|
-
|
|
224
424
|
public_class_method def self.get_transactions(opts = {})
|
|
225
|
-
|
|
226
|
-
|
|
425
|
+
bounds = scan_bounds(opts)
|
|
426
|
+
raise ArgumentError, 'Range exceeds max_blocks; use scan_transactions and next_height' if bounds[:page_end] < opts[:end_height]
|
|
227
427
|
|
|
228
|
-
|
|
428
|
+
result = scan_transactions(opts)
|
|
429
|
+
raise RPCError.new(kind: 'incomplete scan; use scan_transactions for evidence'), cause: nil unless result[:complete]
|
|
229
430
|
|
|
230
|
-
|
|
231
|
-
|
|
431
|
+
result[:transactions].map { |tx| tx[:txid] }
|
|
432
|
+
end
|
|
232
433
|
|
|
233
|
-
|
|
234
|
-
|
|
434
|
+
# Supported Method Parameters::
|
|
435
|
+
# Scan address scripts in explicit blocks, not an arbitrary address index or wallet history.
|
|
436
|
+
# PWN::Blockchain::BTC.scan_address_activity(
|
|
437
|
+
# address: 'required - Bitcoin address validated by the configured node',
|
|
438
|
+
# start_height: 'required - first nonnegative integer height; use next_height for continuation',
|
|
439
|
+
# end_height: 'required - final inclusive integer height; keep fixed across pages',
|
|
440
|
+
# max_blocks: 'optional - blocks examined per page, 1..1000; default 100',
|
|
441
|
+
# max_prevouts: 'optional - total parent RPC lookups per page, 0..1000; default 100',
|
|
442
|
+
# timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
443
|
+
# )
|
|
444
|
+
public_class_method def self.scan_address_activity(opts = {})
|
|
445
|
+
address = opts[:address]
|
|
446
|
+
raise ArgumentError, 'address must be an alphanumeric Bitcoin address' unless address.is_a?(String) && address.match?(/\A[a-zA-Z0-9]{14,90}\z/)
|
|
447
|
+
|
|
448
|
+
bounds = scan_bounds(opts)
|
|
449
|
+
budget = { remaining: integer_option(value: opts.fetch(:max_prevouts, 100), name: 'max_prevouts', range: 0..1000) }
|
|
450
|
+
timeout = opts.fetch(:timeout, 30)
|
|
451
|
+
validation = btc_rpc_call(method: 'validateaddress', params: [address], timeout: timeout)[:result]
|
|
452
|
+
raise ArgumentError, 'address is invalid for the configured chain' unless validation[:isvalid] == true && validation[:scriptPubKey].is_a?(String)
|
|
453
|
+
|
|
454
|
+
state = { activity: [], missing: [], cache: {}, budget: budget, script: validation[:scriptPubKey], timeout: timeout }
|
|
455
|
+
on_block = ->(block) { address_block(block: block, state: state) }
|
|
456
|
+
result = scan_page(bounds: bounds, verbosity: 3, timeout: timeout, on_block: on_block)
|
|
457
|
+
result.merge(
|
|
458
|
+
address: address, address_index: false, activity: state[:activity],
|
|
459
|
+
missing_prevouts: state[:missing], complete: result[:complete] && state[:missing].empty?
|
|
460
|
+
)
|
|
461
|
+
end
|
|
235
462
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
463
|
+
private_class_method def self.address_block(opts = {})
|
|
464
|
+
block = opts[:block]
|
|
465
|
+
state = opts[:state]
|
|
466
|
+
block[:tx].each do |tx|
|
|
467
|
+
state[:cache][tx[:txid]] = tx
|
|
468
|
+
evidence = { txid: tx[:txid], blockhash: block[:hash], height: block[:height] }
|
|
469
|
+
tx[:vout].each do |output|
|
|
470
|
+
next unless output.dig(:scriptPubKey, :hex) == state[:script]
|
|
241
471
|
|
|
242
|
-
|
|
243
|
-
txs.concat(block_res[:result][:tx])
|
|
472
|
+
state[:activity] << evidence.merge(type: :received, vout: output[:n], value_sats: satoshis(value: output[:value]))
|
|
244
473
|
end
|
|
245
|
-
|
|
474
|
+
tx[:vin].each_with_index do |input, index|
|
|
475
|
+
next if input.key?(:coinbase)
|
|
246
476
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
477
|
+
prevout, reason = resolve_prevout(input: input, cache: state[:cache], budget: state[:budget], timeout: state[:timeout])
|
|
478
|
+
unless prevout
|
|
479
|
+
state[:missing] << { txid: input[:txid], vout: input[:vout], spending_txid: tx[:txid], reason: reason }
|
|
480
|
+
next
|
|
481
|
+
end
|
|
482
|
+
next unless prevout.dig(:scriptPubKey, :hex) == state[:script]
|
|
483
|
+
|
|
484
|
+
state[:activity] << evidence.merge(type: :spent, vin: index, prev_txid: input[:txid], prev_vout: input[:vout], value_sats: satoshis(value: prevout[:value]))
|
|
485
|
+
end
|
|
486
|
+
end
|
|
250
487
|
end
|
|
251
488
|
|
|
252
489
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
253
|
-
|
|
254
490
|
public_class_method def self.authors
|
|
255
|
-
|
|
256
|
-
0day Inc. <support@0dayinc.com>
|
|
257
|
-
"
|
|
491
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
258
492
|
end
|
|
259
493
|
|
|
260
|
-
# Display
|
|
261
|
-
|
|
494
|
+
# Display usage for this module.
|
|
262
495
|
public_class_method def self.help
|
|
263
496
|
puts "USAGE:
|
|
264
|
-
#
|
|
497
|
+
# Return the legacy blockchain JSON-RPC envelope without AI analysis.
|
|
265
498
|
#{self}.get_latest_block
|
|
266
499
|
|
|
267
|
-
#
|
|
500
|
+
# Read chain synchronization and pruning status.
|
|
501
|
+
#{self}.chain_status(timeout: 'optional - RPC timeout seconds, 1..300; default 30')
|
|
502
|
+
|
|
503
|
+
# Read a block by height with Core verbosity.
|
|
268
504
|
#{self}.get_block_details(
|
|
269
|
-
height: '
|
|
505
|
+
height: 'optional - nonnegative integer height; default current tip',
|
|
506
|
+
verbosity: 'optional - integer 0..3; default 1; 3 requires undo data',
|
|
507
|
+
timeout: 'optional - RPC timeout seconds, 1..300; default 30'
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Inspect exact transaction amounts and fees only when all input values are known.
|
|
511
|
+
#{self}.inspect_transaction(
|
|
512
|
+
txid: 'required - 64-character hexadecimal transaction hash',
|
|
513
|
+
blockhash: 'optional - containing block hash for root lookup without txindex',
|
|
514
|
+
max_prevouts: 'optional - parent RPC lookup budget 0..1000; default 100',
|
|
515
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Check current UTXO membership; absence means spent or unknown, not proof of spending.
|
|
519
|
+
#{self}.inspect_outpoint(
|
|
520
|
+
txid: 'required - 64-character hexadecimal transaction hash',
|
|
521
|
+
vout: 'required - nonnegative integer output index',
|
|
522
|
+
include_mempool: 'optional - boolean include mempool effects; default true',
|
|
523
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Read aggregate mempool statistics without enumerating transactions.
|
|
527
|
+
#{self}.mempool_summary(timeout: 'optional - per-RPC timeout seconds, 1..300; default 30')
|
|
528
|
+
|
|
529
|
+
# Trace ancestor input references, not ownership, change, or later output allocation.
|
|
530
|
+
#{self}.trace_transaction(
|
|
531
|
+
txid: 'required - 64-character hexadecimal root transaction hash',
|
|
532
|
+
blockhash: 'optional - containing block hash for root lookup without txindex',
|
|
533
|
+
max_depth: 'optional - ancestor depth 0..100; default 3',
|
|
534
|
+
max_transactions: 'optional - total transaction RPC budget 1..1000; default 100',
|
|
535
|
+
max_edges: 'optional - evidence edge cap 1..10000; default 1000',
|
|
536
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Scan each explicit height for UTC dates; timestamps are not monotonic. Returns evidence and next_height.
|
|
540
|
+
#{self}.scan_transactions(
|
|
541
|
+
from: 'required - inclusive UTC date YYYY-MM-DD',
|
|
542
|
+
to: 'required - inclusive UTC date YYYY-MM-DD',
|
|
543
|
+
start_height: 'required - first nonnegative integer height; resume with next_height',
|
|
544
|
+
end_height: 'required - final inclusive integer height; keep fixed across pages',
|
|
545
|
+
max_blocks: 'optional - examined blocks per page 1..1000; default 100',
|
|
546
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
270
547
|
)
|
|
271
548
|
|
|
272
|
-
#
|
|
549
|
+
# Return the legacy ID array only for complete explicit height ranges; incomplete scans raise.
|
|
273
550
|
#{self}.get_transactions(
|
|
274
|
-
from: 'required -
|
|
275
|
-
to: 'required -
|
|
551
|
+
from: 'required - inclusive UTC date YYYY-MM-DD',
|
|
552
|
+
to: 'required - inclusive UTC date YYYY-MM-DD',
|
|
553
|
+
start_height: 'required - first nonnegative integer height',
|
|
554
|
+
end_height: 'required - final inclusive integer height',
|
|
555
|
+
max_blocks: 'optional - full range budget 1..1000; default 100; larger ranges require scan_transactions',
|
|
556
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
276
557
|
)
|
|
277
558
|
|
|
278
|
-
#
|
|
559
|
+
# Scan address scripts in explicit blocks; no arbitrary address index, balance or wallet history is implied.
|
|
560
|
+
#{self}.scan_address_activity(
|
|
561
|
+
address: 'required - Bitcoin address validated for the configured chain',
|
|
562
|
+
start_height: 'required - first nonnegative integer height; resume with next_height',
|
|
563
|
+
end_height: 'required - final inclusive integer height; keep fixed across pages',
|
|
564
|
+
max_blocks: 'optional - examined blocks per page 1..1000; default 100',
|
|
565
|
+
max_prevouts: 'optional - total parent RPC lookups per page 0..1000; default 100',
|
|
566
|
+
timeout: 'optional - per-RPC timeout seconds, 1..300; default 30'
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
Scans return range-scoped completeness, missing evidence and chain anchors.
|
|
570
|
+
Recheck anchor hashes across pages; no multi-call atomic snapshot is promised.
|
|
571
|
+
Historical transaction lookups may require txindex; pruned data stays unavailable.
|
|
572
|
+
Configuration: PWN::Env[:plugins][:blockchain][:bitcoin] with rpc_host,
|
|
573
|
+
rpc_port, rpc_user, rpc_pass and optional rpc_scheme http or https.
|
|
574
|
+
HTTPS verifies certificates. HTTP is plaintext; use only a trusted local channel.
|
|
575
|
+
No prompts, AI, wallet mutation, broadcasting, or implicit full-chain scans.
|
|
576
|
+
|
|
577
|
+
# Return author information.
|
|
279
578
|
#{self}.authors
|
|
280
579
|
"
|
|
281
|
-
constants.sort
|
|
282
580
|
end
|
|
283
581
|
end
|
|
284
582
|
end
|