Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions typescript/eip712-typed-data-signing-playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# EIP-712 Typed Data Signing Playground

A small **BNBChain Cookbook** demo for **EIP-712** typed structured data hashing and signing on **BNB Smart Chain (BSC)**. Sign and verify typed data in the browser with a Web3 wallet (e.g. MetaMask).

## Screenshot

![EIP-712 Playground UI](./eip712-typed-data-signing-playground.png)

## What it does

- **Left pane:** Short explainer on EIP-712, domain separation, and what the demo does.
- **Right pane:** Connect wallet → load a BSC Mail sample → view EIP-712 hashes (domain, struct, full digest) → sign via `eth_signTypedData_v4` → verify and recover signer.

## Tech stack

- **TypeScript**
- **ethers** v6 (hashing, verification)
- **Vite** (build + dev server)
- **Vitest** (unit tests)

## Quick start

```bash
npm install
npm run dev
```

Open the dev server URL (e.g. `http://localhost:5173`), connect a wallet on BSC (or any chain for the demo), load the sample, and sign.

## Scripts

| Command | Description |
|---------------|----------------------------|
| `npm run dev` | Start Vite dev server |
| `npm run build` | Production build |
| `npm run preview` | Serve `dist/` |
| `npm test` | Run Vitest unit tests |

## Clone & run (evaluators)

Use the provided script for a no-friction run with a pre-seeded `.env`:

```bash
./clone-and-run.sh
```

This installs deps, creates `.env` from `.env.example`, runs tests, builds, and starts the dev server.

## Project layout

- `src/app.ts` — EIP-712 logic (hash, domain, struct, verify, payload).
- `src/frontend.ts` — UI (dark theme, info + interaction panes).
- `index.html` — Entry page.
- `tests/app.test.ts` — Unit tests for all app functions.

## License

MIT.
148 changes: 148 additions & 0 deletions typescript/eip712-typed-data-signing-playground/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import { Wallet } from "ethers";
import {
BSC_CHAIN_ID,
getBscMailTypedData,
hashTypedData,
hashDomain,
hashStruct,
verifyTypedDataSignature,
getSignPayload,
type EIP712TypedData,
} from "./app.js";

const MNEMONIC =
"test test test test test test test test test test test junk";
const wallet = Wallet.fromPhrase(MNEMONIC);

describe("BSC_CHAIN_ID", () => {
it("is 56 for BNB Smart Chain mainnet", () => {
expect(BSC_CHAIN_ID).toBe(56);
});
});

describe("getBscMailTypedData", () => {
it("returns valid EIP-712 typed data with BSC domain", () => {
const data = getBscMailTypedData();
expect(data.domain.chainId).toBe(BSC_CHAIN_ID);
expect(data.domain.name).toBe("BNB Cookbook");
expect(data.primaryType).toBe("Mail");
expect(data.types.Mail).toBeDefined();
expect(data.types.Person).toBeDefined();
expect(data.types.EIP712Domain).toBeDefined();
expect(data.message.from).toEqual({
name: "Alice",
wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
});
expect(data.message.contents).toBe("Hello from BSC EIP-712!");
});
});

describe("hashDomain", () => {
it("returns 32-byte hex domain separator", () => {
const data = getBscMailTypedData();
const h = hashDomain(data.domain);
expect(h).toMatch(/^0x[a-fA-F0-9]{64}$/);
});

it("is deterministic for same domain", () => {
const data = getBscMailTypedData();
expect(hashDomain(data.domain)).toBe(hashDomain(data.domain));
});
});

describe("hashStruct", () => {
it("returns 32-byte hex struct hash", () => {
const data = getBscMailTypedData();
const h = hashStruct(data.primaryType, data.types, data.message);
expect(h).toMatch(/^0x[a-fA-F0-9]{64}$/);
});

it("is deterministic for same struct", () => {
const data = getBscMailTypedData();
const a = hashStruct(data.primaryType, data.types, data.message);
const b = hashStruct(data.primaryType, data.types, data.message);
expect(a).toBe(b);
});

it("changes when message changes", () => {
const data = getBscMailTypedData();
const h1 = hashStruct(data.primaryType, data.types, data.message);
const alt = {
...data.message,
contents: "Different",
};
const h2 = hashStruct(data.primaryType, data.types, alt);
expect(h1).not.toBe(h2);
});
});

describe("hashTypedData", () => {
it("returns 32-byte hex full EIP-712 digest", () => {
const data = getBscMailTypedData();
const h = hashTypedData(data);
expect(h).toMatch(/^0x[a-fA-F0-9]{64}$/);
});

it("is deterministic for same typed data", () => {
const data = getBscMailTypedData();
expect(hashTypedData(data)).toBe(hashTypedData(data));
});
});

describe("getSignPayload", () => {
it("returns object with domain, types, primaryType, message", () => {
const data = getBscMailTypedData();
const payload = getSignPayload(data) as Record<string, unknown>;
expect(payload.domain).toBeDefined();
expect(payload.types).toBeDefined();
expect(payload.primaryType).toBe("Mail");
expect(payload.message).toBeDefined();
});

it("is JSON-serializable", () => {
const data = getBscMailTypedData();
const payload = getSignPayload(data);
expect(() => JSON.stringify(payload)).not.toThrow();
});
});

