eth_sendRawTransaction to submit an offline-signed withdrawal.
The JavaScript and Java examples below are excerpted from compile-tested samples. See Tested Exchange Examples.
Online and offline split
| Step | Environment | Data |
|---|---|---|
| Fetch nonce and fees | Online | eth_getTransactionCount, eth_gasPrice, eth_maxPriorityFeePerGas, eth_estimateGas |
| Build unsigned transaction | Online or signing coordinator | chainId, nonce, to, value, gas fields, optional data |
| Sign transaction | Offline signer or HSM | Private key, unsigned transaction |
| Analyze signed transaction | Offline and online | Hash, sender, chain ID, nonce, recipient, value, data selector |
| Broadcast | Online | Raw signed transaction bytes via eth_sendRawTransaction |
| Confirm | Online | eth_getTransactionReceipt with status == 1 |
Transaction fields
| Field | Native CTM transfer | ERC-20 transfer |
|---|---|---|
to | Recipient account | Token contract |
value | Amount in wei | 0 |
data | Empty | ABI-encoded transfer(address,uint256) |
gasLimit | Usually 21000 | Use eth_estimateGas plus policy buffer |
chainId | 8 mainnet, 8888 testnet | Same |
type | Dynamic fee transaction is recommended | Dynamic fee transaction is recommended |
JavaScript
import {
Interface,
JsonRpcProvider,
Transaction,
Wallet,
parseEther,
parseUnits,
} from "ethers";
const provider = new JsonRpcProvider("https://public-evm-rpc.c8ntinuum.com");
const chainId = 8;
const privateKey = process.env.PRIVATE_KEY;
const signer = new Wallet(privateKey);
async function feeFields() {
const feeData = await provider.getFeeData();
return {
maxFeePerGas: feeData.maxFeePerGas ?? feeData.gasPrice,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
};
}
async function signNativeTransfer(to) {
const from = await signer.getAddress();
const nonce = await provider.getTransactionCount(from, "pending");
const fees = await feeFields();
const unsignedTx = {
type: 2,
chainId,
nonce,
to,
value: parseEther("1.0"),
gasLimit: 21000n,
...fees,
};
const raw = await signer.signTransaction(unsignedTx);
const decoded = Transaction.from(raw);
console.log({
hash: decoded.hash,
from: decoded.from,
chainId: decoded.chainId?.toString(),
nonce: decoded.nonce,
to: decoded.to,
value: decoded.value.toString(),
});
return raw;
}
async function signErc20Transfer(token, recipient) {
const from = await signer.getAddress();
const nonce = await provider.getTransactionCount(from, "pending");
const erc20 = new Interface(["function transfer(address to,uint256 amount) returns (bool)"]);
const data = erc20.encodeFunctionData("transfer", [
recipient,
parseUnits("12.5", 18),
]);
const fees = await feeFields();
const gasLimit = await provider.estimateGas({
from,
to: token,
value: 0,
data,
});
return signer.signTransaction({
type: 2,
chainId,
nonce,
to: token,
value: 0,
data,
gasLimit: (gasLimit * 120n) / 100n,
...fees,
});
}
async function broadcast(raw) {
const sent = await provider.broadcastTransaction(raw);
const receipt = await sent.wait();
if (receipt.status !== 1) throw new Error(`transaction failed: ${sent.hash}`);
return receipt;
}
Java
import java.math.BigInteger;
import java.util.Arrays;
import org.web3j.abi.FunctionEncoder;
import org.web3j.abi.TypeReference;
import org.web3j.abi.datatypes.Address;
import org.web3j.abi.datatypes.Bool;
import org.web3j.abi.datatypes.Function;
import org.web3j.abi.datatypes.generated.Uint256;
import org.web3j.crypto.Credentials;
import org.web3j.crypto.RawTransaction;
import org.web3j.crypto.TransactionDecoder;
import org.web3j.crypto.TransactionEncoder;
import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.DefaultBlockParameterName;
import org.web3j.protocol.core.methods.response.EthSendTransaction;
import org.web3j.protocol.http.HttpService;
import org.web3j.utils.Convert;
import org.web3j.utils.Numeric;
Web3j web3j = Web3j.build(new HttpService("https://public-evm-rpc.c8ntinuum.com"));
Credentials credentials = Credentials.create(System.getenv("PRIVATE_KEY"));
long chainId = 8L;
BigInteger nonce = web3j
.ethGetTransactionCount(credentials.getAddress(), DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
BigInteger maxPriorityFeePerGas = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
BigInteger maxFeePerGas = web3j.ethGasPrice().send().getGasPrice().multiply(BigInteger.valueOf(2));
RawTransaction nativeTx = RawTransaction.createEtherTransaction(
chainId,
nonce,
BigInteger.valueOf(21_000),
"0x1111111111111111111111111111111111111111",
Convert.toWei("1.0", Convert.Unit.ETHER).toBigInteger(),
maxPriorityFeePerGas,
maxFeePerGas);
byte[] signed = TransactionEncoder.signMessage(nativeTx, chainId, credentials);
String raw = Numeric.toHexString(signed);
var decoded = TransactionDecoder.decode(raw);
System.out.println("raw=" + raw);
System.out.println("hash=" + org.web3j.crypto.Hash.sha3(raw));
System.out.println("from=" + credentials.getAddress());
System.out.println("chainId=" + chainId);
System.out.println("nonce=" + decoded.getNonce());
System.out.println("to=" + decoded.getTo());
System.out.println("value=" + decoded.getValue());
System.out.println("gasLimit=" + decoded.getGasLimit());
System.out.println("maxPriorityFeePerGas=" + maxPriorityFeePerGas);
System.out.println("maxFeePerGas=" + maxFeePerGas);
EthSendTransaction sent = web3j.ethSendRawTransaction(raw).send();
String txHash = sent.getTransactionHash();
System.out.println("broadcastHash=" + txHash);
Function transfer = new Function(
"transfer",
Arrays.asList(new Address("0x1111111111111111111111111111111111111111"), new Uint256(new BigInteger("12500000000000000000"))),
Arrays.asList(new TypeReference<Bool>() {}));
String data = FunctionEncoder.encode(transfer);
RawTransaction erc20Tx = RawTransaction.createTransaction(
chainId,
nonce.add(BigInteger.ONE),
BigInteger.valueOf(80_000),
"0xc8Fb80fCc03f699C70ff0CC08C09106288888888",
BigInteger.ZERO,
data,
maxPriorityFeePerGas,
maxFeePerGas);
String erc20Raw = Numeric.toHexString(
TransactionEncoder.signMessage(erc20Tx, chainId, credentials));
var decodedErc20 = TransactionDecoder.decode(erc20Raw);
System.out.println("erc20DataSelector=" + decodedErc20.getData().substring(0, 10));
Go
package withdrawals
import (
"context"
"crypto/ecdsa"
"math/big"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
var chainID = big.NewInt(8)
func SignNativeTransfer(ctx context.Context, client *ethclient.Client, key *ecdsa.PrivateKey, to common.Address, amount *big.Int) ([]byte, common.Hash, error) {
from := crypto.PubkeyToAddress(key.PublicKey)
nonce, err := client.PendingNonceAt(ctx, from)
if err != nil {
return nil, common.Hash{}, err
}
tip, err := client.SuggestGasTipCap(ctx)
if err != nil {
return nil, common.Hash{}, err
}
feeCap, err := client.SuggestGasPrice(ctx)
if err != nil {
return nil, common.Hash{}, err
}
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: chainID,
Nonce: nonce,
GasTipCap: tip,
GasFeeCap: new(big.Int).Mul(feeCap, big.NewInt(2)),
Gas: 21_000,
To: &to,
Value: amount,
})
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), key)
if err != nil {
return nil, common.Hash{}, err
}
raw, err := signed.MarshalBinary()
if err != nil {
return nil, common.Hash{}, err
}
return raw, signed.Hash(), nil
}
func SignERC20Transfer(ctx context.Context, client *ethclient.Client, key *ecdsa.PrivateKey, token common.Address, recipient common.Address, amount *big.Int) (*types.Transaction, error) {
erc20ABI, err := abi.JSON(strings.NewReader(`[{"name":"transfer","type":"function","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"type":"bool"}]}]`))
if err != nil {
return nil, err
}
data, err := erc20ABI.Pack("transfer", recipient, amount)
if err != nil {
return nil, err
}
from := crypto.PubkeyToAddress(key.PublicKey)
nonce, err := client.PendingNonceAt(ctx, from)
if err != nil {
return nil, err
}
tip, err := client.SuggestGasTipCap(ctx)
if err != nil {
return nil, err
}
feeCap, err := client.SuggestGasPrice(ctx)
if err != nil {
return nil, err
}
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: chainID,
Nonce: nonce,
GasTipCap: tip,
GasFeeCap: new(big.Int).Mul(feeCap, big.NewInt(2)),
Gas: 80_000,
To: &token,
Value: big.NewInt(0),
Data: data,
})
return types.SignTx(tx, types.LatestSignerForChainID(chainID), key)
}
func Broadcast(ctx context.Context, client *ethclient.Client, signed *types.Transaction) error {
return client.SendTransaction(ctx, signed)
}
Rust
use alloy::consensus::{SignableTransaction, TxEip1559, TxEnvelope};
use alloy::eips::eip2718::Encodable2718;
use alloy::network::TxSignerSync;
use alloy::primitives::{address, Bytes, TxKind, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::signers::local::PrivateKeySigner;
fn build_native_tx(
nonce: u64,
to: alloy::primitives::Address,
value: U256,
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
) -> TxEip1559 {
TxEip1559 {
chain_id: 8,
nonce,
gas_limit: 21_000,
max_fee_per_gas,
max_priority_fee_per_gas,
to: TxKind::Call(to),
value,
input: Bytes::new(),
access_list: Default::default(),
}
}
fn sign_offline(tx: &mut TxEip1559, private_key: &str) -> eyre::Result<Vec<u8>> {
let signer: PrivateKeySigner = private_key.parse()?;
let signature = signer.sign_transaction_sync(tx)?;
let signed = tx.clone().into_signed(signature);
let envelope: TxEnvelope = signed.into();
Ok(envelope.encoded_2718())
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
let provider = ProviderBuilder::new().on_http(
"https://public-evm-rpc.c8ntinuum.com".parse()?,
);
let mut tx = build_native_tx(
7,
address!("0000000000000000000000000000000000000001"),
U256::from(1_000_000_000_000_000_000u128),
50_000_000_000,
1_000_000_000,
);
let raw = sign_offline(&mut tx, &std::env::var("PRIVATE_KEY")?)?;
let pending = provider.send_raw_transaction(&raw).await?;
let receipt = pending.get_receipt().await?;
if !receipt.status() {
eyre::bail!("transaction failed");
}
Ok(())
}
Signed data analysis
Before broadcasting, decode the raw signed transaction and verify:chainIdis8for mainnet or8888for testnet.frommatches the expected hot wallet, withdrawal wallet, HSM key, or custody account.noncematches the reserved nonce in your withdrawal database.to,value, anddatamatch the approved withdrawal instruction.- ERC-20 withdrawals call
transfer(address,uint256)on the intended token contract. - Gas fields satisfy your policy and are not extreme.
- The raw transaction hash is stored before broadcast.
Broadcasting and polling
Broadcast witheth_sendRawTransaction, then poll eth_getTransactionReceipt by hash until one of these happens:
| Result | Action |
|---|---|
Receipt status == 1 | Mark withdrawal confirmed. |
Receipt status == 0 | Mark failed and investigate before retrying. |
null receipt | Continue polling until timeout. |
| Nonce too low | Check if the transaction was already mined or replaced. |
| Transaction underpriced | Rebuild with a replacement transaction using the same nonce and higher fee, if policy allows. |
| Insufficient funds | Stop retries until the sender has native CTM for value plus gas. |