STATUS: DRAFT · to be submitted to bnb-chain/BEPs

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.

  1. 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.
  2. Hostname binding: a DNS TXT record _frontend.<hostname> that maps a hostname to (chainId, store, site).
  3. 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*:

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

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:

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>"

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:

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:

RequestSubject
eth_sendTransaction with to = contractto
approve, increaseAllowance, setApprovalForAll, permit (EIP-2612), Permit2 permit*the spender / operator, not the token
EIP-712 typed data with a verifyingContractverifyingContract
Plain value transfer to an EOA, personal_signno subject; skip step 6

Then:

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

StateRequired UI
OfficialStrong positive indicator; show subject address and site address
PublishedMild positive indicator; MUST show site address / name; MUST NOT use wording that implies endorsement
UnboundNo indicator (today's default)
Unverified / MismatchNegative indicator with reason; if the user proceeds to sign, repeat the warning
Wrong frontendProminent 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

5. Backwards Compatibility

6. Security Considerations

7. Reference Implementation

8. Test Cases (normative outline)

  1. Page with one HTML and one same-origin JS, both matching → *Published*.
  2. Same page with one byte changed in JS → *Mismatch*.
  3. Same page loading https://cdn.example/lib.js → *Unverified (cross-origin-executable)*.
  4. Subject contract declared (56, store, site); page identity equal → *Official*.
  5. Subject declared a different site; page *Published* → *Wrong frontend* warning on eth_sendTransaction.
  6. approve(spender, amount) where spender declared another site → *Wrong frontend* (subject is spender, not token).
  7. Two v=1 TXT records → *Unbound*.
  8. SPA route /swap served as index.html → verified against index.html.
  9. Store in mid-upload with sha256Hash not matching bytes → treated as absent → *Mismatch*.
  10. declare called by a non-owner → revert.

9. Copyright

Copyright and related rights waived via CC0.