@crewhaus/tool-onchain
v0.8.0
Published
Deterministic onchain tools: ABI encoding and decoding, EIP-55 address checksums, EIP-712 and EIP-191 hashing, unit conversion and fixed-point DeFi maths
Readme
@crewhaus/tool-onchain
The onchain arithmetic, offline.
| Tool | Answers |
|---|---|
| AbiEncodeCall | what are the exact bytes of this contract call |
| AbiDecode | what does this returned hex actually say |
| FunctionSelector | what is this function's selector, or this event's topic |
| AddressCheck | is this address well-formed, and does its checksum hold |
| TypedDataHash | what digest would a wallet sign for this message |
| TokenUnits | what is this amount in base units, exactly |
| DefiMath | minimum out, maximum in, price impact, share, health factor |
No RPC, no network, no keys. These are the calculations that must be right before a transaction is composed — and a transaction is irreversible, so "plausible" is not good enough.
Nothing here signs
No private key is accepted, read or handled anywhere in this package, and a test asserts that no schema has a field one could be passed to.
TypedDataHash computes the digest a wallet would sign. That is the useful
half, because it lets a signature request be checked before it is
approved: hash the message a human was actually shown and compare it against
what the dapp asked for. A malicious dapp's whole trick is the gap between
those two.
Verified against published vectors, not against itself
A codec tested only against its own output agrees with itself and with nothing on a chain. So:
- ABI encoding is checked against real ERC-20
transfercalldata, byte for byte, and selectors against the ones every block explorer shows. - EIP-712 is checked against the specification's own worked example — the type hash, the domain separator, the struct hash and the final digest.
- EIP-55 is checked against the addresses in that specification.
- Keccak-256, in
@crewhaus/tool-encode, is checked against the standard vectors. It is not SHA3-256: same permutation, different padding, and substituting one gives a plausible digest that is wrong for every Ethereum purpose. WebCrypto offers neither, which is exactly the trap.
The mistakes it exists to prevent
A misplaced offset. Dynamic values move to a tail and leave an offset behind, relative to the start of the enclosing tuple — not the call. A wrong offset produces calldata a node accepts and a contract misreads.
Alignment. Fixed bytes are left-aligned, integers right-aligned.
bytes4anduint32holding "the same" value are different words.A canonical signature that differs from what was typed.
uintisuint256in the string the selector is hashed over, so writinguintand hashing it literally gives a selector no contract answers to.Precision. A
uint256does not fit in a double; a balance read as a number loses its low digits, and two balances one wei apart become the same value. Integers arebigintor decimal strings throughout, and a number past the safe range is refused rather than accepted after it has already lost precision.0.1 * 1e18.TokenUnitsis string arithmetic, and it refuses an amount with more decimal places than the token has rather than dropping the extra digits.The direction of a rounding error in a bound. A minimum is rounded down and a maximum up; the other way round rejects a swap that was inside tolerance or accepts one that was not. Slippage is basis points, never a percentage, because a percentage invites a decimal and a decimal invites a float.
Data that decodes to far more than itself. Return data comes from whoever deployed the contract, and nothing in the ABI format stops two offsets pointing at the same bytes: a few kilobytes whose heads all share one tail decode to millions of values.
AbiDecode(and every package that decodes through it) refuses data that decodes to more than four times its own size — no encoder writes such data — and checks an array's length against the bytes that follow it before building anything. It also counts the values it produces, tuples and arrays included, and stops at what the data could hold for those types if each word were read once. An empty tuple()is refused as a type to decode: no Solidity type is one, and it reads no bytes, so a type string full of them multiplied every word of data on its own. Type strings are capped at 8,192 characters and 32 levels of nesting.An address re-typed between two calls.
AbiEncodeCallandTypedDataHash(message fields andverifyingContractalike) check every address they encode the wayAddressCheckdoes: 0x and 40 hex characters, and a mixed-case address must pass its EIP-55 checksum. All-lowercase and all-uppercase hex carry no checksum and are accepted, as ethers and viem accept them.
An address with no checksum is reported as valid and as unverifiable,
rather than letting valid: true be read as "no typo". A position with no
debt has no health factor rather than an infinite one — Infinity would read
as safe to a caller comparing against a threshold.
Multicall3, as library code
encodeAggregate3, decodeAggregate3 and decodeRevertData are exported
from the package but register no tool: packing a batch is only useful to
something that can dial a chain, and nothing here dials anything. They pack
aggregate3((address,bool,bytes)[]) and read (bool,bytes)[] back.
Two properties are the reason they exist:
- A failed sub-call is data. With
allowFailure— which defaults to true — the batch succeeds and the failed entry comes back withsuccess: falseand its revert bytes. A batch whose own answer cannot be read throws instead, because that is a different event: there are no partial results. - Results are matched by position, and nothing in the return blob names the call it answers. A blob holding a different number of results than the batch sent is refused rather than zipped against the calls, since a balance read against the wrong token is worse than no balance.
Revert bytes are decoded where they say something: Error(string) gives its
reason, Panic(uint256) the documented meaning of its code. An unrecognised
selector keeps its hex and gets no message — a guess at a custom error is
worse than the bytes, which are at least searchable.
The canonical Multicall3 address is a default, not a constant: the deterministic deploy is at the same address on most chains, which is not the same as all of them, so every entry point takes it as an argument.
What it does not do
- It does not reach a chain. No RPC calls, no balances, no gas estimates, no broadcasting. Those need a node and belong elsewhere.
- It does not sign or hold keys.
- It does not know what a contract does.
AbiDecodedecodes the types you name; naming the wrong ones produces confident nonsense, which is why data that ends early is an error rather than a short answer. So is a word that is not the encoding of its type — auint8holding 256, anaddressorbytes4with non-zero padding, anint8that is not sign-extended — which is how Solidity's own decoder treats it, and what reading auint256slot as auint8looks like. - It does not fetch an ABI. Signatures come from the caller.
- It does not price anything.
DefiMathcomputes against a reference you supply; where that reference came from is your problem.
