BEP-XXXX: Verifiable On-chain Frontends and Contract Frontend Declarations
1. Summary
This BEP defines three small, independent pieces that together let a wallet tell a user, before they sign, whether the web page they are looking at is (a) byte-for-byte identical to a frontend that was published on BNB Smart Chain and (b) the frontend that the contract they are about to interact with has declared as its own.
- On-chain File Store interface (
IOnchainFileStore): a minimal read interface for contracts that hold static web files (HTML, JS, CSS, WASM, …) on chain, keyed by a *site account* and a path, each with a SHA-256 hash. - Hostname binding: a DNS TXT record
_frontend.<hostname>that maps a hostname to(chainId, store, site). - Contract Frontend Declaration registry (
IFrontendDeclaration): an ownerless, immutable contract where a contract's owner declares which site accounts are its official frontends.
A wallet that implements the verification procedure in §4 can show two distinct signals: Published on-chain (integrity) and Official frontend of the contract you are calling (authenticity), and can raise a warning when a transaction targets a contract whose declared frontend is not the page the user is on.
2. Motivation
Almost every large user-fund loss that did not come from a contract bug came from the *frontend*:
- Replaced frontends. The February 2025 Bybit incident (~USD 1.5 billion) was executed by modifying the JavaScript of a legitimate wallet UI so that signers approved a different transaction than the one displayed. The contracts were fine; the bytes served to the browser were not.
- Infrastructure compromise. DNS hijacks, CDN account takeovers and npm supply-chain injections all end the same way: the domain is right, the contract is right, the page is wrong.
- Approval phishing. Look-alike domains ask users to
approveorsetApprovalForAllto an attacker's spender. Wallets currently have no on-chain fact that ties "the page I am on" to "the contract I am about to trust".
Smart contracts are verifiable; frontends are not. This BEP closes that gap with the smallest possible surface: a hash per file on chain, a DNS record, and a declaration registry. It deliberately does not specify how files get on chain, how they are served to ordinary browsers, or any token.
3. Specification
The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as in RFC 2119.
3.1 Terms
- Site account (
site): an address that owns a set of files in a store. It MAY be an EOA, an ERC-6551 token-bound account, a multisig, or any contract. The store decides who may write for a site account; this BEP only reads. - Store: a contract implementing
IOnchainFileStore. - Path: a UTF-8 string, NFC-normalised, without a leading
/, using/as separator, e.g.index.html,assets/app.js. Paths are case-sensitive..and..segments are invalid. - Frontend identity: the triple
(chainId, store, site).
3.2 IOnchainFileStore
interface IOnchainFileStore {
/// @notice Metadata of one file. chunkCount == 0 means "no such file".
/// @return size total bytes of the file
/// @return contentType MIME type as published, e.g. "text/html; charset=utf-8"
/// @return sha256Hash SHA-256 of the complete file bytes
/// @return updatedAt block timestamp of the last complete write
/// @return chunkCount number of storage chunks (implementation detail; 0 == absent)
function fileInfo(address site, string calldata path)
external view returns (uint256 size, string memory contentType, bytes32 sha256Hash, uint64 updatedAt, uint256 chunkCount);
/// @notice Complete file bytes. MUST revert or return empty for absent files.
function read(address site, string calldata path) external view returns (bytes memory);
/// @notice Bytes [offset, offset+length) of the file, for files too large for one call.
function readRange(address site, string calldata path, uint256 offset, uint256 length) external view returns (bytes memory);
/// @notice Number of paths published for the site.
function pathCount(address site) external view returns (uint256);
/// @notice Paths [start, start+count) in publication order.
function pathsRange(address site, uint256 start, uint256 count) external view returns (string[] memory);
/// @notice Emitted after every complete write or removal. sha256Hash == 0 means removed.
event FileUpdated(address indexed site, string path, bytes32 sha256Hash, uint256 size);
}
Requirements for a conforming store:
sha256HashMUST be the SHA-256 of exactly the bytes returned byread(or the concatenation of allreadRangesegments). A store MAY expose a file whose declared hash does not match its bytes during an in-progress upload; verifiers MUST treat such a file as absent.- A store MUST reject paths containing
.or..segments, control characters, or a leading/. - A store SHOULD support ERC-165 with the interface id of
IOnchainFileStore. - A store MAY be upgradeable. If it is, it SHOULD expose
implementation()so verifiers can pin the implementation they audited (§6).
3.3 Hostname binding: _frontend TXT record
To bind a hostname to a frontend identity, the DNS zone MUST contain:
_frontend.<hostname>. IN TXT "v=1 chain=<chainId> store=<0xStoreAddress> site=<0xSiteAddress>"
- Keys are separated by single spaces; order is not significant; unknown keys MUST be ignored.
- Addresses are 40 hex characters with
0xprefix; checksum casing is RECOMMENDED but verifiers MUST compare case-insensitively. chainis the decimal EIP-155 chain id (56for BNB Smart Chain mainnet,204for opBNB).- Exactly one
v=1record MUST exist per hostname. If several exist, verifiers MUST treat the hostname as unbound. - Verifiers SHOULD resolve the record over DNS-over-HTTPS or a DNSSEC-validating resolver, and SHOULD cache the result for no longer than the record TTL.
Legacy form. A TXT record whose entire value is a single 0x address MAY be accepted by a verifier as site=<address> with chain and store taken from verifier configuration. New deployments MUST use the v=1 form.
Page hint (optional). A page MAY carry <meta name="frontend-identity" content="v=1 chain=56 store=0x… site=0x…">. Verifiers MUST NOT rely on the meta tag alone; if both DNS and meta exist and disagree, the DNS record wins and the page MUST be treated as unverified.
3.4 IFrontendDeclaration
A single, ownerless registry per chain where a contract's controller declares its official frontend site accounts.
interface IFrontendDeclaration {
struct Frontend { uint64 chainId; address store; address site; }
/// @notice Declare `frontend` as an official frontend of `subject`.
/// Authorised callers: (1) `subject` itself; (2) `Ownable(subject).owner()` if `subject` exposes `owner()`;
/// (3) an EOA declaring for itself (subject == msg.sender).
function declare(address subject, Frontend calldata frontend) external;
/// @notice Remove a declaration. Same authorisation as `declare`.
function revoke(address subject, Frontend calldata frontend) external;
/// @notice Revoke everything declared for `subject`. Same authorisation as `declare`.
function revokeAll(address subject) external;
/// @notice True if `frontend` is currently declared for `subject`.
function isOfficial(address subject, Frontend calldata frontend) external view returns (bool);
/// @notice All current declarations for `subject`.
function frontendsOf(address subject) external view returns (Frontend[] memory);
/// @notice Number of current declarations for `subject`. 0 means "no declaration": wallets MUST stay neutral.
function declarationCount(address subject) external view returns (uint256);
event Declared(address indexed subject, uint64 chainId, address indexed store, address indexed site, address by);
event Revoked(address indexed subject, uint64 chainId, address indexed store, address indexed site, address by);
}
Requirements:
- The registry MUST have no owner, no upgrade path and no pause. Its bytecode and deployment salt MUST be published so it lives at the same address on every EVM chain that adopts this BEP (CREATE2 through a well-known deterministic deployer).
declareMUST revert if the caller is not authorised. Authorisation is checked at call time only; a later change ofowner()does not invalidate existing declarations, but the new owner MAY revoke them.- A subject MAY declare several frontends (primary site + mirrors). There is no ordering.
- A declaration MAY point to a store on a different chain (
chainId != block.chainid) so that L2 frontends can be declared for L1 contracts and vice versa.
3.5 Verification procedure (wallets and dapp browsers)
A verifier (browser-extension wallet, in-app dapp browser, or gateway) performs the following when a page is loaded and again before presenting any signing request originating from it.
Step 1 — Resolve identity. Resolve _frontend.<hostname> per §3.3. If absent or malformed: state = *Unbound*; stop.
Step 2 — Collect executable resources. The set R = the top-level HTML document plus every resource the page loaded that can execute or change what is executed: script (classic, module, worker, service worker), WebAssembly, and HTML documents in same-origin frames. Stylesheets, images, fonts, media and JSON are NOT required for integrity but MAY be verified.
Step 3 — Same-origin rule. Every resource in R MUST have been fetched from the page's own origin. If any executable resource was fetched cross-origin (CDN scripts, https:// script tags to other hosts): state = *Unverified* with reason cross-origin-executable; stop. Inline scripts are part of the HTML document and are covered by its hash.
Step 4 — Hash and compare. For every resource in R: compute SHA-256 of the response body exactly as received (after transfer decoding, before any parsing). Derive path from the URL path (strip leading /, percent-decode, NFC-normalise). If the URL path ends with /, or maps to an absent file and has no file extension, use path + "index.html" and then "index.html" (single-page-app fallback) in that order. Call store.fileInfo(site, path); the resource matches if chunkCount > 0 and sha256Hash equals the computed hash. If any resource does not match: state = *Mismatch*; stop.
Step 5 — Result: Published. All executable resources match. State = *Published* with identity (chainId, store, site). The verifier MUST display the site address (or a human-readable name resolved from it) next to the indicator; "Published on-chain" without saying *by whom* is misleading, because an attacker can publish a phishing page on chain too.
Step 6 — Authenticity check at signing time. When the page requests a signature or transaction, determine the *subject* contract:
| Request | Subject |
|---|---|
eth_sendTransaction with to = contract | to |
approve, increaseAllowance, setApprovalForAll, permit (EIP-2612), Permit2 permit* | the spender / operator, not the token |
EIP-712 typed data with a verifyingContract | verifyingContract |
Plain value transfer to an EOA, personal_sign | no subject; skip step 6 |
Then:
- If
declarationCount(subject) == 0: state stays *Published*; show neutral text ("this contract has not declared an official frontend"). - Else if
isOfficial(subject, identity)is true: state = Official; show the strong positive indicator. - Else: state = Wrong frontend; the verifier MUST show a prominent warning before the user can sign, e.g. "The contract 0x… has declared an official frontend and this page is not it." The verifier SHOULD NOT block signing outright (declarations can be stale), but MUST require an explicit extra confirmation.
The same check applies when the page state is *Unbound*, *Unverified* or *Mismatch*: if the subject has a declaration, the user is on the wrong (or tampered) page and MUST be warned.
Step 7 — Caching. Verifiers MAY cache fileInfo results keyed by (chainId, store, site, path, sha256) and SHOULD invalidate on FileUpdated. DNS results follow their TTL. Declaration results SHOULD be cached for no more than a few minutes.
3.6 Display requirements
| State | Required UI |
|---|---|
| Official | Strong positive indicator; show subject address and site address |
| Published | Mild positive indicator; MUST show site address / name; MUST NOT use wording that implies endorsement |
| Unbound | No indicator (today's default) |
| Unverified / Mismatch | Negative indicator with reason; if the user proceeds to sign, repeat the warning |
| Wrong frontend | Prominent warning requiring explicit confirmation before signing |
Verifiers MUST NOT present *Published* using the same visual as *Official*.
3.7 Gateways (informative)
Ordinary browsers cannot read the chain. A gateway is any HTTP server that resolves _frontend.<host>, reads the files from the store, checks the hash, and serves them with the stored contentType. Gateways are interchangeable by construction: any party can run one against the same records, and a verifier per §3.5 detects a misbehaving gateway. This BEP does not standardise gateway behaviour beyond: a gateway MUST NOT serve a file whose bytes do not match sha256Hash, and SHOULD add a response header X-Frontend-Identity: v=1 chain=… store=… site=….
4. Rationale
- Why hashes per file rather than one root hash? Verifiers only see the resources a page actually loaded. Per-file hashes let them verify exactly that set without downloading the whole site; a Merkle root would require the full manifest on every page load.
- Why the spender is the subject for approvals. Approval phishing never touches the token's own frontend; the thing the user is trusting is the spender. Checking the token would give a green light on the phishing page.
- Why DNS and not only a meta tag. The meta tag lives inside the page, which is exactly what an attacker controls. DNS is controlled by whoever controls the domain, and it is what a gateway uses anyway. The meta tag is a hint for offline tools.
- Why "Published" must name the publisher. Anyone can put a phishing page on chain. Integrity without identity is a false signal. The strong indicator is reserved for the case where the target contract itself vouches for the page.
- Why an ownerless registry. A declaration registry with an owner is a single point of censorship and a target. Deterministic deployment lets wallets hard-code one address across chains.
- Why no token, no fee, no storage format. Those are products. The standard only needs facts a wallet can read.
5. Backwards Compatibility
- Existing sites stored on chain under other schemes can conform by exposing
fileInfo(a thin adapter contract suffices). - The legacy single-address TXT form (
_tapeout.<host>in the reference deployment) remains readable by verifiers configured with a default store; new records use_frontendwithv=1. - Contracts that never call
declareare unaffected: wallets stay neutral for them.
6. Security Considerations
- Malicious publisher. A verified page can still be malicious if its publisher is. *Published* is an integrity claim only; *Official* transfers the trust question to the subject contract's owner, which is the same party users already trust with the contract.
- Store upgradeability. If a store is upgradeable, a malicious upgrade could return false hashes. Verifiers SHOULD pin
implementation()for stores they trust by default and treat a change as *Unverified* until re-audited. - DNS. An attacker who controls the domain's DNS can point
_frontendat their own site account. That yields at most *Published (by attacker)*; it can never yield *Official* for a contract they do not control. Verifiers SHOULD use DoH/DNSSEC to avoid local resolver spoofing. - Owner key compromise. If a contract owner's key is stolen, the attacker can declare a phishing frontend as official. This is no worse than today (the same key can usually upgrade or drain the contract) and is visible on chain via
Declaredevents, which monitoring services SHOULD watch. - Stale declarations. After ownership transfer or a frontend migration, old declarations remain until revoked. Hence *Wrong frontend* is a warning with override, not a hard block.
- Cross-origin executables. The same-origin rule is strict on purpose: a single cross-origin script defeats verification. Dapps that want the indicator must bundle their dependencies, which is already best practice.
- Service workers. A previously installed service worker can serve stale or modified bytes. Verifiers MUST hash what the network layer actually delivered, and SHOULD treat a page controlled by a service worker that was not itself verified as *Unverified*.
- Large files.
fileInfois O(1); verifiers never needread. Gateways reading large files SHOULD pin allreadRangecalls to one block number to avoid torn reads during an update. - Time of check vs time of use. Verification result is tied to the resources already loaded. Any later dynamic
import()or injected script MUST trigger re-verification or downgrade to *Unverified*.
7. Reference Implementation
- Store: HashPort
SiteRegistryon BNB Smart Chain, proxy0xd006ffdd5Ae313B17729621A00999cD3C71CE5e6(UUPS; implementation0xe474e3E3831a3fFd76BC76DF74c379C08a45ED23). Files are stored as SSTORE2 chunks of 24,000 bytes, max 350 chunks per file; site accounts are ERC-6551 token-bound accounts. - Gateway:
tapeout/sites/gateway/server.mjs(Node.js), serving_frontend/_tapeout-bound hostnames over HTTPS with per-file SHA-256 enforcement. - Declaration registry: to be deployed at a deterministic address; source in
tapeout/sites/src/FrontendDeclaration.sol(pending). - Verifier library:
@hashport/verify-frontend(JavaScript, pending), implementing §3.5 for browser-extension and in-app dapp browsers, with a demo athttps://console.hashport.org/verify.
8. Test Cases (normative outline)
- Page with one HTML and one same-origin JS, both matching → *Published*.
- Same page with one byte changed in JS → *Mismatch*.
- Same page loading
https://cdn.example/lib.js→ *Unverified (cross-origin-executable)*. - Subject contract declared
(56, store, site); page identity equal → *Official*. - Subject declared a different site; page *Published* → *Wrong frontend* warning on
eth_sendTransaction. approve(spender, amount)wherespenderdeclared another site → *Wrong frontend* (subject is spender, not token).- Two
v=1TXT records → *Unbound*. - SPA route
/swapserved asindex.html→ verified againstindex.html. - Store in mid-upload with
sha256Hashnot matching bytes → treated as absent → *Mismatch*. declarecalled by a non-owner → revert.
9. Copyright
Copyright and related rights waived via CC0.