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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -1,284 +1,582 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'base64'
4
3
  require 'json'
5
- require 'rest-client'
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
- # This plugin interacts with BitCoin's Blockchain API.
12
+ # Read-only Bitcoin Core intelligence. No wallet, signing or broadcast RPCs.
11
13
  module BTC
12
- # Supported Method Parameters::
13
- # btc_rest_call(
14
- # http_method: 'optional HTTP method (defaults to GET)
15
- # rest_call: 'required rest call to make per the schema',
16
- # params: 'optional params passed in the URI or HTTP Headers',
17
- # http_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',
18
- # timeout: 'optional timeout in seconds (defaults to 180)',
19
- # spinner: 'optional - display spinner (defaults to false)'
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
- private_class_method def self.btc_rest_call(opts = {})
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
- rpc_host = blockchain[:rpc_host] ||= '127.0.0.1'
27
- rpc_port = blockchain[:rpc_port] ||= '8332'
28
- base_uri = "http://#{rpc_host}:#{rpc_port}"
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
- rpc_user = blockchain[:rpc_user] ||= PWN::Plugins::AuthenticationHelper.username(prompt: 'Bitcoin Node RPC Username')
31
- rpc_pass = blockchain[:rpc_pass] ||= PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Bitcoin Node RPC Password')
33
+ value
34
+ end
32
35
 
33
- http_method = opts[:http_method] ||= :get
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
- rest_call = opts[:rest_call].to_s.scrub
36
- params = opts[:params]
40
+ value
41
+ end
37
42
 
