Your Solidity contract1 computes keccak256(...), your Node backend computes createHash("sha3-256") over the same bytes, and the two results look nothing alike. There's no bug in your byte handling: you're using two different functions that share the same permutation and differ in a single padding2 byte.

This post ties up a loose end from Buffer vs Uint8Array in TypeScript crypto, where I left it at one line: "Keccak-256 ≠ SHA3-256 (FIPS)"3. Here's the why, with numbers. As in the AES-GCM post, the examples use encloom 2.0.4 (encloom/sha3), and every digest4 in this article comes from actually running them.

Not an Ethereum guide. About one padding byte, and one word, "sha3", that means two things.

Two functions, one engine

This is src/sha3.ts on main (commit 3c97dab, JSDoc comments omitted):

import { keccak_256, sha3_256 } from "@noble/hashes/sha3.js"; export function sha3(msg: Uint8Array): Uint8Array { return sha3_256(msg); } export function keccak256(msg: Uint8Array): Uint8Array { return keccak_256(msg); } 

Both take a Uint8Array5 and return a 32-byte Uint8Array. Both delegate to @noble/hashes. encloom 2.0.4 declares "@noble/hashes": "^2.2.0". The repo's lockfile has pinned 2.3.0 since 2.0.3, and a clean install today resolves 2.4.0. In all three versions, sha3_256 and keccak_256 are defined the same way.

Same input:

import { sha3, keccak256 } from "encloom/sha3"; import { bufferToHex, utf8ToBuffer } from "encloom/helpers/encoding"; const empty = new Uint8Array(0); console.log("sha3('') ", bufferToHex(sha3(empty))); console.log("keccak256('') ", bufferToHex(keccak256(empty))); const abc = utf8ToBuffer("abc"); console.log("sha3('abc') ", bufferToHex(sha3(abc))); console.log("keccak256('abc')", bufferToHex(keccak256(abc))); console.log(sha3(abc).length, keccak256(abc).length); 
sha3('') a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a keccak256('') c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 sha3('abc') 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532 keccak256('abc') 4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45 32 32 

Memorize those two empty-string prefixes: a7ffc6f8… is SHA3-256 and c5d24601… is Keccak-256. They're the fastest test for figuring out which function you're looking at. The SHA3-256 one matches Python's hashlib.sha3_256(b"") and openssl dgst -sha3-256. The Keccak-256 one matches ethers.keccak256(new Uint8Array(0)) and openssl dgst -keccak-256.

Same length, same look. Nothing in the format tells you they're different functions.

One byte apart: 0x06 vs 0x01

Keccak is a sponge6 function. It absorbs the message in blocks of r bits (the rate) into a 1600-bit state, applies the Keccak-f[1600] permutation after each block, and finally squeezes out the output. For the 256-bit version, the rate is 1088 bits (136 bytes) and the capacity is 5127. None of that differs between SHA3-256 and Keccak-256.

What differs is what gets appended to the message before padding. FIPS 202, Section 6.1, defines:

SHA3-256(M) = KECCAK[512] (M || 01, 256) 

That 01 is two bits of domain suffix8 stuck onto the message. Then comes the usual padding, pad10*1: a 1 bit, zeros, and a 1 bit at the end of the block. The Keccak submitted to the competition appends no suffix.

For byte-aligned messages, that boils down to one byte. The Keccak Team's specifications page calls it d, the "delimited suffix", which encodes the suffix bits plus the first 1 of the padding: d = 0x06 for SHA3-224/256/384/512 and 0x1F for SHAKE. With the same formula and no suffix bits, d is 0x01: the original Keccak. The last byte of the block gets 0x80 in both cases.

Bytes appended (q) SHA3-256 (FIPS 202, Table 6) Keccak-256 (original)
q > 2 M ‖ 0x06 ‖ 0x00… ‖ 0x80 M ‖ 0x01 ‖ 0x00… ‖ 0x80
q = 2 M ‖ 0x0680 M ‖ 0x0180
q = 1 M ‖ 0x86 M ‖ 0x81

