Most teams meet transaction signing at the worst possible moment: right before release, when “send” needs to work on every network your users might pick.
In staging, signatures look fine. In production, you get a support thread that reads like a riddle. The raw transaction looks valid, yet the chain rejects it. The nonce “should be correct,” until two requests race. The fee fields look modern until someone adds gasPrice out of habit, and serialization starts failing.
A signing module earns its keep when it turns all of that into a small set of predictable outcomes. The rest of your app should be able to say “sign this intent” and get back the same kind of result every time, regardless of whether the underlying implementation uses Ethers.js or web3.js.
What a “transaction signing module” is
A transaction signing module is the boundary where an application’s intent becomes a signed byte sequence. Upstream code describes what the user wants to do (send value, call a contract method, approve a token). Downstream code handles broadcast, tracking, and receipts. The signing module is where you commit to values that must be correct for the network to accept the transaction: chain ID, nonce, transaction type, and fee fields.
It’s also where a common misconception dies: signing is not purely local. The cryptography can be offline, yet the transaction structure is shaped by network state. Nonce depends on account history. Fee rules depend on the chain’s fee market. Some networks accept dynamic-fee transactions consistently, while others behave closer to legacy Ethereum. If your module signs partial objects and hopes a provider will “fill the rest,” you’ll eventually ship transactions that fail only in production.
What the module takes in, and what it must return
Keep the interface strict and boring. A good module takes an intent object and returns deterministic artifacts.
Input (intent):
- recipient (to)
- value (value)
- calldata (data)
- optional policy hints (speed tier, desired chain, optional gas cap)
Output:
- serialized signed transaction (raw bytes/hex)
- a summary object safe for UI and logs (chainId, nonce, fees, gas limit, to, value, method label if available)
That summary output is not “nice to have.” It’s how you debug without exposing secrets. It also keeps UI consistent: confirmation screens can display exactly what was signed, not what the UI thinks it asked to sign.
Two design decisions that remove 80% of signing chaos
A clean split between “intent” and “final transaction”
Intent is human-level: “send 0.2 ETH to Alice,” “swap,” “approve.” Final transaction is machine-level: exact nonce, exact fee fields, exact gas limit, exact transaction type, exact chainId. The module should be the only place where intent turns into final transaction, because that’s the only way to keep behavior consistent across UI, workers, and backend services.
One owner for network-dependent fields
Fields like nonce, chainId, gas estimate, and base fee are network-dependent. If those are computed in multiple places, you get drift. UI uses one provider, backend uses another. A hook refetches late and overwrites state. A retry path recomputes fees differently from the first attempt. A signing module centralizes those decisions and becomes the single owner of how the product behaves.
The signing workflow that stays stable over time
Step 1: Chain identity is locked before anything else
Start by treating the active network as a security input. The module should read chainId from the signer’s connected provider at the moment of signing and compare it with whatever the app UI or backend believes is selected. If those values don’t match, signing should stop immediately with a clear, user-facing error.
This protects you from a common real-world situation: the user changes networks in their wallet between the time the UI prepares a transaction and the time they approve it. It also protects you from configuration drift in multi-chain apps, where one part of the system is still pointed at Chain A while the signer is already on Chain B.
Step 2: Nonce is resolved using a policy, not a single RPC call
Nonce should be handled like a shared resource, because it is. A single getTransactionCount call is fine when requests are serialized naturally, but production traffic rarely stays that polite. Users double-tap. Background jobs trigger sends. Retries fire after timeouts. If two transactions sign against the same nonce, one will be dropped, replaced, or rejected with “nonce too low,” depending on timing and miner behavior. Your module needs a nonce policy that you can explain.
The simplest policy is to serialize signing per account so the module only signs one pending nonce at a time and queues the rest. If you expect high throughput, you can reserve nonces locally and reconcile periodically, yet that design must also handle replacements, cancellations, and dropped transactions without leaving the local counter out of sync. Whichever approach you choose, the module should return the selected nonce as part of its summary output so that support and debugging can reason about what happened.
Step 3: Transaction type is chosen deliberately, and fee fields are enforced
After the chain and nonce are decided, the module should choose a transaction type explicitly rather than letting it drift based on whichever fields happen to be present. This is where many systems accidentally create invalid payloads: they include EIP-1559 dynamic-fee fields and also include gasPrice, or they omit a fee field and assume the signer will “fix it.” A good module applies a single coherent model.
For chains where you support EIP-1559 style transactions, it should construct a type-2 transaction with maxFeePerGas and maxPriorityFeePerGas and ensure the relationship between those fields is valid. For chains that behave like legacy pricing, it should produce a legacy transaction that uses gasPrice and does not include dynamic-fee fields at all.
Step 4: Gas limit is treated as a product decision
Gas limit has a habit of becoming “whatever estimateGas says,” and that’s often where reliability starts to wobble. Estimation can fail for legitimate reasons, such as contracts that revert under simulation, calls that depend on transient state, or provider-specific quirks. Even when estimation works, it can be volatile. A signing module should decide how it behaves when estimation fails and how it caps risk.
For a simple value transfer, you may choose a known fixed gas limit. For contract calls, you may estimate and then apply a conservative margin that prevents underestimation without letting a bug sign a wildly expensive transaction. If your product has risk boundaries, such as “never sign transactions above this gas ceiling,” the signing module is the right place to enforce them, because it sees the final transaction before the user approves it.
Step 5: Preflight simulation is used to catch preventable failures early
Before signing a contract interaction, it’s worth simulating the call using eth_call against the pending state whenever you can. This step doesn’t guarantee success, but it catches the failures that are most painful when discovered late: wrong calldata, missing approvals, insufficient balance, or contract-side checks that will definitely revert.
The key is how you handle failures. A good module treats a simulation revert as a structured error with a message your UI can turn into something actionable. That improves user trust because the user learns “this action will fail” before they sign and pay fees, rather than after the transaction sits on-chain and reverts.
Step 6: Signing produces two outputs that the rest of the app can rely on
When everything is resolved and validated, the module signs the final transaction object and returns two artifacts every time. The first is the serialized signed transaction payload, suitable for broadcasting through any transport. The second is a summary object that is safe to store and display, capturing the chainId, nonce, transaction type, fee fields, gas limit, recipient, and value.
This summary becomes the source of truth for confirmations and receipts. It also becomes the debugging interface when things go wrong. If a transaction is stuck, you can inspect the fee policy that was applied. If a transaction is rejected, you can see whether the chainId or nonce was inconsistent. If a user reports “I signed something different,” you can compare what the UI showed with the actual finalized fields that were signed.
Where Ethers.js fits cleanly
Ethers encourages a split that maps well to the module boundary: a Provider reads state, a Signer signs. Your module should accept a signer abstraction, not a private key string. That reduces accidental key leakage and keeps the door open for hardware wallets, secure enclaves, or external signing services later.
A common footgun is letting Ethers “fill in” fields implicitly. If you pass incomplete transaction objects, behavior can differ depending on provider, signer type, and environment. Your module should do the resolution explicitly, then hand Ethers a complete object to sign.
Where web3.js fits cleanly
web3.js is blunt about something many people learn the hard way: signing requires a complete transaction object, and you usually need network access to get nonce and chain details unless you provide them. That strictness can be a benefit when you design a signing module, because it forces completeness.
If you use web3.js, decide early whether your module always fills nonce and chainId internally, or always requires them from the caller. Mixing those approaches creates the “works on one network, fails on another” pattern because different callers pass different levels of completeness.
The small set of rules that prevent the worst failures
Never mix legacy and EIP-1559 fee fields in the same transaction
A transaction should follow one fee model all the way through. If you include gasPrice alongside maxFeePerGas or maxPriorityFeePerGas, you’re creating an internally inconsistent payload. Some libraries will refuse to serialize it, and others will pick one set of fields in a way that’s easy to miss in code review. Your signing module should validate this explicitly right before signing and fail fast with a clear error, because this mistake is common during refactors and chain expansions.
Enforce a coherent relationship between max fee and priority fee
For EIP-1559 style transactions, maxFeePerGas represents the maximum total fee the user is willing to pay, while maxPriorityFeePerGas represents the tip portion. If maxFeePerGas is lower than maxPriorityFeePerGas, the fee policy is impossible to satisfy under any base fee. This is not a “maybe it will work” situation. It should be rejected as invalid input before signing, and your error should make it obvious which field needs adjustment or which speed tier produced the incoherent values.
Require chainId at signing time and treat a mismatch as a hard stop
Signing without a verified chainId is a reliability and safety problem. It can produce signatures that fail validation, and it can create confusion when the app believes it is operating on one chain while the wallet is connected to another. The module should read chainId from the signer’s provider at the moment of signing, compare it to the intended chain, and refuse to proceed if there’s any mismatch. This turns a subtle, hard-to-debug class of failures into a clear, user-resolvable issue.
Treat nonce as a stateful value that can collide under concurrency
Nonce errors are rarely caused by “bad math.” They’re caused by two signing attempts happening closer together than your system assumed. If you treat nonce as a simple value fetched from the network for each send, you’ll eventually sign two transactions with the same nonce. Your module needs to own a nonce policy that makes collisions predictable: either serialize signing per account or implement nonce reservation with reconciliation. When failures happen, the module should surface the nonce context in its summary output so you can understand whether the transaction was replaced, dropped, or rejected.
Refuse to sign transactions with ambiguous or incomplete type selection
Transaction type should be a deliberate choice, not an accidental inference from whatever fields happened to be present. If type selection is ambiguous, you can end up signing a payload that is valid for one network and rejected by another, or a payload whose serialization differs across libraries. Your module should decide the type based on the current chain’s capabilities and your product policy, then validate that all required fields for that type are present and that no incompatible fields remain before signing.
Conclusion
A transaction signing module is small code with outsized consequences. Its job is to make signing deterministic by controlling inputs, enforcing transaction type rules, and treating chainId and nonce as validated state rather than “just fields.” Once that boundary is stable, Ethers.js and web3.js become interchangeable backends: the rest of your app asks for a signature and gets a predictable artifact back. That’s when “send” stops being a fragile edge of your product and starts behaving like infrastructure.
If you want, I can also rewrite your existing draft in this format line-by-line (keeping your original content, just reorganized), so you don’t lose any details you already like.




