Skip to content

Commit 7fb9f8a

Browse files
committed
wip
1 parent 41182e3 commit 7fb9f8a

File tree

13 files changed

+696
-30
lines changed

13 files changed

+696
-30
lines changed

src/Clients/ContractClientGeneric.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Farbcode\LaravelEvm\Contracts\RpcClient;
88
use Farbcode\LaravelEvm\Contracts\Signer;
99
use Farbcode\LaravelEvm\Jobs\SendTransaction;
10+
use Farbcode\LaravelEvm\Support\CallResult;
11+
use Farbcode\LaravelEvm\Events\CallPerformed;
1012

1113
class ContractClientGeneric implements ContractClient
1214
{
@@ -35,11 +37,21 @@ public function call(string $function, array $args = []): mixed
3537
$from = $this->signer->getAddress();
3638
$data = $this->abi->encodeFunction($this->abiJson, $function, $args);
3739

38-
return $this->rpc->call('eth_call', [[
40+
$res = $this->rpc->call('eth_call', [[
3941
'from' => $from,
4042
'to' => $this->address,
4143
'data' => $data,
4244
], 'latest']);
45+
46+
// Dispatch read event with raw result (before wrapping)
47+
event(new CallPerformed($from, $this->address, $function, $args, $res));
48+
49+
// Wrap raw hex for convenient decoding if applicable.
50+
if (is_string($res) && str_starts_with($res, '0x')) {
51+
return new CallResult($res);
52+
}
53+
54+
return $res; // Return original if already decoded or different format
4355
}
4456

4557
public function estimateGas(string $data, ?string $from = null): int

src/Clients/RpcHttpClient.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,24 @@ public function callRaw(string $method, array $params = []): array
128128
* Convenience wrapper returning the result field
129129
* Throws when the RPC response carries an error
130130
*/
131-
public function call(string $method, array $params = []): false|string
131+
public function call(string $method, array $params = []): mixed // align with interface
132132
{
133133
$json = $this->callRaw($method, $params);
134134

135135
if (isset($json['error'])) {
136-
throw new RpcException(is_array($json['error']) ? json_encode($json['error']) : (string) $json['error']);
136+
$err = $json['error'];
137+
throw new RpcException(is_array($err) ? json_encode($err) : (string) $err);
137138
}
138139

139-
// Some providers return already unwrapped arrays for simple calls
140-
return isset($json['result']) && is_array($json['result']) ? json_encode($json['result']) : (string) ($json['result'] ?? $json);
140+
if (! array_key_exists('result', $json)) {
141+
// Unexpected shape; return whole response for caller inspection
142+
return $json;
143+
}
144+
145+
$result = $json['result'];
146+
147+
// Return arrays directly (e.g. receipts) and scalars/hex as-is.
148+
return $result;
141149
}
142150

143151
/**
@@ -153,4 +161,17 @@ public function health(): array
153161
'block' => is_string($bnHex) ? hexdec($bnHex) : (int) $bnHex,
154162
];
155163
}
164+
165+
/**
166+
* Get logs wrapper calling eth_getLogs with validation
167+
*/
168+
public function getLogs(array $filter): array
169+
{
170+
// Basic validation: require either blockHash or fromBlock/toBlock pair.
171+
if (!isset($filter['blockHash']) && !isset($filter['fromBlock']) && !isset($filter['toBlock'])) {
172+
throw new \InvalidArgumentException('getLogs filter requires blockHash or fromBlock/toBlock');
173+
}
174+
$res = $this->call('eth_getLogs', [$filter]);
175+
return is_array($res) ? $res : [];
176+
}
156177
}

src/Codec/AbiCodecWeb3p.php

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,11 @@
66

77
use Farbcode\LaravelEvm\Contracts\AbiCodec;
88
use kornrunner\Keccak;
9-
use Web3\Contract; // keccak256 helper
109

1110
class AbiCodecWeb3p implements AbiCodec
1211
{
1312
public function encodeFunction(array|string $abi, string $fn, array $args): string
1413
{
15-
// web3p Contract::getData() returns void and fills internal state. To avoid dynamic property reliance
16-
// implement a lightweight encoder for common static call patterns.
1714
$abiArray = is_string($abi) ? json_decode($abi, true) : $abi;
1815
if (! is_array($abiArray)) {
1916
throw new \InvalidArgumentException('ABI must decode to array');
@@ -29,54 +26,103 @@ public function encodeFunction(array|string $abi, string $fn, array $args): stri
2926
throw new \RuntimeException('Function '.$fn.' not found in ABI');
3027
}
3128
$inputs = $item['inputs'] ?? [];
32-
// Build function selector
29+
3330
$typesSig = implode(',', array_map(fn ($i) => $i['type'], $inputs));
3431
$signature = $fn.'('.$typesSig.')';
3532
$hash = Keccak::hash($signature, 256);
3633
$selector = '0x'.substr($hash, 0, 8);
37-
// Encode arguments (very simplified: handles address, uint256, bytes32, bool, string)
38-
$encodedArgs = '';
34+
35+
// Build head (static slots + dynamic offsets) and tail (dynamic data)
36+
$head = [];
37+
$dynamicParts = [];
3938
foreach ($inputs as $idx => $in) {
4039
$type = $in['type'];
4140
$val = $args[$idx] ?? null;
42-
$encodedArgs .= $this->encodeValue($type, $val);
41+
if ($this->isDynamic($type)) {
42+
// Placeholder offset, will fill after we know tail sizes
43+
$head[] = '__DYNAMIC_OFFSET_PLACEHOLDER__';
44+
$dynamicParts[] = $this->encodeDynamic($type, $val);
45+
} else {
46+
$head[] = $this->encodeStatic($type, $val);
47+
}
48+
}
49+
50+
// Compute offsets (in bytes) for dynamic parts
51+
$baseHeadSize = 32 * count($head); // bytes
52+
$tailSoFar = 0;
53+
$dynamicIndex = 0;
54+
foreach ($head as $i => $slot) {
55+
if ($slot === '__DYNAMIC_OFFSET_PLACEHOLDER__') {
56+
$offset = $baseHeadSize + $tailSoFar; // bytes from start of args (after selector)
57+
$head[$i] = str_pad(dechex($offset), 64, '0', STR_PAD_LEFT);
58+
$tailSoFar += strlen($dynamicParts[$dynamicIndex]) / 2; // hex length /2 = bytes
59+
$dynamicIndex++;
60+
}
4361
}
4462

45-
return $selector.$encodedArgs;
63+
$encodedHead = implode('', $head);
64+
$encodedTail = implode('', $dynamicParts);
65+
66+
return $selector.$encodedHead.$encodedTail;
4667
}
4768

48-
private function encodeValue(string $type, mixed $val): string
69+
private function isDynamic(string $type): bool
70+
{
71+
return in_array($type, ['string', 'bytes']) || $type === 'bytes' || str_starts_with($type, 'bytes') === false && in_array($type, ['string', 'bytes']);
72+
}
73+
74+
private function encodeStatic(string $type, mixed $val): string
4975
{
50-
// Simplified static encoding (no dynamic types except string truncated)
5176
if (str_starts_with($type, 'uint')) {
5277
return str_pad(dechex((int) $val), 64, '0', STR_PAD_LEFT);
5378
}
5479
if ($type === 'address') {
5580
$clean = strtolower(preg_replace('/^0x/', '', (string) $val));
56-
5781
return str_pad($clean, 64, '0', STR_PAD_LEFT);
5882
}
5983
if ($type === 'bytes32') {
6084
$clean = strtolower(preg_replace('/^0x/', '', (string) $val));
61-
6285
return str_pad(substr($clean, 0, 64), 64, '0', STR_PAD_RIGHT);
6386
}
6487
if ($type === 'bool') {
6588
return str_pad($val ? '1' : '0', 64, '0', STR_PAD_LEFT);
6689
}
67-
if ($type === 'string') {
68-
// naive: hex of string truncated to 32 bytes
69-
$hex = bin2hex((string) $val);
90+
// Fallback for unsupported static types
91+
throw new \RuntimeException('Unsupported static ABI type '.$type);
92+
}
7093

71-
return str_pad(substr($hex, 0, 64), 64, '0', STR_PAD_RIGHT);
94+
private function encodeDynamic(string $type, mixed $val): string
95+
{
96+
// Only string/bytes handled
97+
if ($type === 'string') {
98+
$bin = (string) $val;
99+
$hex = bin2hex($bin);
100+
$length = strlen($bin); // bytes
101+
$lenSlot = str_pad(dechex($length), 64, '0', STR_PAD_LEFT);
102+
$dataPadded = $this->padHexRight($hex);
103+
return $lenSlot.$dataPadded;
72104
}
73-
throw new \RuntimeException('Unsupported ABI type '.$type);
105+
if ($type === 'bytes') {
106+
$clean = preg_replace('/^0x/', '', (string) $val);
107+
$bin = hex2bin($clean) ?: '';
108+
$length = strlen($bin);
109+
$lenSlot = str_pad(dechex($length), 64, '0', STR_PAD_LEFT);
110+
$dataPadded = $this->padHexRight($clean);
111+
return $lenSlot.$dataPadded;
112+
}
113+
throw new \RuntimeException('Unsupported dynamic ABI type '.$type);
114+
}
115+
116+
private function padHexRight(string $hex): string
117+
{
118+
$bytesLen = (int) ceil(strlen($hex) / 2);
119+
$padBytes = (32 - ($bytesLen % 32)) % 32;
120+
return $hex.str_repeat('00', $padBytes);
74121
}
75122

76123
public function callStatic(array|string $abi, string $fn, array $args, callable $ethCall): mixed
77124
{
78125
$data = $this->encodeFunction($abi, $fn, $args);
79-
80126
return $ethCall($data);
81127
}
82128
}

src/Commands/EvmBumpCommand.php

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
namespace Farbcode\LaravelEvm\Commands;
4+
5+
use Farbcode\LaravelEvm\Contracts\FeePolicy;
6+
use Farbcode\LaravelEvm\Contracts\RpcClient;
7+
use Farbcode\LaravelEvm\Contracts\Signer;
8+
use Illuminate\Console\Command;
9+
use Web3p\EthereumTx\EIP1559Transaction;
10+
11+
class EvmBumpCommand extends Command
12+
{
13+
protected $signature = 'evm:bump {--nonce=} {--original=} {--factor=2.0} {--gas=21000} {--priority=} {--max=} {--dry-run} {--auto}';
14+
15+
protected $description = 'Send a high-fee empty self transaction to replace a stuck pending transaction (same nonce).';
16+
17+
public function handle(RpcClient $rpc, Signer $signer, FeePolicy $fees): int
18+
{
19+
$address = $signer->getAddress();
20+
$chainId = (int) config('evm.chain_id');
21+
22+
// Fetch latest and pending nonce
23+
$pendingHex = $rpc->call('eth_getTransactionCount', [$address, 'pending']);
24+
$latestHex = $rpc->call('eth_getTransactionCount', [$address, 'latest']);
25+
$pending = hexdec($pendingHex);
26+
$latest = hexdec($latestHex);
27+
28+
$specifiedNonceOpt = $this->option('nonce');
29+
$nonce = null;
30+
if ($specifiedNonceOpt !== null) {
31+
$nonce = (int) $specifiedNonceOpt;
32+
} else {
33+
if ($pending > $latest) {
34+
$nonce = $pending - 1; // last pending
35+
} else {
36+
$this->error('No pending transactions detected (pending == latest). Use --nonce to force replacement.');
37+
return self::FAILURE;
38+
}
39+
}
40+
if ($nonce < 0) {
41+
$this->error('Calculated nonce is negative; abort.');
42+
return self::FAILURE;
43+
}
44+
45+
// Optional original tx fetch
46+
$origHash = $this->option('original');
47+
$origPriority = null; $origMaxFee = null; $origGas = null;
48+
if ($origHash) {
49+
try {
50+
$orig = $rpc->call('eth_getTransactionByHash', [$origHash]);
51+
if (is_array($orig) && isset($orig['nonce'])) {
52+
$origNonce = hexdec($orig['nonce']);
53+
if ($origNonce !== $nonce) {
54+
$this->warn('Original tx nonce '.$origNonce.' != target nonce '.$nonce.'; continuing but replacement may fail.');
55+
}
56+
// Legacy or EIP-1559: fields differ; try both
57+
if (isset($orig['maxPriorityFeePerGas'])) {
58+
$origPriority = hexdec($orig['maxPriorityFeePerGas']);
59+
} elseif (isset($orig['gasPrice'])) {
60+
$origPriority = hexdec($orig['gasPrice']); // treat as priority baseline
61+
}
62+
if (isset($orig['maxFeePerGas'])) {
63+
$origMaxFee = hexdec($orig['maxFeePerGas']);
64+
} elseif (isset($orig['gasPrice'])) {
65+
$origMaxFee = hexdec($orig['gasPrice']);
66+
}
67+
if (isset($orig['gas'])) {
68+
$origGas = hexdec($orig['gas']);
69+
}
70+
} else {
71+
$this->warn('Could not fetch original tx or unexpected response shape.');
72+
}
73+
} catch (\Throwable $e) {
74+
$this->warn('Fetch original tx failed: '.$e->getMessage());
75+
}
76+
}
77+
78+
// Base gas price
79+
$gasPriceHex = $rpc->call('eth_gasPrice');
80+
$base = is_string($gasPriceHex) ? hexdec($gasPriceHex) : (int) $gasPriceHex;
81+
82+
$factor = (float) $this->option('factor');
83+
$userPrio = $this->option('priority');
84+
$userMax = $this->option('max');
85+
[$suggestPrio, $suggestMax] = $fees->suggest(fn () => $gasPriceHex);
86+
87+
// Auto mode: derive min bump from original tx if provided
88+
$priority = $userPrio !== null ? (int) $userPrio : max($suggestPrio, (int) ($base * 0.3));
89+
$maxFee = $userMax !== null ? (int) $userMax : max($suggestMax, (int) ($base * $factor));
90+
91+
if ($this->option('auto') && $origPriority !== null && $origMaxFee !== null) {
92+
$minPriority = max((int) ($origPriority * 1.125), $origPriority + 1_000_000_000); // +12.5% or +1 gwei
93+
$minMaxFee = max((int) ($origMaxFee * 1.125), $origMaxFee + 2_000_000_000); // +12.5% or +2 gwei
94+
$priority = max($priority, $minPriority);
95+
$maxFee = max($maxFee, $minMaxFee);
96+
}
97+
98+
if ($maxFee <= $priority) {
99+
$maxFee = $priority * 2;
100+
}
101+
102+
$gasLimit = (int) $this->option('gas');
103+
if ($gasLimit < 21000) {
104+
$gasLimit = 21000;
105+
}
106+
if ($origGas !== null) {
107+
// Keep at least original gas limit if higher (avoid underestimating)
108+
$gasLimit = max($gasLimit, $origGas);
109+
}
110+
111+
// ChainId sanity check
112+
$rpcChainHex = $rpc->call('eth_chainId');
113+
$rpcChain = is_string($rpcChainHex) ? hexdec($rpcChainHex) : (int) $rpcChainHex;
114+
if ($rpcChain !== $chainId && ! $this->option('dry-run')) {
115+
$this->error('ChainId mismatch: local='.$chainId.' remote='.$rpcChain.' (abort)');
116+
return self::FAILURE;
117+
}
118+
119+
$fields = [
120+
'chainId' => $chainId,
121+
'nonce' => $nonce,
122+
'maxPriorityFeePerGas' => $priority,
123+
'maxFeePerGas' => $maxFee,
124+
'gas' => $gasLimit,
125+
'to' => $address,
126+
'value' => 0,
127+
'data' => '0x',
128+
'accessList' => [],
129+
];
130+
131+
$this->line('Prepared replacement transaction:');
132+
$rows = [[
133+
$nonce,
134+
$priority,
135+
$maxFee,
136+
$gasLimit,
137+
]];
138+
$this->table(['nonce','priority','maxFee','gasLimit'], $rows);
139+
if ($origPriority !== null) {
140+
$this->info('Original priority: '.$origPriority.' original maxFee: '.$origMaxFee.' original gas: '.$origGas);
141+
}
142+
$this->info('Base fee (approx from gasPrice): '.$base);
143+
144+
if ($this->option('dry-run')) {
145+
try {
146+
$rawSigned = new EIP1559Transaction($fields)->sign($signer->privateKey());
147+
$rawHex = str_starts_with($rawSigned, '0x') ? $rawSigned : '0x'.$rawSigned;
148+
$this->info('Dry-run signed rawTx (not broadcasted):');
149+
$this->line($rawHex);
150+
} catch (\Throwable $e) {
151+
$this->error('Dry-run signing failed: '.$e->getMessage());
152+
return self::FAILURE;
153+
}
154+
return self::SUCCESS;
155+
}
156+
157+
try {
158+
$rawSigned = new EIP1559Transaction($fields)->sign($signer->privateKey());
159+
$rawHex = str_starts_with($rawSigned, '0x') ? $rawSigned : '0x'.$rawSigned;
160+
$txHash = $rpc->call('eth_sendRawTransaction', [$rawHex]);
161+
$this->info('Broadcast replacement txHash: '.$txHash);
162+
$this->info('Use a block explorer or evm:wait to confirm mining.');
163+
} catch (\Throwable $e) {
164+
$msg = $e->getMessage();
165+
$this->error('Broadcast failed: '.$msg);
166+
if (str_contains($msg, 'could not replace')) {
167+
$this->warn('Suggestion: Increase --priority and --max or use --auto with --original=<txHash>.');
168+
if ($origPriority !== null) {
169+
$this->line('Try at least priority >= '.max((int) ($origPriority * 1.15), $origPriority + 2_000_000_000)
170+
.' and maxFee >= '.max((int) ($origMaxFee * 1.15), $origMaxFee + 3_000_000_000));
171+
} else {
172+
$this->line('If original tx fees unknown, start with factor 3-5 or specify --priority / --max manually.');
173+
}
174+
}
175+
return self::FAILURE;
176+
}
177+
178+
return self::SUCCESS;
179+
}
180+
}

0 commit comments

Comments
 (0)