You don't have to take it on faith. @noble/hashes exports the Keccak(blockLen, suffix, outputLen) class, and changing the suffix is all it takes to switch from one function to the other:

import { Keccak } from "@noble/hashes/sha3.js"; import { sha3, keccak256 } from "encloom/sha3"; import { bufferToHex } from "encloom/helpers/encoding"; // Same permutation, same rate (136 bytes), same output (32 bytes). // The only change is the suffix byte that goes in before the padding. const withSuffix = (suffix: number, msg: Uint8Array) => new Keccak(136, suffix, 32).update(msg).digest(); const msg = new Uint8Array(0); console.log("suffix 0x06:", bufferToHex(withSuffix(0x06, msg)), bufferToHex(withSuffix(0x06, msg)) === bufferToHex(sha3(msg))); console.log("suffix 0x01:", bufferToHex(withSuffix(0x01, msg)), bufferToHex(withSuffix(0x01, msg)) === bufferToHex(keccak256(msg))); 
suffix 0x06: a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a true suffix 0x01: c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 true 

(I import @noble/hashes directly only for the demo. If you use it in your own code, declare it as your own dependency instead of relying on the one encloom brings in.)

One different byte in the last block is enough: the permutation mixes the whole state over 24 rounds, and the outputs bear no resemblance. There's no way to convert one digest into the other. If you were handed a SHA3-256 hash and you need the Keccak-256, you have to go back to the original bytes.

How it happened: a competition, a standard, and a network that didn't wait

The dates, all from primary sources:

  • November 2, 2007. NIST announces the SHA-3 competition (CSRC, SHA-3 Project).
  • January 2011. The Keccak team (Bertoni, Daemen, Peeters, and Van Assche) submits version 3 of their proposal. It's reference [2] in FIPS 202.
  • October 2, 2012. NIST closes the competition and announces Keccak as the winner (same CSRC page).
  • April 1, 2014. The Ethereum Yellow Paper9 is released (ethereum.org, history).
  • May 2014. NIST publishes the Draft FIPS 202, which already defines SHA3-256(M) = KECCAK[512](M || 01, 256).
  • July 30, 2015. Frontier, Ethereum's mainnet, goes live (ethereum.org).
  • August 2015. Final FIPS 202 is published (CSRC; the document history says August 4, and the Federal Register notice is dated August 5).

In other words, Ethereum was already running days before the final SHA-3 standard existed. The current version of the Yellow Paper defines its hash function as "the Keccak-256 hash function (as per version 3 of the winning entry to the SHA-3 contest by Bertoni et al. [2011], rather than the final SHA-3 specification)", and calls it KEC.

Why did NIST add the suffix? FIPS 202 explains it in the introduction: the four SHA-3 functions "differ slightly from the instances of KECCAK that were proposed for the SHA-3 competition. In particular, a two-bit suffix is appended to the messages, in order to distinguish the SHA-3 hash functions from the SHA-3 XOFs". It's domain separation: SHA3-256 and SHAKE256 use the same capacity (512 bits), and the suffix (01 vs 1111) keeps their inputs from ever colliding. The permutation doesn't change; the input does, and with it, the function.

Why "sha3" means two things

The real problem isn't cryptographic, it's naming. In the Ethereum ecosystem, "sha3" was used as a name for Keccak-256, and that name still shows up in APIs you use today:

What you see What it computes Source
Solidity keccak256(bytes) Keccak-256 Solidity docs
Solidity sha3(...) Keccak-256 (alias removed in 0.5.0) Same page: "There used to be an alias for keccak256 called sha3, which was removed in version 0.5.0."
JSON-RPC10 web3_sha3 Keccak-256 ethereum.org: "Returns Keccak-256 (not the standardized SHA3-256)"
web3.js web3.utils.sha3 Keccak-256 web3.js docs: "computes the Keccak-256 hash of the input"
ethers keccak256, id Keccak-256 ethers v6 docs
Node createHash("sha3-256") SHA3-256 (FIPS 202) Run below
Python hashlib.sha3_256 SHA3-256 (FIPS 202) Python docs: "the SHA-3 series (defined in the FIPS 202 standard)"
encloom sha3 / keccak256 SHA3-256 / Keccak-256 src/sha3.ts

Not even the Python docs are spared: the 3.6 version note says "SHA3 (Keccak) and SHAKE constructors … were added", but what it implements is FIPS 202. Keccak is the family, and SHA-3 is one specific instance of it. When someone writes "SHA3 (Keccak)", they're not telling you which suffix it uses.

The classic mistake, in numbers

Scenario: the backend has to reproduce something Ethereum computes, and someone picks the function by its name.

import { createHash, getHashes } from "node:crypto"; import { sha3, keccak256 } from "encloom/sha3"; import { bufferToHex, utf8ToBuffer } from "encloom/helpers/encoding"; const msg = "abc"; const node = createHash("sha3-256").update(msg).digest("hex"); console.log("node sha3-256 ", node); console.log("encloom sha3 ", bufferToHex(sha3(utf8ToBuffer(msg)))); console.log("encloom keccak256 ", bufferToHex(keccak256(utf8ToBuffer(msg)))); console.log("Node sha3-256 === keccak256?", node === bufferToHex(keccak256(utf8ToBuffer(msg)))); console.log("keccak hashes in this Node:", getHashes().filter((h) => h.startsWith("keccak-2"))); 
node sha3-256 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532 encloom sha3 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532 encloom keccak256 4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45 Node sha3-256 === keccak256? false keccak hashes in this Node: [ 'keccak-224', 'keccak-256' ] 