describe("verifyTypedDataSignature", () => {
it("recovers signer address from valid EIP-712 signature", async () => {
const data = getBscMailTypedData();
const sig = await wallet.signTypedData(
data.domain,
{ Mail: data.types.Mail, Person: data.types.Person },
data.message
);
const recovered = verifyTypedDataSignature(data, sig);
expect(recovered.toLowerCase()).toBe(wallet.address.toLowerCase());
});

it("produces same recoverable address for same data and signature", async () => {
const data = getBscMailTypedData();
const sig = await wallet.signTypedData(
data.domain,
{ Mail: data.types.Mail, Person: data.types.Person },
data.message
);
const a = verifyTypedDataSignature(data, sig);
const b = verifyTypedDataSignature(data, sig);
expect(a).toBe(b);
});

it("recovers different address when message differs", async () => {
const data = getBscMailTypedData();
const sig = await wallet.signTypedData(
data.domain,
{ Mail: data.types.Mail, Person: data.types.Person },
data.message
);
const altered: EIP712TypedData = {
...data,
message: { ...data.message, contents: "Tampered" },
};
const recovered = verifyTypedDataSignature(altered, sig);
expect(recovered.toLowerCase()).not.toBe(wallet.address.toLowerCase());
});
});
131 changes: 131 additions & 0 deletions typescript/eip712-typed-data-signing-playground/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* EIP-712 Typed Data Signing — core logic for BNB Smart Chain (BSC).
* Hashes, encodes, and verifies EIP-712 typed structured data.
*/

import {
TypedDataEncoder,
verifyTypedData,
type TypedDataDomain,
type TypedDataField,
} from "ethers";

/** BSC mainnet chain ID (BNB Smart Chain). */
export const BSC_CHAIN_ID = 56;

/** EIP-712 typed data shape: domain, types, primaryType, message. */
export interface EIP712TypedData {
domain: TypedDataDomain;
types: Record<string, TypedDataField[]>;
primaryType: string;
message: Record<string, unknown>;
}

/**
* Returns a BSC-scoped EIP-712 example (Mail-style struct).
* Uses chainId 56 and a sample verifying contract for demo purposes.
*/
export function getBscMailTypedData(): EIP712TypedData {
return {
domain: {
name: "BNB Cookbook",
version: "1",
chainId: BSC_CHAIN_ID,
verifyingContract: "0x0000000000000000000000000000000000000000",
},
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "Person" },
{ name: "to", type: "Person" },
{ name: "contents", type: "string" },
],
Person: [
{ name: "name", type: "string" },
{ name: "wallet", type: "address" },
],
},
primaryType: "Mail",
message: {
from: {
name: "Alice",
wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
},
to: {
name: "Bob",
wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
},
contents: "Hello from BSC EIP-712!",
},
};
}

/**
* Hashes the full EIP-712 digest (\\x19\\x01 ‖ domainSeparator ‖ hashStruct(message)).
*/
export function hashTypedData(data: EIP712TypedData): string {
const { domain, types, message } = data;
return TypedDataEncoder.hash(domain, _typesWithoutEIP712Domain(types), message);
}

/**
* Returns the domain separator hash (hashStruct(EIP712Domain)).
*/
export function hashDomain(domain: TypedDataDomain): string {
return TypedDataEncoder.hashDomain(domain);
}

/**
* Returns the hashStruct of the message for the given primary type.
*/
export function hashStruct(
primaryType: string,
types: Record<string, TypedDataField[]>,
message: Record<string, unknown>
): string {
return TypedDataEncoder.hashStruct(
primaryType,
_typesWithoutEIP712Domain(types),
message
);
}

/**
* Recovers the signer address from an EIP-712 signature.
*/
export function verifyTypedDataSignature(
data: EIP712TypedData,
signature: string
): string {
const { domain, types, message } = data;
return verifyTypedData(
domain,
_typesWithoutEIP712Domain(types),
message,
signature
);
}

/**
* Returns the JSON payload for eth_signTypedData_v4.
* Use this with wallet request `eth_signTypedData_v4`.
*/
export function getSignPayload(data: EIP712TypedData): unknown {
const { domain, types, primaryType, message } = data;
return { domain, types, primaryType, message };
}

function _typesWithoutEIP712Domain(
types: Record<string, TypedDataField[]>
): Record<string, TypedDataField[]> {
const out: Record<string, TypedDataField[]> = {};
for (const [k, v] of Object.entries(types)) {
if (k !== "EIP712Domain") out[k] = v;
}
return out;
}
34 changes: 34 additions & 0 deletions typescript/eip712-typed-data-signing-playground/clone-and-run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail

# EIP-712 Typed Data Signing Playground — clone-and-run script.
# Run from repo root or from this project directory.
# Pre-seeds .env for a hassle-free evaluator run.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

echo "[1/5] Ensuring .env..."
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
echo " Created .env from .env.example"
else
echo "VITE_BSC_RPC_URL=https://bsc-dataseed.bnbchain.org" > .env
echo " Created .env with default BSC RPC"
fi
else
echo " .env already exists"
fi

echo "[2/5] Installing dependencies..."
npm install

echo "[3/5] Running unit tests..."
npm test

echo "[4/5] Building..."
npm run build

echo "[5/5] Starting dev server..."
npm run dev
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading