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
@@ -2,7 +2,378 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- describe PWN::Blockchain::BTC do
5
+ describe PWN::Blockchain::BTC do # rubocop:disable Metrics/BlockLength
6
+ let(:config) { { rpc_host: 'localhost', rpc_port: 8332, rpc_user: 'alice', rpc_pass: 'secret' }.freeze }
7
+ let(:http) { instance_double(Net::HTTP) }
8
+
9
+ before do
10
+ stub_const('PWN::Env', { plugins: { blockchain: { bitcoin: config } } })
11
+ allow(Net::HTTP).to receive(:new).and_return(http)
12
+ allow(http).to receive(:open_timeout=)
13
+ allow(http).to receive(:read_timeout=)
14
+ allow(http).to receive(:write_timeout=)
15
+ allow(http).to receive(:use_ssl=)
16
+ allow(http).to receive(:verify_mode=)
17
+ end
18
+
19
+ def rpc_response(result: nil, error: nil, status: '200')
20
+ double('response', code: status, body: JSON.generate(result: result, error: error, id: 'pwn-btc'))
21
+ end
22
+
23
+ it 'returns the legacy envelope silently without AI or config mutation' do
24
+ allow(http).to receive(:request).and_return(rpc_response(result: { blocks: 10 }))
25
+ expect { expect(described_class.get_latest_block[:result]).to eq(blocks: 10) }.not_to output.to_stdout
26
+ end
27
+
28
+ it 'preserves HTTP 500 RPC error codes without echoing server secrets' do
29
+ allow(http).to receive(:request).and_return(rpc_response(error: { code: -5, message: 'secret alice' }, status: '500'))
30
+ expect { described_class.get_latest_block }.to raise_error(described_class::RPCError) { |e|
31
+ expect(e.code).to eq(-5)
32
+ expect(e.http_status).to eq(500)
33
+ expect(e.message).not_to include('secret', 'alice')
34
+ }
35
+ end
36
+
37
+ it 'uses verified TLS, bounded timeouts and authenticated POST' do
38
+ config = self.config.merge(rpc_scheme: 'https')
39
+ stub_const('PWN::Env', { plugins: { blockchain: { bitcoin: config } } })
40
+ expect(http).to receive(:use_ssl=).with(true)
41
+ expect(http).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_PEER)
42
+ expect(http).to receive(:read_timeout=).with(30)
43
+ expect(http).to receive(:request) do |request|
44
+ expect(request).to be_a(Net::HTTP::Post)
45
+ expect(JSON.parse(request.body)['method']).to eq('getblockchaininfo')
46
+ rpc_response(result: { blocks: 0 })
47
+ end
48
+ described_class.get_latest_block
49
+ end
50
+
51
+ it 'sanitizes transport failures and suppresses their causes' do
52
+ allow(http).to receive(:request).and_raise(IOError, 'secret alice')
53
+ expect { described_class.get_latest_block }.to raise_error(described_class::RPCError) { |e|
54
+ expect(e.message).not_to include('secret', 'alice')
55
+ expect(e.cause).to be_nil
56
+ }
57
+ end
58
+ let(:txid) { 'a' * 64 }
59
+ let(:parent_id) { 'b' * 64 }
60
+ let(:blockhash) { 'c' * 64 }
61
+ let(:parent_tx) { { txid: parent_id, vin: [{ coinbase: '00' }], vout: [{ n: 0, value: '0.10000001', scriptPubKey: { address: 'test' } }] } }
62
+ let(:transaction) { { txid: txid, vin: [{ txid: parent_id, vout: 0 }], vout: [{ n: 0, value: '0.10000000', scriptPubKey: { address: 'test' } }] } }
63
+
64
+ def serve_rpc(&block)
65
+ allow(http).to receive(:request) do |request|
66
+ payload = JSON.parse(request.body, symbolize_names: true)
67
+ result = block.call(payload[:method], payload[:params])
68
+ rpc_response(result: result)
69
+ end
70
+ end
71
+
72
+ it 'inspects exact satoshis, resolves prevouts and computes fees only with evidence' do
73
+ serve_rpc do |method, params|
74
+ expect(method).to eq('getrawtransaction')
75
+ params.first == txid ? transaction : parent_tx
76
+ end
77
+ result = described_class.inspect_transaction(txid: txid)
78
+ expect(result[:outputs].first[:value_sats]).to eq(10_000_000)
79
+ expect(result[:inputs].first[:value_sats]).to eq(10_000_001)
80
+ expect(result[:fee_sats]).to eq(1)
81
+ expect(result[:missing_prevouts]).to be_empty
82
+ end
83
+
84
+ it 'reports unavailable prevouts, does not invent fees and bounds lookups' do
85
+ serve_rpc { transaction }
86
+ result = described_class.inspect_transaction(txid: txid, max_prevouts: 0)
87
+ expect(result[:fee_sats]).to be_nil
88
+ expect(result[:missing_prevouts]).to eq([{ txid: parent_id, vout: 0, reason: :lookup_limit }])
89
+ expect(http).to have_received(:request).once
90
+ end
91
+
92
+ it 'passes an optional block hash for non-indexed transaction lookup' do
93
+ serve_rpc do |_method, params|
94
+ expect(params).to eq([txid, true, blockhash])
95
+ transaction.merge(vin: [{ coinbase: '00' }])
96
+ end
97
+ result = described_class.inspect_transaction(txid: txid, blockhash: blockhash)
98
+ expect(result[:coinbase]).to be(true)
99
+ expect(result[:fee_sats]).to be_nil
100
+ end
101
+
102
+ it 'does not hide authentication errors as missing transactions' do
103
+ allow(http).to receive(:request).and_return(rpc_response(result: transaction), rpc_response(error: { code: -32_600 }, status: '401'))
104
+ expect { described_class.inspect_transaction(txid: txid) }.to raise_error(described_class::RPCError)
105
+ end
106
+
107
+ it 'inspects an unspent outpoint and distinguishes null from proof of spending' do
108
+ serve_rpc do |method, params|
109
+ expect(method).to eq('gettxout')
110
+ expect(params).to eq([txid, 0, false])
111
+ { value: '0.00000001', confirmations: 4 }
112
+ end
113
+ expect(described_class.inspect_outpoint(txid: txid, vout: 0, include_mempool: false)[:value_sats]).to eq(1)
114
+ serve_rpc { nil }
115
+ expect(described_class.inspect_outpoint(txid: txid, vout: 0)).to include(status: :spent_or_unknown, unspent: false)
116
+ end
117
+
118
+ it 'reads mempool statistics without fetching all transactions' do
119
+ serve_rpc do |method, params|
120
+ expect([method, params]).to eq(['getmempoolinfo', []])
121
+ { size: 10, bytes: 2345, loaded: true }
122
+ end
123
+ expect(described_class.mempool_summary[:size]).to eq(10)
124
+ end
125
+
126
+ it 'rejects malformed identifiers, indices and flags before issuing RPC' do
127
+ expect(http).not_to receive(:request)
128
+ expect { described_class.inspect_transaction(txid: 'bad') }.to raise_error(ArgumentError)
129
+ expect { described_class.inspect_outpoint(txid: txid, vout: '0') }.to raise_error(ArgumentError)
130
+ expect { described_class.inspect_outpoint(txid: txid, vout: 0, include_mempool: 'false') }.to raise_error(ArgumentError)
131
+ expect { described_class.get_block_details(height: -1) }.to raise_error(ArgumentError)
132
+ expect { described_class.get_block_details(height: 0, verbosity: 4) }.to raise_error(ArgumentError)
133
+ end
134
+
135
+ it 'traces ancestors with spend-reference evidence and exact values' do
136
+ serve_rpc { |_method, params| params.first == txid ? transaction : parent_tx }
137
+ result = described_class.trace_transaction(txid: txid)
138
+ expect(result[:nodes].map { |node| node[:txid] }).to eq([txid, parent_id])
139
+ expect(result[:edges]).to eq([{ from_txid: parent_id, from_vout: 0, to_txid: txid, to_vin: 0, value_sats: 10_000_001, evidence: :vin_reference }])
140
+ expect(result[:complete]).to be(true)
141
+ expect(http).to have_received(:request).twice
142
+ end
143
+
144
+ it 'reports depth and transaction limits without fetching beyond either bound' do
145
+ serve_rpc { transaction }
146
+ result = described_class.trace_transaction(txid: txid, max_depth: 0)
147
+ expect(result).to include(complete: false, truncated: true)
148
+ expect(result[:truncation_reasons]).to include(:max_depth)
149
+ expect(result[:missing_prevouts].first).to include(txid: parent_id, vout: 0)
150
+ expect(http).to have_received(:request).once
151
+ end
152
+
153
+ it 'bounds edges and reports omitted input evidence explicitly' do
154
+ serve_rpc { transaction.merge(vin: [{ txid: parent_id, vout: 0 }, { txid: parent_id, vout: 1 }]) }
155
+ result = described_class.trace_transaction(txid: txid, max_edges: 1, max_transactions: 1)
156
+ expect(result[:edges].length).to eq(1)
157
+ expect(result[:truncation_reasons]).to include(:max_edges, :max_transactions)
158
+ expect(http).to have_received(:request).once
159
+ end
160
+
161
+ it 'reports unavailable ancestors rather than claiming a complete trace' do
162
+ allow(http).to receive(:request).and_return(rpc_response(result: transaction), rpc_response(error: { code: -5 }, status: '500'))
163
+ result = described_class.trace_transaction(txid: txid)
164
+ expect(result[:complete]).to be(false)
165
+ expect(result[:missing_transactions]).to eq([{ txid: parent_id, code: -5 }])
166
+ expect(result[:missing_prevouts].first[:reason]).to eq(:unavailable)
167
+ end
168
+
169
+ def serve_chain(times:, transactions: nil, reorg: false)
170
+ anchor_reads = 0
171
+ serve_rpc do |method, params|
172
+ case method
173
+ when 'getblockchaininfo' then { blocks: times.length - 1, bestblockhash: blockhash, chain: 'regtest' }
174
+ when 'getblockhash'
175
+ anchor_reads += 1 if params.first == times.length - 1
176
+ reorg && anchor_reads > 2 ? 'f' * 64 : format('%064x', params.first)
177
+ when 'getblock'
178
+ height = params.first.to_i(16)
179
+ { hash: params.first, height: height, previousblockhash: format('%064x', height - 1), time: times[height], tx: transactions || [format('%064x', height + 100)] }
180
+ when 'validateaddress' then { isvalid: true, scriptPubKey: '76a91400' }
181
+ else raise "Unexpected RPC #{method}"
182
+ end
183
+ end
184
+ end
185
+
186
+ it 'scans every explicit height despite nonmonotonic timestamps and uses UTC days' do
187
+ serve_chain(times: [1_704_067_200, 1_704_240_000, 1_704_067_210])
188
+ result = described_class.scan_transactions(from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 2)
189
+ expect(result[:transactions].map { |tx| tx[:height] }).to eq([0, 2])
190
+ expect(result).to include(complete: true, next_height: nil, reorg_detected: false)
191
+ expect(result[:blocks].length).to eq(3)
192
+ expect(result[:scope]).to eq(:explicit_height_range)
193
+ end
194
+
195
+ it 'returns bounded pages with explicit continuation instead of silently omitting dates' do
196
+ serve_chain(times: [1_704_067_200] * 3)
197
+ result = described_class.scan_transactions(from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 2, max_blocks: 1)
198
+ expect(result).to include(complete: false, truncated: true, next_height: 1)
199
+ expect(result[:transactions].length).to eq(1)
200
+ end
201
+
202
+ it 'preserves the legacy array only for a fully scanned explicit range' do
203
+ serve_chain(times: [1_704_067_200])
204
+ expect(described_class.get_transactions(from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 0)).to eq([format('%064x', 100)])
205
+ expect { described_class.get_transactions(from: '2024-01-01', to: '2024-01-01') }.to raise_error(ArgumentError)
206
+ expect { described_class.get_transactions(from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 2, max_blocks: 1) }.to raise_error(ArgumentError)
207
+ end
208
+
209
+ it 'detects anchor changes and never labels reorganized scans complete' do
210
+ serve_chain(times: [1_704_067_200], reorg: true)
211
+ result = described_class.scan_transactions(from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 0)
212
+ expect(result).to include(reorg_detected: true, complete: false)
213
+ end
214
+
215
+ it 'requires strict dates and explicit integer height ranges before RPC' do
216
+ expect(http).not_to receive(:request)
217
+ expect { described_class.scan_transactions(from: '2024-02-30', to: '2024-03-01', start_height: 0, end_height: 1) }.to raise_error(ArgumentError)
218
+ expect { described_class.scan_transactions(from: '2024-01-02', to: '2024-01-01', start_height: 0, end_height: 1) }.to raise_error(ArgumentError)
219
+ expect { described_class.scan_transactions(from: '2024-01-01', to: '2024-01-01') }.to raise_error(ArgumentError)
220
+ end
221
+
222
+ it 'scans address scripts with receive and spend evidence, not an address index' do
223
+ parent = parent_tx.merge(vout: [{ n: 0, value: '0.10000001', scriptPubKey: { hex: '76a91400' } }])
224
+ spend = transaction.merge(vout: [{ n: 0, value: '0.10000000', scriptPubKey: { hex: 'other' } }])
225
+ serve_chain(times: [1_704_067_200], transactions: [parent, spend])
226
+ result = described_class.scan_address_activity(address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', start_height: 0, end_height: 0, max_prevouts: 0)
227
+ expect(result[:activity].map { |event| event[:type] }).to eq(%i[received spent])
228
+ expect(result[:activity].map { |event| event[:value_sats] }).to eq([10_000_001, 10_000_001])
229
+ expect(result[:complete]).to be(true)
230
+ expect(result[:address_index]).to be(false)
231
+ end
232
+
233
+ it 'uses undo-rich blocks without spending the available parent lookup budget' do
234
+ prevout = { value: '0.10000001', scriptPubKey: { hex: '76a91400' } }
235
+ spend = transaction.merge(vin: [{ txid: parent_id, vout: 0, prevout: prevout }])
236
+ serve_chain(times: [1_704_067_200], transactions: [spend])
237
+ expect(described_class).to receive(:get_block_details).with({ height: 0, verbosity: 3, timeout: 30 }).and_call_original
238
+ result = described_class.scan_address_activity(address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', start_height: 0, end_height: 0, max_prevouts: 5)
239
+ expect(result).to include(complete: true, missing_prevouts: [])
240
+ expect(result[:activity]).to include(include(type: :spent, value_sats: 10_000_001, prev_txid: parent_id))
241
+ expect(http).to have_received(:request).exactly(7).times
242
+ end
243
+
244
+ [-8, -32_602].each do |code|
245
+ it "falls back to verbosity 2 for older-node parameter error #{code}" do
246
+ parent = parent_tx.merge(vout: [{ n: 0, value: '0.10000001', scriptPubKey: { hex: '76a91400' } }])
247
+ serve_chain(times: [1_704_067_200], transactions: [parent, transaction])
248
+ allow(described_class).to receive(:btc_rpc_call).and_wrap_original do |original, opts|
249
+ raise described_class::RPCError.new(rpc_method: 'getblock', code: code, http_status: 500) if opts[:method] == 'getblock' && opts[:params].last == 3
250
+
251
+ original.call(opts)
252
+ end
253
+ result = described_class.scan_address_activity(address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', start_height: 0, end_height: 0, max_prevouts: 0)
254
+ expect(result).to include(complete: true, missing_prevouts: [])
255
+ expect(result[:activity].map { |event| event[:type] }).to eq(%i[received spent])
256
+ expect(described_class).to have_received(:btc_rpc_call).with(method: 'getblock', params: [format('%064x', 0), 2], timeout: 30).once
257
+ end
258
+ end
259
+
260
+ it 'does not downgrade unavailable undo data into a successful address scan' do
261
+ serve_chain(times: [1_704_067_200], transactions: [transaction])
262
+ allow(described_class).to receive(:btc_rpc_call).and_wrap_original do |original, opts|
263
+ if opts[:method] == 'getblock'
264
+ expect(opts[:params].last).to eq(3)
265
+ raise described_class::RPCError.new(rpc_method: 'getblock', code: -1, http_status: 500)
266
+ end
267
+
268
+ original.call(opts)
269
+ end
270
+ result = described_class.scan_address_activity(address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', start_height: 0, end_height: 0)
271
+ expect(result).to include(complete: false, missing_blocks: [{ height: 0, code: -1 }])
272
+ end
273
+
274
+ it 'reports unknown address spends when parent data is missing' do
275
+ serve_chain(times: [1_704_067_200], transactions: [transaction])
276
+ result = described_class.scan_address_activity(address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', start_height: 0, end_height: 0, max_prevouts: 0)
277
+ expect(result[:complete]).to be(false)
278
+ expect(result[:missing_prevouts].first).to include(txid: parent_id, reason: :lookup_limit)
279
+ end
280
+
281
+ it 'accepts uppercase hashes without losing trace evidence or rewriting the RPC token' do
282
+ serve_rpc do |_method, params|
283
+ expect(params.first).to eq(txid.upcase)
284
+ transaction
285
+ end
286
+ result = described_class.trace_transaction(txid: txid.upcase, max_depth: 0)
287
+ expect(result[:edges].first[:to_txid]).to eq(txid)
288
+ end
289
+
290
+ it 'decodes JSON numeric amounts without a Float roundtrip' do
291
+ body = JSON.generate(result: transaction.merge(vin: [{ coinbase: '00' }]), error: nil, id: 'pwn-btc').sub('"0.10000000"', '20999999.99999999')
292
+ allow(http).to receive(:request).and_return(double('response', code: '200', body: body))
293
+ expect(described_class.inspect_transaction(txid: txid)[:output_sats]).to eq(2_099_999_999_999_999)
294
+ end
295
+
296
+ it 'rejects sub-satoshi values rather than rounding them' do
297
+ bad = transaction.merge(vin: [{ coinbase: '00' }], vout: [{ n: 0, value: '0.000000001' }])
298
+ serve_rpc { bad }
299
+ expect { described_class.inspect_transaction(txid: txid) }.to raise_error(described_class::RPCError, /invalid monetary/)
300
+ end
301
+
302
+ it 'reuses a single parent lookup across several inputs' do
303
+ parent = parent_tx.merge(vout: [{ n: 0, value: '0.10000001' }, { n: 1, value: '0.2' }])
304
+ spend = transaction.merge(vin: [{ txid: parent_id, vout: 0 }, { txid: parent_id, vout: 1 }])
305
+ serve_rpc { |_method, params| params.first == txid ? spend : parent }
306
+ expect(described_class.inspect_transaction(txid: txid, max_prevouts: 1)[:input_sats]).to eq(30_000_001)
307
+ expect(http).to have_received(:request).twice
308
+ end
309
+
310
+ it 'returns pruning gaps as incomplete date evidence and raises in legacy array mode' do
311
+ serve_chain(times: [1_704_067_200])
312
+ allow(described_class).to receive(:get_block_details).and_raise(described_class::RPCError.new(code: -1, http_status: 500))
313
+ opts = { from: '2024-01-01', to: '2024-01-01', start_height: 0, end_height: 0 }
314
+ result = described_class.scan_transactions(opts)
315
+ expect(result).to include(complete: false, missing_blocks: [{ height: 0, code: -1 }])
316
+ expect { described_class.get_transactions(opts) }.to raise_error(described_class::RPCError, /incomplete scan/)
317
+ end
318
+
319
+ it 'returns block serialization or transaction verbosity as explicitly requested' do
320
+ [0, 1, 2, 3].each do |verbosity|
321
+ serve_rpc do |method, params|
322
+ if method == 'getblockhash'
323
+ expect(params).to eq([0])
324
+ blockhash
325
+ else
326
+ expect(params).to eq([blockhash, verbosity])
327
+ verbosity.zero? ? 'deadbeef' : { hash: blockhash, tx: [] }
328
+ end
329
+ end
330
+ result = described_class.get_block_details(height: 0, verbosity: verbosity)
331
+ expect(result).to eq(verbosity.zero? ? 'deadbeef' : { hash: blockhash, tx: [] })
332
+ end
333
+ end
334
+
335
+ it 'rejects malformed JSON, mismatched IDs and non-JSON HTTP failures without leaking bodies' do
336
+ ['secret alice', '{"id":"other","result":"secret"}', '[]'].each do |body|
337
+ allow(http).to receive(:request).and_return(double('response', code: '503', body: body))
338
+ expect { described_class.get_latest_block }.to raise_error(described_class::RPCError) { |e|
339
+ expect(e.http_status).to eq(503)
340
+ expect(e.message).not_to include('secret', 'alice')
341
+ expect(e.cause).to be_nil
342
+ }
343
+ end
344
+ end
345
+
346
+ it 'rejects absent credentials, unsafe hosts, invalid ports and timeouts without connecting' do
347
+ expect(http).not_to receive(:request)
348
+ [config.merge(rpc_pass: nil), config.merge(rpc_host: 'http://secret@host'), config.merge(rpc_port: '8332oops')].each do |invalid|
349
+ stub_const('PWN::Env', { plugins: { blockchain: { bitcoin: invalid.freeze } } })
350
+ expect { described_class.get_latest_block }.to raise_error(ArgumentError)
351
+ end
352
+ stub_const('PWN::Env', { plugins: { blockchain: { bitcoin: config } } })
353
+ expect { described_class.chain_status(timeout: 0) }.to raise_error(ArgumentError)
354
+ expect { described_class.chain_status(timeout: '30') }.to raise_error(ArgumentError)
355
+ end
356
+
357
+ it 'rejects write RPCs even at the private transport boundary' do
358
+ expect(http).not_to receive(:request)
359
+ expect { described_class.send(:btc_rpc_call, method: 'sendrawtransaction', params: ['00']) }.to raise_error(ArgumentError)
360
+ end
361
+
362
+ it 'sanitizes connection setup exceptions as well as request failures' do
363
+ allow(Net::HTTP).to receive(:new).and_raise(IOError, 'secret alice')
364
+ expect { described_class.get_latest_block }.to raise_error(described_class::RPCError) { |e|
365
+ expect(e.message).not_to include('secret', 'alice')
366
+ expect(e.cause).to be_nil
367
+ }
368
+ end
369
+
370
+ it 'enforces a total request deadline even when a peer keeps dribbling data' do
371
+ allow(http).to receive(:request) { sleep 2 }
372
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
373
+ expect { described_class.chain_status(timeout: 1) }.to raise_error(described_class::RPCError, /transport/)
374
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1.8
375
+ end
376
+
6
377
  it 'should display information for authors' do
7
378
  authors_response = PWN::Blockchain::BTC
8
379
  expect(authors_response).to respond_to :authors
@@ -3,6 +3,211 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Blockchain::ETH do
6
+ let(:rpc_opts) { { rpc_url: 'https://node.example/secret' } }
7
+ let(:address) { "0x#{'ab' * 20}" }
8
+ let(:tx_hash) { "0x#{'cd' * 32}" }
9
+ let(:transfer_topic) { '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' }
10
+ let(:transfer_log) do
11
+ { address: address, topics: [transfer_topic, "0x#{'0' * 24}#{address[2..]}", "0x#{'0' * 64}"], data: "0x#{'0' * 63}7", blockNumber: '0x1', transactionHash: tx_hash }
12
+ end
13
+
14
+ it 'preserves BlockCypher APIs and token with verified TLS' do
15
+ allow(RestClient::Request).to receive(:execute) do |request|
16
+ expect(request[:verify_ssl]).to eq(OpenSSL::SSL::VERIFY_PEER)
17
+ expect(request[:headers][:params]).to eq(token: 'token')
18
+ double(body: '{"height":123}')
19
+ end
20
+ expect(described_class.get_latest_block(token: 'token')).to eq(height: 123)
21
+ expect(described_class.get_block_details(height: 123, token: 'token')).to eq(height: 123)
22
+ expect { described_class.get_block_details(height: '../bad') }.to raise_error(ArgumentError)
23
+ end
24
+
25
+ it 'redacts HTTP failures and JSON RPC provider messages including causes' do
26
+ allow(RestClient::Request).to receive(:execute).and_raise(RestClient::Forbidden.new)
27
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, 'Ethereum HTTP transport failed') { |error| expect(error.cause).to be_nil }
28
+ allow(RestClient::Request).to receive(:execute).and_return(double(body: '{"jsonrpc":"2.0","id":1,"error":{"message":"secret"}}'))
29
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, 'JSON-RPC provider returned an error')
30
+ end
31
+
32
+ it 'rejects bad envelopes, invalid quantities and invalid endpoints' do
33
+ allow(RestClient::Request).to receive(:execute).and_return(double(body: 'not json'))
34
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, /JSON response/)
35
+ allow(RestClient::Request).to receive(:execute).and_return(double(body: '{"jsonrpc":"2.0","id":2,"result":1}'))
36
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, /envelope/)
37
+ stub_rpc('eth_chainId' => '0x01')
38
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, /quantity/)
39
+ expect { described_class.chain_status(rpc_url: 'file:///secret') }.to raise_error(ArgumentError)
40
+ end
41
+
42
+ it 'marks reverted receipts and does not infer success from inclusion alone' do
43
+ stub_rpc('eth_getTransactionByHash' => { hash: tx_hash, value: '0x0', blockNumber: '0x1' },
44
+ 'eth_getTransactionReceipt' => { transactionHash: tx_hash, status: '0x0' })
45
+ expect(described_class.transaction(rpc_opts.merge(hash: tx_hash))[:status]).to eq(:reverted)
46
+ stub_rpc('eth_getTransactionByHash' => { hash: tx_hash, value: '0x0', blockNumber: '0x1' }, 'eth_getTransactionReceipt' => nil)
47
+ expect(described_class.transaction(rpc_opts.merge(hash: tx_hash))[:status]).to eq(:receipt_unavailable)
48
+ end
49
+
50
+ it 'decodes only standard Transfer layouts without claiming contract conformance' do
51
+ expect(described_class.decode_transfer(log: transfer_log)).to include(standard: :erc20, value: 7, from: address)
52
+ nft = transfer_log.merge(topics: transfer_log[:topics] + ["0x#{'0' * 63}9"], data: '0x')
53
+ expect(described_class.decode_transfer(log: nft)).to include(standard: :erc721, token_id: 9)
54
+ expect(described_class.decode_transfer(log: transfer_log.merge(topics: [tx_hash]))).to be_nil
55
+ expect { described_class.decode_transfer(log: transfer_log.merge(data: '0x01')) }.to raise_error(ArgumentError)
56
+ expect { described_class.decode_transfer(log: transfer_log.merge(topics: [transfer_topic, tx_hash, tx_hash])) }.to raise_error(ArgumentError)
57
+ end
58
+
59
+ it 'bounds logs and reports local truncation without provider completeness guarantees' do
60
+ stub_rpc('eth_getLogs' => [transfer_log, transfer_log])
61
+ result = described_class.event_logs(rpc_opts.merge(from_block: 1, to_block: 2, limit: 1))
62
+ expect(result).to include(truncated: true, complete: false, provider_completeness: :unverified)
63
+ expect(result[:logs].length).to eq(1)
64
+ expect { described_class.event_logs(rpc_opts.merge(from_block: 0, to_block: 10_001)) }.to raise_error(ArgumentError)
65
+ end
66
+
67
+ it 'paginates address activity by whole blocks and excludes traces explicitly' do
68
+ stub_rpc('eth_getBlockByNumber' => { number: '0x1', hash: tx_hash, transactions: [{ hash: tx_hash, from: address, to: nil, value: '0x1' }] })
69
+ result = described_class.address_activity(rpc_opts.merge(address: address, from_block: 1, to_block: 2, page_size: 1))
70
+ expect(result).to include(next_block: 2, complete: false, internal_traces: :not_covered)
71
+ expect(result[:transactions].length).to eq(1)
72
+ end
73
+
74
+ it 'rejects malformed unknown-event topics and out-of-range provider logs' do
75
+ stub_rpc('eth_getLogs' => [transfer_log.merge(topics: ['0xbad'])])
76
+ expect { described_class.event_logs(rpc_opts.merge(from_block: 1, to_block: 2)) }.to raise_error(ArgumentError)
77
+ stub_rpc('eth_getLogs' => [transfer_log.merge(blockNumber: '0x3')])
78
+ expect { described_class.event_logs(rpc_opts.merge(from_block: 1, to_block: 2)) }.to raise_error(described_class::RPCError)
79
+ end
80
+
81
+ it 'redacts malformed legacy response bodies and timeout details' do
82
+ allow(RestClient::Request).to receive(:execute).and_return(double(body: 'secret-invalid-json'))
83
+ expect { described_class.get_latest_block }.to raise_error(described_class::RPCError, 'Invalid BlockCypher JSON response') { |error| expect(error.cause).to be_nil }
84
+ allow(RestClient::Request).to receive(:execute).and_raise(Timeout::Error, 'https://node.example/secret')
85
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, 'Ethereum HTTP transport failed')
86
+ end
87
+
88
+ it 'rejects mismatched block identities and receipt inclusion contexts' do
89
+ stub_rpc('eth_getBlockByNumber' => { number: '0x2', hash: tx_hash })
90
+ expect { described_class.block(rpc_opts.merge(block: 1)) }.to raise_error(described_class::RPCError)
91
+ stub_rpc('eth_getTransactionByHash' => { hash: tx_hash, value: '0x0', blockNumber: '0x1', blockHash: tx_hash },
92
+ 'eth_getTransactionReceipt' => { transactionHash: tx_hash, status: '0x1', blockNumber: '0x2', blockHash: tx_hash })
93
+ expect { described_class.transaction(rpc_opts.merge(hash: tx_hash)) }.to raise_error(described_class::RPCError)
94
+ end
95
+
96
+ it 'resumes activity without duplicates and states when the requested range is exhausted' do
97
+ allow(RestClient::Request).to receive(:execute) do |request|
98
+ payload = JSON.parse(request[:payload])
99
+ height = payload['params'][0]
100
+ double(body: JSON.generate(jsonrpc: '2.0', id: 1, result: { number: height, hash: tx_hash, transactions: [] }))
101
+ end
102
+ page = described_class.address_activity(rpc_opts.merge(address: address, from_block: 1, to_block: 2, page_size: 1))
103
+ last = described_class.address_activity(rpc_opts.merge(address: address, from_block: page[:next_block], to_block: 2, page_size: 1))
104
+ expect(last).to include(complete: true, next_block: nil, from_block: 2)
105
+ end
106
+
107
+ it 'reads token metadata at a common block with optional unsupported fields' do
108
+ allow(RestClient::Request).to receive(:execute) do |request|
109
+ payload = JSON.parse(request[:payload])
110
+ result = if payload['method'] == 'eth_getBlockByNumber'
111
+ { number: '0x1', hash: tx_hash }
112
+ else
113
+ expect(payload['params'][1]).to eq('blockHash' => tx_hash, 'requireCanonical' => true)
114
+ case payload['params'][0]['data']
115
+ when '0x313ce567' then "0x#{18.to_s(16).rjust(64, '0')}"
116
+ when '0x18160ddd' then "0x#{'f' * 64}"
117
+ when '0x95d89b41' then "0x#{'TOK'.unpack1('H*').ljust(64, '0')}"
118
+ else '0x'
119
+ end
120
+ end
121
+ double(body: JSON.generate(jsonrpc: '2.0', id: 1, result: result))
122
+ end
123
+ result = described_class.token_metadata(rpc_opts.merge(contract: address))
124
+ expect(result).to include(decimals: 18, symbol: 'TOK', total_supply: (2**256) - 1, name: nil)
125
+ expect(result[:unavailable]).to include(:name)
126
+ end
127
+
128
+ it 'performs a pinned read-only contract call with exact byte output' do
129
+ stub_rpc('eth_getBlockByNumber' => { number: '0x1', hash: tx_hash }, 'eth_call' => '0x0001')
130
+ expect(described_class.call(rpc_opts.merge(contract: address, data: '0x18160ddd'))).to include(data: '0x0001', block_hash: tx_hash)
131
+ end
132
+
133
+ it 'inspects a mined transaction with exact execution and blob fees' do
134
+ stub_rpc('eth_getTransactionByHash' => { hash: tx_hash, from: address, to: nil, value: '0x20000000000001', blockNumber: '0x10' },
135
+ 'eth_getTransactionReceipt' => { transactionHash: tx_hash, status: '0x1', gasUsed: '0x5208', effectiveGasPrice: '0x100000000', blobGasUsed: '0x2', blobGasPrice: '0x3', contractAddress: address })
136
+ result = described_class.transaction(rpc_opts.merge(hash: tx_hash))
137
+ expect(result).to include(status: :success, value_wei: 9_007_199_254_740_993, execution_fee_wei: 90_194_313_216_000, blob_fee_wei: 6, contract_address: address, internal_traces: :not_covered)
138
+ end
139
+
140
+ it 'distinguishes pending and unknown transactions' do
141
+ stub_rpc('eth_getTransactionByHash' => { hash: tx_hash, value: '0x0', blockNumber: nil }, 'eth_getTransactionReceipt' => nil)
142
+ expect(described_class.transaction(rpc_opts.merge(hash: tx_hash))[:status]).to eq(:pending)
143
+ stub_rpc('eth_getTransactionByHash' => nil)
144
+ expect(described_class.transaction(rpc_opts.merge(hash: tx_hash))[:status]).to eq(:not_found)
145
+ end
146
+
147
+ it 'reads a block by hash and an account at an explicit block context' do
148
+ stub_rpc('eth_getBlockByHash' => { hash: tx_hash, number: '0x10' },
149
+ 'eth_getBlockByNumber' => { hash: tx_hash, number: '0x10' },
150
+ 'eth_getBalance' => '0x20000000000001', 'eth_getTransactionCount' => '0x3', 'eth_getCode' => '0x6000')
151
+ expect(described_class.block(rpc_opts.merge(block: tx_hash))[:block][:hash]).to eq(tx_hash)
152
+ expect(described_class.account(rpc_opts.merge(address: address))).to include(balance_wei: 9_007_199_254_740_993, nonce: 3, code: '0x6000', block_number: 16, block_hash: tx_hash)
153
+ end
154
+
155
+ it 'rejects malformed identifiers before transport' do
156
+ expect(RestClient::Request).not_to receive(:execute)
157
+ expect { described_class.transaction(rpc_opts.merge(hash: '0xabc')) }.to raise_error(ArgumentError)
158
+ expect { described_class.account(rpc_opts.merge(address: "#{address} ")) }.to raise_error(ArgumentError)
159
+ expect { described_class.block(rpc_opts.merge(block: -1)) }.to raise_error(ArgumentError)
160
+ end
161
+
162
+ def stub_rpc(results)
163
+ allow(RestClient::Request).to receive(:execute) do |request|
164
+ payload = JSON.parse(request[:payload])
165
+ expect(request[:method]).to eq(:post)
166
+ expect(request[:max_redirects]).to eq(0)
167
+ expect(request[:timeout]).to eq(30)
168
+ expect(request[:verify_ssl]).to eq(OpenSSL::SSL::VERIFY_PEER)
169
+ expect(request[:open_timeout]).to eq(10)
170
+ result = results.fetch(payload.fetch('method'))
171
+ double(body: JSON.generate(jsonrpc: '2.0', id: 1, result: result))
172
+ end
173
+ end
174
+
175
+ it 'reads chain status through verified read-only JSON RPC' do
176
+ stub_rpc('eth_chainId' => '0x1', 'eth_blockNumber' => '0x123', 'eth_syncing' => false)
177
+ expect(described_class.chain_status(rpc_opts)).to include(chain_id: 1, block_number: 291, syncing: false)
178
+ end
179
+
180
+ it 'validates dynamic ABI string bounds and padding' do
181
+ words = [32, 3].map { |value| value.to_s(16).rjust(64, '0') }.join
182
+ dynamic = "0x#{words}#{'TOK'.unpack1('H*').ljust(64, '0')}"
183
+ stub_rpc('eth_getBlockByNumber' => { number: '0x1', hash: tx_hash }, 'eth_call' => dynamic)
184
+ expect(described_class.token_metadata(rpc_opts.merge(contract: address))).to include(name: 'TOK', symbol: 'TOK', decimals: nil)
185
+ stub_rpc('eth_getBlockByNumber' => { number: '0x1', hash: tx_hash }, 'eth_call' => "#{dynamic[0...-1]}1")
186
+ expect(described_class.token_metadata(rpc_opts.merge(contract: address))[:unavailable]).to include(:name, :symbol)
187
+ end
188
+
189
+ it 'rejects transport redirects, TLS failures and missing results without revealing endpoints' do
190
+ [RestClient::MovedPermanently.new, OpenSSL::SSL::SSLError.new('secret endpoint')].each do |error|
191
+ allow(RestClient::Request).to receive(:execute).and_raise(error)
192
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, 'Ethereum HTTP transport failed') { |failure| expect(failure.cause).to be_nil }
193
+ end
194
+ allow(RestClient::Request).to receive(:execute).and_return(double(body: '{"jsonrpc":"2.0","id":1}'))
195
+ expect { described_class.chain_status(rpc_opts) }.to raise_error(described_class::RPCError, 'Missing JSON-RPC result')
196
+ end
197
+
198
+ it 'validates numeric boundaries and filters before issuing requests' do
199
+ expect(RestClient::Request).not_to receive(:execute)
200
+ expect { described_class.chain_status(rpc_opts.merge(timeout: 0)) }.to raise_error(ArgumentError)
201
+ expect { described_class.block(rpc_opts.merge(block: '0x01')) }.to raise_error(ArgumentError)
202
+ expect { described_class.event_logs(rpc_opts.merge(from_block: 2, to_block: 1)) }.to raise_error(ArgumentError)
203
+ expect { described_class.event_logs(rpc_opts.merge(from_block: 1, to_block: 1, topics: ['0x1'])) }.to raise_error(ArgumentError)
204
+ expect { described_class.address_activity(rpc_opts.merge(address: address, from_block: 1, to_block: 2, page_size: 101)) }.to raise_error(ArgumentError)
205
+ end
206
+
207
+ it 'includes every public API in usable help' do
208
+ expect { described_class.help }.to output(/token_metadata.*get_latest_block/m).to_stdout
209
+ end
210
+
6
211
  it 'should display information for authors' do