createHash("sha3-256") is FIPS 202, same as encloom's sha3(). Watch the last line: on my Node 24.22.1, with OpenSSL 3.5.7, getHashes() includes keccak-256. According to the Node docs, the available algorithms "depend on the version and configuration of OpenSSL on the platform", so don't count on it everywhere (and there's no node:crypto in the browser). Check getHashes() before you use it.

Now, two things Ethereum computes with Keccak-256 that every backend ends up reproducing:

  • the function selector11: per the Solidity ABI specification12, it's the first four bytes of the Keccak-256 of the signature (transfer(address,uint256));
  • the address13: per the Yellow Paper, it's the rightmost 160 bits of the Keccak-256 of the 64-byte ECDSA public key, that is, without the 0x04 prefix of the uncompressed format.
import { sha3, keccak256 } from "encloom/sha3"; import { getPublic } from "encloom/ecdsa"; import { bufferToHex, hexToBuffer, utf8ToBuffer } from "encloom/helpers/encoding"; // 1) Selector: the first 4 bytes of the hash of the canonical signature const sig = utf8ToBuffer("transfer(address,uint256)"); console.log("selector keccak256:", bufferToHex(keccak256(sig).slice(0, 4))); console.log("selector sha3 :", bufferToHex(sha3(sig).slice(0, 4))); // 2) Address: the last 20 bytes of the hash of the public key without the 0x04 const priv = hexToBuffer("0x" + "00".repeat(31) + "01"); // private key = 1 (demo only!) const pub = getPublic(priv); // 65 bytes: 0x04 || X || Y console.log("pub:", pub.length, "bytes, prefix", pub[0].toString(16).padStart(2, "0")); console.log("address keccak256: 0x" + bufferToHex(keccak256(pub.subarray(1)).slice(-20))); console.log("address sha3 : 0x" + bufferToHex(sha3(pub.subarray(1)).slice(-20))); 
selector keccak256: a9059cbb selector sha3 : 4b40e901 pub: 65 bytes, prefix 04 address keccak256: 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf address sha3 : 0x0502987e630ea7ebb2bf1d84a65a727109385bcf 

a9059cbb is the selector for transfer(address,uint256), the ERC-20 transfer function. With sha3 you get 4b40e901, and the contract can't find the function. The address for private key 1 is 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, and with sha3 you get a valid, well-formed address that belongs to nobody you know. That's the worst case: no error at all, just a wrong value.

To make sure encloom computes the same thing as the rest of the ecosystem, I cross-checked it against ethers 6.17.0 and viem 2.57.18:

encloom 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b ethers.id 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b viem 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b ethers computeAddress(1): 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf 

I also ran the web3_sha3 example from ethereum.org (0x68656c6c6f20776f726c64, i.e. "hello world") through encloom's keccak256, and got the same result the docs show: 0x47173285a8d7341e5e972fc677286384f802f8ef42a5ec5f03bbfa254cb01fad.

Uint8Array: which bytes are you hashing?

Picking the right function is half the problem. The other half is feeding it the right bytes, and here the Ethereum libraries don't agree on what to do with a string.

import { keccak256 } from "encloom/sha3"; import { bufferToHex, hexToBuffer, utf8ToBuffer } from "encloom/helpers/encoding"; import { keccak256 as ethersKeccak } from "ethers"; import { keccak256 as viemKeccak } from "viem"; const s = "0xdeadbeef"; console.log("as UTF-8 text (10 bytes):", bufferToHex(keccak256(utf8ToBuffer(s)))); console.log("as hex bytes (4 bytes) :", bufferToHex(keccak256(hexToBuffer(s)))); console.log("ethers.keccak256('0xdeadbeef'):", ethersKeccak(s)); console.log("viem keccak256('0xdeadbeef') :", viemKeccak(s)); try { ethersKeccak("hola" as any); } catch (err) { console.log("ethers.keccak256('hola') →", (err as Error).message.split(" (")[0]); } try { viemKeccak("hola" as any); console.log("viem keccak256('hola') → (doesn't throw)", viemKeccak("hola" as any)); } catch (err) { console.log("viem keccak256('hola') →", (err as Error).message.split("\n")[0]); } // A Node Buffer is also a Uint8Array: console.log("Buffer ok:", bufferToHex(keccak256(Buffer.from("abc")))); try { keccak256("abc" as any); } catch (err) { console.log("keccak256('abc' string) →", (err as Error).message); } 
as UTF-8 text (10 bytes): 4f440a001006a49f24a7de53c04eca3f79aef851ac58e460c9630d044277c8b0 as hex bytes (4 bytes) : d4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1 ethers.keccak256('0xdeadbeef'): 0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1 viem keccak256('0xdeadbeef') : 0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1 ethers.keccak256('hola') → invalid BytesLike value viem keccak256('hola') → (doesn't throw) 0x8aca9664752dbae36135fd0956c956fc4a370feeac67485b49bcd4b99608ae41 Buffer ok: 4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45 keccak256('abc' string) → expected Uint8Array, got type=string 

What happened, library by library:

  • ethers keccak256 only accepts BytesLike: a 0x… string is taken as bytes, and one that isn't hex14 throws. To hash text, you have id(), which, per its docs, "simply computes the UTF-8 bytes and computes the keccak256". But hashMessage (EIP-191) does the opposite: its docs warn that hashMessage("0x4243") hashes the six characters, not the two bytes.
  • viem keccak256 took "0xdeadbeef" as 4 bytes, and "hola" ("hello" in Spanish), which isn't hex, it hashed as UTF-8 without complaint. That's what I observed in 2.57.18.
  • web3.js 4.16.0: web3.utils.sha3("0xdeadbeef") also treats it as bytes, and web3.utils.sha3("") returns undefined, as its docs say. To get c5d24601…, you need sha3Raw("").
  • encloom doesn't guess: keccak256 and sha3 only accept Uint8Array (the type requires it, and if you bypass it with as any, @noble/hashes throws expected Uint8Array, got type=string). You decide whether the string is text (utf8ToBuffer) or hex (hexToBuffer, which accepts the 0x and rejects odd lengths).

It's the same argument as in the Buffer vs Uint8Array post: the explicit type isn't red tape, it's what forces you to decide which bytes go in. And since at runtime Buffer is a Uint8Array, keccak256(Buffer.from("abc")) works without any conversion (it gives 4e03657a…, same as above). Every example in this post passes tsc --strict --noEmit (TypeScript 7.0.2); the as any casts are there on purpose, to bypass the type and see what happens at runtime.

When to use which

keccak256 when the other side is Ethereum or the EVM: function selectors, addresses, the result of keccak256 in a contract or of web3_sha3 on a node. In general, any value that Solidity, ethers, viem, or web3.js also computes.

sha3 when a spec calls for SHA3-256 or FIPS 202, or when the other side is hashlib.sha3_256, createHash("sha3-256"), OpenSSL -sha3-256, or any implementation that follows the NIST standard.

Neither for storing passwords. That's what key derivation is for: encloom ships encloom/pbkdf2, as we saw in the AES-GCM post.

And a rule that saves whole afternoons: if a system uses the word "sha3" and whatever is behind it has anything to do with Ethereum, assume Keccak-256 until the empty-string vector tells you otherwise.

Checklist before you blame your bytes

Symptom Check
Your hash of "" starts with a7ffc6f8 and you expected c5d24601 You're using SHA3-256 (FIPS 202) and you need Keccak-256
The selector for transfer(address,uint256) isn't a9059cbb Hash function and canonical signature, no spaces or parameter names
The derived address doesn't match the wallet's Keccak-256, public key without the 0x04, last 20 bytes
createHash("keccak-256") works on your machine and fails in CI It depends on OpenSSL: check crypto.getHashes() or use a JS implementation
The same string gives two hashes in two libraries UTF-8 text or hex bytes? Convert it yourself and pass a Uint8Array
web3.utils.sha3("") returns undefined That's documented behavior; use sha3Raw

References

Notes

  1. A smart contract is a program deployed on the Ethereum network that runs when a transaction calls it. Solidity is a high-level language for writing them that targets the Ethereum Virtual Machine (EVM), according to its documentation. ↩

  2. Padding: bits appended to the end of the message so its length is an exact multiple of the block size the function processes. In SHA-3 and Keccak, the rule is called pad10*1 (FIPS 202, §5.1). ↩

  3. NIST (National Institute of Standards and Technology) is the United States standards agency. Its FIPS (Federal Information Processing Standards) are standards for federal government systems; some, such as the hash standards, are also used outside government (NIST, SHA-3 Project; FIPS 202, item 6). FIPS 202 is the one that defines SHA-3. ↩

  4. Digest: the output of a hash function, a fixed-length value (here, 32 bytes) computed from an input of any size. It's usually shown in hexadecimal. ↩

  5. Uint8Array is JavaScript's standard type for a sequence of bytes, each an integer from 0 to 255. In Node, Buffer is a subclass of Uint8Array (Node documentation). ↩

  6. Sponge: a construction that first absorbs the message block by block into an internal state, mixing it with a fixed permutation, and then extracts the output bytes from that state (FIPS 202, §4). ↩

  7. The rate (r) is the part of the 1600-bit state that receives message data on each block. The capacity (c = 1600 − r) is the part the message never touches directly, and the security level depends on it (FIPS 202, §4 and Appendix A.1). ↩

  8. Domain suffix: fixed bits appended to the message so that two functions built on the same Keccak never process the same input and give independent results (FIPS 202, §6.1; Keccak Team). ↩

  9. The Yellow Paper is the formal technical specification of the Ethereum protocol, written by Gavin Wood (ethereum.org). ↩

  10. JSON-RPC is a remote procedure call protocol with JSON messages. Ethereum nodes expose it so applications can query the network, and web3_sha3 is one of its methods (ethereum.org). ↩

  11. Function selector: the 4 bytes at the start of a contract call's data that say which function to run (ABI specification). ↩

  12. The ABI (Application Binary Interface) defines how calls to a contract's functions and thei...