kornrunner/ethereum-offline-raw-tx
Build and sign Ethereum transactions offline in PHP. Set nonce, gas price, gas limit, recipient, value, and data, sign with a local private key, and output raw transaction hex ready to broadcast via any node. Keep private keys off RPC servers for wallets and backend payouts.
Install
composer require kornrunner/ethereum-offline-raw-tx
This pulls in kornrunner/keccak (hashing) and kornrunner/secp256k1 (pure-PHP EC signing). There is no PDO/HTTP dependency — the package talks to nothing, it only produces signed bytes.
Where to look first
README.md — the usage example is the entire API surface.src/Transaction.php — the package is essentially one class in the kornrunner\Ethereum namespace. Read the constructor and sign() / getRawTransaction() before anything else.composer.json — confirm the PHP constraint and the pinned versions of keccak and secp256k1; those two libraries are where 90% of your bugs will actually live.First use case: a plain ETH transfer
use kornrunner\Ethereum\Transaction;
// All numeric fields are hex strings. No 0x prefix. Ever.
$tx = new Transaction(
'09', // nonce = 9
'04a817c800', // gasPrice = 20 gwei
'5208', // gasLimit = 21000
'3535353535353535353535353535353535353535', // to (40 hex chars, no 0x)
'0de0b6b3a7640000', // value = 1 ETH in wei
'', // data
1 // chainId (EIP-155)
);
$tx->sign(str_repeat('1', 64)); // 64-hex private key, no 0x
echo $tx->getRawTransaction(); // 0xf86c... ready for eth_sendRawTransaction
Feed the output straight into eth_sendRawTransaction (via web3p/web3.php, Guzzle, or curl). The returned hex string is the entire signing step — persistence, queueing, and transport are your problem.
Fetch → sign → broadcast, kept separate
The package intentionally has no knowledge of the network. The canonical workflow is a three-stage pipeline you should keep as separate services/functions:
// 1. Gather (RPC)
$nonce = $rpc->eth_getTransactionCount($from, 'pending');
$gasPrice = $rpc->eth_gasPrice();
$gasLimit = $rpc->eth_estimateGas([...]);
$chainId = $rpc->eth_chainId();
// 2. Sign (this package — can run on an air-gapped box)
$raw = (new Transaction(
dechex($nonce), dechex($gasPrice), dechex($gasLimit),
$to, $valueHex, $data, hexdec($chainId)
))->sign($privateKeyHex)->getRawTransaction();
// 3. Broadcast (RPC)
$rpc->eth_sendRawTransaction($raw);
This split is the main value proposition: the signing box needs the private key, the RPC box does not.
Normalizing RPC responses into hex strings
Every RPC call returns 0x-prefixed quantities. Wrap the conversion once so you don't scatter dechex() across the codebase:
function hex0xToField(string $q): string {
$q = ltrim($q, '0x');
$q = ltrim($q, '0'); // strip leading zeros — see Gotchas
return $q === '' ? '' : (strlen($q) % 2 ? '0' . $q : $q);
}
ERC-20 transfers
Contract calls are just to = token contract, value = 0, and data = selector + padded args:
$data = 'a9059cbb'
. str_pad(ltrim($recipient, '0x'), 64, '0', STR_PAD_LEFT)
. str_pad(dechex($amountWei), 64, '0', STR_PAD_LEFT);
You are responsible for ABI encoding — this package does no ABI work. Pair it with web3p/ethereum-abi or hand-roll the padding for simple calls.
Contract deployment
Set to to an empty string (''), put the deployment bytecode (hex, no 0x) in data, and budget a real gasLimit from eth_estimateGas (never hardcode 21000).
Nonce management for batches
Sign N transactions with nonce n, n+1, n+2... derived from a single eth_getTransactionCount(..., 'pending') snapshot. Because signing is deterministic (RFC 6979), re-signing the same inputs yields the identical raw tx — safe to retry the broadcast, not the signing with a different nonce.
Wrapping it
In a real codebase, put a thin value object around it:
final class RawTransaction {
private function __construct(public readonly string $hex) {}
public static function transfer(string $pk, string $to, string $weiWei, int $chainId, int $nonce, int $gasPrice, int $gasLimit): self { /* ... */ }
}
This gives you one place to validate hex, normalize zeros, and swap in web3p/ethereum-tx later if you need EIP-1559.
Testing
Keep a set of known vectors (ethers.js / web3.py output for identical inputs) and assert byte equality in unit tests. Then do one real eth_sendRawTransaction on Sepolia before shipping. The signing path is deterministic, so a single golden test catches almost every regression.
Every field is a hex string without 0x — including "numbers"
nonce, gasPrice, gasLimit, value, and chainId are all raw hex strings. Passing 9 instead of '09', or '0x5208' instead of '5208', produces a transaction that RLP-encodes but is semantically wrong (or throws). Strip 0x at the boundary and never look back.
Leading zeros and RLP integer encoding
RLP requires integers to be minimally encoded. A value of '00' or '000000' can produce bytes that differ from a reference implementation. Normalize numeric fields: left-trim leading zeros, and decide explicitly what you pass for zero ('' vs '00'). Test this against a known vector — this is the single most common source of "my tx hash doesn't match ethers" bugs.
Private keys are raw 32-byte hex, no 0x
sign() wants 64 hex characters. Not a WIF, not a mnemonic, not a keystore JSON. If you're loading from a keystore, decrypt it yourself first and pass the raw hex. Store keys in env/secret manager and never log the argument to sign().
to is 40 hex chars; deployment is ''
No EIP-55 checksum validation is done — checksummed, lowercase, and uppercase addresses all encode to the same bytes, so a bad-but-well-formed address will happily sign. Validate preg_match('/^[0-9a-fA-F]{40}$/', $to) yourself and compare against an expected whitelist if you're moving funds.
Chain ID is not optional in practice
Omitting chainId (or passing null) yields a pre-EIP-155 signature with v = 27/28, which modern nodes reject for replay protection. Always pass the chain ID from eth_chainId and double-check you're not mixing mainnet values into a testnet run — that's a silent funds-loss path.
No RPC, no ABI, no gas estimation, no nonce tracking
The package does one thing. You must supply nonce, gasPrice, gasLimit, chainId, and any data (ABI-encoded). If you want those handled for you, use web3p/ethereum-tx or web3p/web3.php's contract layer.
EIP-1559 support depends on your version
Check the installed version's source/tests rather than the README before assuming type-2 support. If you need maxFeePerGas/maxPriorityFeePerGas and it isn't there, either upgrade, submit legacy type-0 transactions (still accepted), or move to web3p/ethereum-tx. Pin the version in composer.json — with 0 dependents and a small maintainer surface, an unexpected minor bump is a real risk.
Pure-PHP EC signing is slow
Expect single-digit to low-tens of milliseconds per signature, and noticeably more on cold PHP-FPM workers. It's fine for wallet-style flows and CI; benchmark before putting it in a hot loop. Cache nothing that contains a private key.
Deterministic signatures are a feature, not a bug
RFC 6979 means the same inputs always yield the same raw tx. That makes retries idempotent and testing trivial — but it also means signing with a reused nonce and different payloads is instantly catastrophic. Guard nonce allocation with a lock.
Debugging raw transaction mismatches
When eth_sendRawTransaction returns rlp: ... or invalid sender, diff your hex against ethers.js or web3.py for the exact same inputs, field by field. Nine times out of ten the culprit is an odd-length hex field, a stray 0x, or a leftover leading zero in value/gasPrice.
Verify the tx hash independently
The tx hash is keccak256(rawTxBytes). If getHash() isn't available in your version, compute it with kornrunner/keccak over hex2bin(ltrim($raw, '0x')) and log it — it's your only handle for polling after broadcast.
already known is success
When you re-broadcast a previously submitted raw tx, most nodes return an "already known" / "nonce too low" error. Treat those as idempotent success, not failure.
Extension points are minimal by design
Transaction is not heavily abstracted. For custom behavior (EIP-2930 access lists, fee oracles, metadata) wrap it rather than subclass it — you're better off with a value object you own than with fragile inheritance over a single-file class.
How can I help you explore Laravel packages today?