From 565e34eff7a87fda8dd2095667b4a2f35bee519e Mon Sep 17 00:00:00 2001 From: Martin Weinschenk Date: Thu, 20 Nov 2025 16:25:20 +0100 Subject: [PATCH 1/3] feat: event payload support for async EVM transactions --- src/Clients/ContractClientGeneric.php | 5 +++-- src/Contracts/ContractClient.php | 8 ++++++-- src/Events/TxBroadcasted.php | 2 +- src/Events/TxFailed.php | 2 +- src/Events/TxMined.php | 2 +- src/Events/TxQueued.php | 2 +- src/Events/TxReplaced.php | 2 +- src/Jobs/SendTransaction.php | 23 ++++++++++++----------- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Clients/ContractClientGeneric.php b/src/Clients/ContractClientGeneric.php index f8c8622..b6fa8e7 100644 --- a/src/Clients/ContractClientGeneric.php +++ b/src/Clients/ContractClientGeneric.php @@ -68,7 +68,7 @@ public function estimateGas(string $data, ?string $from = null): int return (int) max(150000, ceil($n * $pad)); } - public function sendAsync(string $function, array $args = [], array $opts = []): string + public function sendAsync(string $function, array $args = [], array $opts = [], mixed $payload = null): string { $data = $this->abi->encodeFunction($this->abiJson, $function, $args); $queue = (string) ($this->txCfg['queue'] ?? 'evm-send'); @@ -80,7 +80,8 @@ public function sendAsync(string $function, array $args = [], array $opts = []): data: $data, opts: array_merge($opts, ['request_id' => $requestId]), chainId: $this->chainId, - txCfg: $this->txCfg + txCfg: $this->txCfg, + payload: $payload ))->onQueue($queue); return $requestId; diff --git a/src/Contracts/ContractClient.php b/src/Contracts/ContractClient.php index 93cd45a..44cfad9 100644 --- a/src/Contracts/ContractClient.php +++ b/src/Contracts/ContractClient.php @@ -9,8 +9,12 @@ public function at(string $address, array|string $abi = []): self; /** Synchronous read only call returning raw hex or decoded value depending on ABI usage. */ public function call(string $function, array $args = []): mixed; - /** Enqueue a non blocking write job. Returns job id string. */ - public function sendAsync(string $function, array $args = [], array $opts = []): string; + /** + * Enqueue a non blocking write job. Returns job id string. + * Optional $payload allows attaching any serializable context (e.g. an Eloquent model) that will + * be forwarded to all transaction lifecycle events (TxQueued, TxBroadcasted, TxReplaced, TxMined, TxFailed). + */ + public function sendAsync(string $function, array $args = [], array $opts = [], mixed $payload = null): string; /** Wait for a receipt with timeout returns receipt array or null. */ public function wait(string $txHash, int $timeoutSec = 120, int $pollMs = 800): ?array; diff --git a/src/Events/TxBroadcasted.php b/src/Events/TxBroadcasted.php index a82c98d..daf8f45 100644 --- a/src/Events/TxBroadcasted.php +++ b/src/Events/TxBroadcasted.php @@ -6,5 +6,5 @@ class TxBroadcasted { - public function __construct(public string $txHash, public array $fields) {} + public function __construct(public string $txHash, public array $fields, public mixed $payload = null) {} } diff --git a/src/Events/TxFailed.php b/src/Events/TxFailed.php index 82652a8..7352406 100644 --- a/src/Events/TxFailed.php +++ b/src/Events/TxFailed.php @@ -6,5 +6,5 @@ class TxFailed { - public function __construct(public string $to, public string $data, public string $reason) {} + public function __construct(public string $to, public string $data, public string $reason, public mixed $payload = null) {} } diff --git a/src/Events/TxMined.php b/src/Events/TxMined.php index 9aa7877..b446608 100644 --- a/src/Events/TxMined.php +++ b/src/Events/TxMined.php @@ -6,5 +6,5 @@ class TxMined { - public function __construct(public string $txHash, public array $receipt) {} + public function __construct(public string $txHash, public array $receipt, public mixed $payload = null) {} } diff --git a/src/Events/TxQueued.php b/src/Events/TxQueued.php index 53dff34..64e72b7 100644 --- a/src/Events/TxQueued.php +++ b/src/Events/TxQueued.php @@ -6,5 +6,5 @@ class TxQueued { - public function __construct(public string $to, public string $data) {} + public function __construct(public string $to, public string $data, public mixed $payload = null) {} } diff --git a/src/Events/TxReplaced.php b/src/Events/TxReplaced.php index f851dad..9eee0b4 100644 --- a/src/Events/TxReplaced.php +++ b/src/Events/TxReplaced.php @@ -11,5 +11,5 @@ class TxReplaced * @param array $newFields Neue Felder inklusive angehobener Fees * @param int $attempt Laufende Ersatz-Versuchsnummer (1-basiert) */ - public function __construct(public string $oldTxHash, public array $newFields, public int $attempt) {} + public function __construct(public string $oldTxHash, public array $newFields, public int $attempt, public mixed $payload = null) {} } diff --git a/src/Jobs/SendTransaction.php b/src/Jobs/SendTransaction.php index c801d55..02743db 100644 --- a/src/Jobs/SendTransaction.php +++ b/src/Jobs/SendTransaction.php @@ -27,11 +27,11 @@ class SendTransaction implements ShouldQueue { use InteractsWithQueue, Queueable, SerializesModels; - public function __construct(public string $address, public string $data, public array $opts, public int $chainId, public array $txCfg) {} + public function __construct(public string $address, public string $data, public array $opts, public int $chainId, public array $txCfg, public mixed $payload = null) {} public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, FeePolicy $fees): void { - event(new TxQueued($this->address, $this->data)); + event(new TxQueued($this->address, $this->data, $this->payload)); $from = $signer->getAddress(); @@ -67,7 +67,7 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee $pk = method_exists($signer, 'privateKey') ? $signer->privateKey() : null; if (! $pk) { - event(new TxFailed($this->address, $this->data, 'Signer has no privateKey method')); + event(new TxFailed($this->address, $this->data, 'Signer has no privateKey method', $this->payload)); return; } @@ -78,10 +78,10 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee $rawHex = str_starts_with($raw, '0x') ? $raw : '0x'.$raw; // ensure 0x prefix $txHash = $rpc->call('eth_sendRawTransaction', [$rawHex]); $nonces->markUsed($from, $nonce); - event(new TxBroadcasted($txHash, $fields)); + event(new TxBroadcasted($txHash, $fields, $this->payload)); } catch (\Throwable $e) { - event(new TxFailed($this->address, $this->data, 'rpc_send_error: '.$e->getMessage())); + event(new TxFailed($this->address, $this->data, 'rpc_send_error: '.$e->getMessage(), $this->payload)); return; } @@ -94,7 +94,7 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee while (time() < $deadline) { $rec = $rpc->call('eth_getTransactionReceipt', [$txHash]); if (! empty($rec)) { - event(new TxMined($txHash, $rec)); + event(new TxMined($txHash, $rec, $this->payload)); return; } @@ -109,15 +109,15 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee $fields['maxFeePerGas'] = $max; // Emit replacement attempt (before rebroadcast) - event(new TxReplaced($oldTxHash, $fields, $i + 1)); + event(new TxReplaced($oldTxHash, $fields, $i + 1, $this->payload)); try { $raw = new EIP1559Transaction($fields)->sign($pk); $rawHex = str_starts_with($raw, '0x') ? $raw : '0x'.$raw; // ensure 0x prefix $txHash = $rpc->call('eth_sendRawTransaction', [$rawHex]); - event(new TxBroadcasted($txHash, $fields)); + event(new TxBroadcasted($txHash, $fields, $this->payload)); } catch (\Throwable $e) { - event(new TxFailed($this->address, $this->data, 'rpc_send_error_replacement_'.$i.': '.$e->getMessage())); + event(new TxFailed($this->address, $this->data, 'rpc_send_error_replacement_'.$i.': '.$e->getMessage(), $this->payload)); return; } @@ -126,7 +126,7 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee while (time() < $deadline) { $rec = $rpc->call('eth_getTransactionReceipt', [$txHash]); if (! empty($rec)) { - event(new TxMined($txHash, $rec)); + event(new TxMined($txHash, $rec, $this->payload)); return; } @@ -137,7 +137,8 @@ public function handle(RpcClient $rpc, Signer $signer, NonceManager $nonces, Fee event(new TxFailed( $this->address, $this->data, - sprintf('no_receipt_after_%d_replacements (last maxFee=%d priority=%d)', $maxRep, $fields['maxFeePerGas'], $fields['maxPriorityFeePerGas']) + sprintf('no_receipt_after_%d_replacements (last maxFee=%d priority=%d)', $maxRep, $fields['maxFeePerGas'], $fields['maxPriorityFeePerGas']), + $this->payload )); } } From 4a44aff62915cd21c709fe6a69e2a99790ca746e Mon Sep 17 00:00:00 2001 From: Martin Weinschenk Date: Thu, 20 Nov 2025 16:26:05 +0100 Subject: [PATCH 2/3] docs: new payload support for async EVM transactions --- docs/pages/advanced-events.md | 36 ++++++++++++++++++++----------- docs/pages/basic-usage.md | 13 +++++++++++- docs/pages/reference.md | 40 +++++++++++++++++------------------ 3 files changed, 56 insertions(+), 33 deletions(-) diff --git a/docs/pages/advanced-events.md b/docs/pages/advanced-events.md index fc4b864..9deedec 100644 --- a/docs/pages/advanced-events.md +++ b/docs/pages/advanced-events.md @@ -1,18 +1,27 @@ # Events Transactional events provide lifecycle visibility for asynchronous writes. Use them for logging, metrics, user -notifications, and fee escalation monitoring. +notifications, and fee escalation monitoring. Each write-related event includes an optional `$payload` that you can attach via `sendAsync(..., $payload)`. ## Event List -| Event | When Fired | Key Fields | Purpose | -|-----------------|-----------------------|-----------------------------------------|-------------------------------------| -| `TxQueued` | Job dispatched | request_id, function, address | Track submission before broadcast | -| `TxBroadcasted` | First raw tx accepted | tx_hash, nonce, fees | Persist hash, start monitoring | -| `TxReplaced` | Replacement broadcast | old_tx_hash, new_tx_hash, attempt, fees | Fee bump / speed-up diagnostics | -| `TxMined` | Receipt obtained | tx_hash, receipt | Success; update state models | -| `TxFailed` | Terminal failure | reason, attempts | Alert; possible manual intervention | -| `CallPerformed` | Read call completed | from, to, function, raw_result | Auditing read queries | +| Event | When Fired | Key Fields (excerpt) | Purpose | +|-----------------|-----------------------|-------------------------------------|-------------------------------------| +| `TxQueued` | Job dispatched | to, data, payload | Track submission before broadcast | +| `TxBroadcasted` | First raw tx accepted | txHash, fields(fees,nonce), payload | Persist hash, start monitoring | +| `TxReplaced` | Replacement broadcast | oldTxHash, newFields, attempt, payload | Fee bump / speed-up diagnostics | +| `TxMined` | Receipt obtained | txHash, receipt, payload | Success; update state models | +| `TxFailed` | Terminal failure | to, data, reason, payload | Alert; possible manual intervention | +| `CallPerformed` | Read call completed | from, address, function, rawResult | Auditing read queries | + +`payload` is `mixed` and can hold any serializable context (e.g. Eloquent model) to correlate app state and blockchain lifecycle. + +## Attaching Payload Example + +```php +$order = Order::find(123); +$contract->sendAsync('transfer', ['0xRecipient', 1000], [], $order); +``` ## Example Listener Registration @@ -37,8 +46,12 @@ class LogTxBroadcasted { Log::info('TX broadcasted', [ 'hash' => $e->txHash, - 'nonce' => $e->nonce, - 'fees' => $e->fees, + 'nonce' => $e->fields['nonce'] ?? null, + 'fees' => [ + 'maxFeePerGas' => $e->fields['maxFeePerGas'] ?? null, + 'maxPriorityFeePerGas' => $e->fields['maxPriorityFeePerGas'] ?? null, + ], + 'payload_id' => method_exists($e->payload, 'getKey') ? $e->payload->getKey() : null, ]); } } @@ -84,4 +97,3 @@ On `TxFailed`, inspect `reason`: - Gas estimation failure: review contract & args. - Broadcast rejection: fees or nonce invalid. - Timeout without receipt: consider manual bump or external explorer check. - diff --git a/docs/pages/basic-usage.md b/docs/pages/basic-usage.md index dabdfb6..25b689f 100644 --- a/docs/pages/basic-usage.md +++ b/docs/pages/basic-usage.md @@ -65,6 +65,17 @@ $requestId = $contract->sendAsync('transfer', ['0xRecipient', 1000]); Writes enqueue a `SendTransaction` job. You need a running queue worker for progress (unless using the sync queue driver). +### Attaching Context Payload + +You can attach any serializable payload (e.g. an Eloquent model instance) that will travel through all lifecycle events: + +```php +$order = Order::find(123); +$requestId = $contract->sendAsync('transfer', ['0xRecipient', 1000], [], $order); +``` + +Each emitted event (`TxQueued`, `TxBroadcasted`, `TxReplaced`, `TxMined`, `TxFailed`) will expose `$payload` so you can correlate blockchain progress with your domain object. + ### Transaction Job Lifecycle The queued job executes these steps: @@ -181,4 +192,4 @@ $block = \Farbcode\LaravelEvm\Facades\EvmRpc::call('eth_blockNumber'); Direct access for diagnostics or unsupported methods. -Proceed to Advanced Usage for log filtering and custom components. +Proceed to Advanced Usage for log filtering, events, payload handling details and custom components. diff --git a/docs/pages/reference.md b/docs/pages/reference.md index 14a51d3..dd4380b 100644 --- a/docs/pages/reference.md +++ b/docs/pages/reference.md @@ -15,13 +15,13 @@ ## ContractClient (via `Evm`) -| Method | Args | Returns | Notes | -|--------------------------------------------|----------------------------|----------------------|------------------------------------------| -| `at(address, abi)` | `string`, `array\| string` | self | Set target contract & ABI JSON/array | -| `call(fn, args=[])` | `string`, `array` | `CallResult\| mixed` | eth_call; hex wrapped for decoding | -| `sendAsync(fn, args=[], opts=[])` | `string`, `array`, `array` | `string` | Dispatch async job; returns request UUID | -| `wait(txHash, timeoutSec=120, pollMs=800)` | `string`, `int`, `int` | `array\| null` | Poll receipt until mined/timeout | -| `estimateGas(data, from?)` | `string`, `?string` | `int` | Uses eth_estimateGas + padding | +| Method | Args | Returns | Notes | +|-------------------------------------------------|-----------------------------------|----------------------|------------------------------------------------------------------------| +| `at(address, abi)` | `string`, `array\| string` | self | Set target contract & ABI JSON/array | +| `call(fn, args=[])` | `string`, `array` | `CallResult\| mixed` | eth_call; hex wrapped for decoding | +| `sendAsync(fn, args=[], opts=[], payload=null)` | `string`, `array`, `array`, mixed | `string` | Dispatch async job; returns request UUID; payload flows through events | +| `wait(txHash, timeoutSec=120, pollMs=800)` | `string`, `int`, `int` | `array\| null` | Poll receipt until mined/timeout | +| `estimateGas(data, from?)` | `string`, `?string` | `int` | Uses eth_estimateGas + padding | ### CallResult @@ -62,10 +62,10 @@ ## FeePolicy (via `EvmFees`) -| Method | Args | Returns | Notes | -|---------------------------|----------------|-----------------------------------------------------------|-----------------------------| -| `suggest()` | - | `['maxFeePerGas'=>string,'maxPriorityFeePerGas'=>string]` | Initial fee suggestion | -| `bump(previous, attempt)` | `array`, `int` | `array` | Adjust fees for replacement | +| Method | Args | Returns | Notes | +|----------------------------|--------------------|---------------------------|------------------------| +| `suggest(cb)` | `callable` | `[prioHex, maxHex]` | Initial fee suggestion | +| `replace(prioHex, maxHex)` | `string`, `string` | `[newPrioHex, newMaxHex]` | Replacement bump | --- @@ -104,14 +104,14 @@ Start with `EvmLogs::query()` then chain: ## Events -| Event | When | Key Data | -|-----------------|--------------------|-----------------------------------| -| `TxQueued` | Job pushed | request_id, function, address | -| `TxBroadcasted` | First broadcast ok | tx_hash, nonce, fees | -| `TxReplaced` | Fee bump broadcast | old_tx_hash, new_tx_hash, attempt | -| `TxMined` | Receipt found | tx_hash, receipt | -| `TxFailed` | Terminal failure | reason, attempts | -| `CallPerformed` | Read executed | from, to, function, raw_result | +| Event | When | Key Data (excerpt) | +|-----------------|--------------------|----------------------------------------| +| `TxQueued` | Job pushed | to, data, payload | +| `TxBroadcasted` | First broadcast ok | txHash, fields, payload | +| `TxReplaced` | Fee bump broadcast | oldTxHash, newFields, attempt, payload | +| `TxMined` | Receipt found | txHash, receipt, payload | +| `TxFailed` | Terminal failure | to, data, reason, payload | +| `CallPerformed` | Read executed | from, address, function, rawResult | --- @@ -157,4 +157,4 @@ Maintains nonce ordering; for scaling use a distributed nonce manager. - Never log private keys. - Limit queue concurrency. - Use multiple RPC endpoints for resilience. - +- Attach domain payloads to events for traceability. From caa839af45d99882666770c3b2922bff053bc1f7 Mon Sep 17 00:00:00 2001 From: Martin Weinschenk Date: Thu, 20 Nov 2025 16:34:04 +0100 Subject: [PATCH 3/3] Update docs/pages/reference.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/pages/reference.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/pages/reference.md b/docs/pages/reference.md index dd4380b..20d9b4c 100644 --- a/docs/pages/reference.md +++ b/docs/pages/reference.md @@ -62,10 +62,10 @@ ## FeePolicy (via `EvmFees`) -| Method | Args | Returns | Notes | -|----------------------------|--------------------|---------------------------|------------------------| -| `suggest(cb)` | `callable` | `[prioHex, maxHex]` | Initial fee suggestion | -| `replace(prioHex, maxHex)` | `string`, `string` | `[newPrioHex, newMaxHex]` | Replacement bump | +| Method | Args | Returns | Notes | +|----------------------------------------|--------------------------------------|--------------------------------|------------------------| +| `suggest(callable $gasPriceFetcher)` | `callable $gasPriceFetcher` | `[priorityWei, maxFeeWei]` | Initial fee suggestion | +| `replace(int $oldPriority, int $oldMax)`| `int $oldPriority, int $oldMax` | `[priorityWei, maxFeeWei]` | Replacement bump | ---