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
|
@@ -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
|
data/spec/lib/pwn/config_spec.rb
CHANGED
|
@@ -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
|
|