38
- basic_auth = Base64.strict_encode64("#{rpc_user}:#{rpc_pass}")
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
- headers = {
41
- content_type: 'application/json; charset=UTF-8',
42
- authorization: "Basic #{basic_auth}"
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
- http_body = opts[:http_body]
46
- http_body ||= {}
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
- timeout = opts[:timeout]
49
- timeout ||= 180
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
- spinner = opts[:spinner] || false
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
- browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
54
- rest_client = browser_obj[:browser]::Request
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
- spin = PWN::Plugins::TTYSpinner.start if spinner
128
+ (amount * 100_000_000).to_i
129
+ end
57
130
 
58
- retries = 0
59
- case http_method.to_s.scrub.downcase.to_sym
60
- when :delete, :get
61
- headers[:params] = params
62
- response = rest_client.execute(
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
- when :post
71
- if http_body.key?(:multipart)
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
- else
94
- raise "Unsupported HTTP Method #{http_method} for #{self} Plugin"
95
- end
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
- response
98
- rescue RestClient::ExceptionWithResponse => e
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
- rescue StandardError => e
106
- raise e
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.btc_rpc_call(opts = {})
112
- method = opts[:method]
113
- params = opts[:params] ||= []
114
- http_body = {
115
- jsonrpc: '1.0',
116
- id: self,
117
- method: method,
118
- params: params
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
- response = btc_rest_call(
122
- http_method: :post,
123
- http_body: http_body
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
- res = JSON.parse(response.body, symbolize_names: true)
127
- raise "RPC Error: #{res[:error][:code]} - #{res[:error][:message]}" if res[:error]
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
- res
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
- private_class_method def self.get_block_timestamp(opts = {})
135
- height = opts[:height]
136
- res = btc_rpc_call(method: 'getblockhash', params: [height])
137
- block_hash = res[:result]
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
- res = btc_rpc_call(method: 'getblockheader', params: [block_hash])
140
- res[:result][:time]
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.find_first_block_ge_timestamp(opts = {})
144
- target_ts = opts[:target_ts]
145
- chain_info = get_latest_block[:result]
146
- low = 0
147
- high = chain_info[:blocks]
148
- result = nil
149
- while low <= high
150
- mid = (low + high) / 2
151
- ts = get_block_timestamp(height: mid)
152
- if ts >= target_ts
153
- result = mid
154
- high = mid - 1
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
- low = mid + 1
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.find_last_block_le_timestamp(opts = {})
163
- target_ts = opts[:target_ts]
164
- chain_info = get_latest_block[:result]
165
- low = 0
166
- high = chain_info[:blocks]
167
- result = nil
168
- while low <= high
169
- mid = (low + high) / 2
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
- high = mid - 1
306
+ missing << { txid: input[:txid], vout: input[:vout], spending_txid: edge[:to_txid], reason: reason }
176
307
  end
177
308
  end
178
- result
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
- # Supported Method Parameters::
182
- # latest_block = PWN::Blockchain::BTC.get_latest_block
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
- public_class_method def self.get_latest_block
185
- latest_block = btc_rpc_call(method: 'getblockchaininfo', params: [])
186
- ai_analysis = PWN::AI::Agent::BTC.analyze(
187
- request: latest_block.to_s
188
- )
189
- puts ai_analysis
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
- latest_block
192
- rescue StandardError => e
193
- raise e
334
+ get_block_details(opts.merge(verbosity: 2))
194
335
  end
195
336
 
196
- # Supported Method Parameters::
197
- # PWN::Blockchain::BTC.get_block_details(
198
- # height: 'required - block height as an integer (0 for genesis block / Defaults to latest block)'
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
- public_class_method def self.get_block_details(opts = {})
202
- latest_block = get_latest_block[:result][:blocks]
203
- height = opts[:height] ||= latest_block
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
- raise "ERROR: height must be >= 0 && <= #{latest_block}" if height.negative? || height > latest_block
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
- hash_res = btc_rpc_call(method: 'getblockhash', params: [height])
208
- block_hash = hash_res[:result]
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
- # Verbosity 1: block details with tx IDs
211
- block_res = btc_rpc_call(method: 'getblock', params: [block_hash, 1])
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
- block_res[:result]
214
- rescue StandardError => e
215
- raise e
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
- # transactions = PWN::Blockchain::BTC.get_transactions(
220
- # from: 'required - start date in YYYY-MM-DD format',
221
- # to: 'required - end date in YYYY-MM-DD format'
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
- from_date = opts[:from]
226
- to_date = opts[:to]
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
- raise ArgumentError, 'from and to dates are required' if from_date.nil? || to_date.nil?
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
- start_ts = Date.parse(from_date).to_time.to_i
231
- end_ts = Date.parse(to_date).to_time.to_i + 86_399 # Include the entire end day
431
+ result[:transactions].map { |tx| tx[:txid] }
432
+ end
232
433
 
233
- start_height = find_first_block_ge_timestamp(target_ts: start_ts)
234
- end_height = find_last_block_le_timestamp(target_ts: end_ts)
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
- txs = []
237
- if start_height && end_height && start_height <= end_height
238
- (start_height..end_height).each do |height|
239
- block_hash_res = btc_rpc_call(method: 'getblockhash', params: [height])
240
- block_hash = block_hash_res[:result]
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
- block_res = btc_rpc_call(method: 'getblock', params: [block_hash, 1])
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
- end
474
+ tx[:vin].each_with_index do |input, index|
475
+ next if input.key?(:coinbase)
246
476
 
247
- txs
248
- rescue StandardError => e
249
- raise e
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
- "AUTHOR(S):
256
- 0day Inc. <support@0dayinc.com>
257
- "
491
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
258
492
  end
259
493
 
260
- # Display Usage for this Module
261
-
494
+ # Display usage for this module.
262
495
  public_class_method def self.help
263
496
  puts "USAGE:
264
- # Run get latest block and return its result
497
+ # Return the legacy blockchain JSON-RPC envelope without AI analysis.
265
498
  #{self}.get_latest_block
266
499
 
267
- # Run get block details and return its result
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: 'required - block height as an integer (0 for genesis block / Defaults to latest block)'
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
- # Run get transactions and return its result
549
+ # Return the legacy ID array only for complete explicit height ranges; incomplete scans raise.
273
550
  #{self}.get_transactions(
274
- from: 'required - start date in YYYY-MM-DD format',
275
- to: 'required - end date in YYYY-MM-DD format'
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
- # Print the AUTHOR(S) string for this module.
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