7
212
  authors_response = PWN::Blockchain::ETH
8
213
  expect(authors_response).to respond_to :authors
@@ -7,4 +7,10 @@ describe PWN::Blockchain do
7
7
  help_response = PWN::Blockchain.help
8
8
  expect(help_response).not_to be_nil
9
9
  end
10
+
11
+ it 'lists the read-only intelligence modules and their detailed help entry points' do
12
+ result = nil
13
+ expect { result = described_class.help }.to output(/BTC.help.*ETH.help/m).to_stdout
14
+ expect(result).to include(:BTC, :ETH)
15
+ end
10
16
  end
@@ -23,6 +23,16 @@ describe PWN::Config do
23
23
  expect(env[:ai_profiles][:local]).to eq(model: 'fixture')
24
24
  end
25
25
 
26
+ it 'defaults every TUI role to the configured palette' do
27
+ expect(described_class.env_template.dig(:ai, :tui, :theme)).to eq(
28
+ border: 'black', title: 'red', operator: 'red', request: 'white',
29
+ assistant: 'white', task: 'green', tool: 'cyan', result: 'yellow',
30
+ notice: 'cyan', warning: 'red', category: 'yellow', value: 'white',
31
+ composer: 'white', header: 'white', footer: 'white', prompt: 'red',
32
+ selection: 'red', status: 'yellow'
33
+ )
34
+ end
35
+
26
36
  describe '.env_template AI providers' do
27
37
  let(:ai) { described_class.env_template[:ai] }
28
38