This specification defines the cryptographic standards and data serialization formats required to enable MetaMask (and compatible Web3 wallets) to decrypt on-chain user balances. It aligns with industry standards including BIP-44 for deterministic key derivation and SEC 1 (secp256k1) for elliptic curve operations, ensuring broad compatibility with existing auditing tools and libraries.
To ensure long-term stability and security, this specification strictly requires the use of a MetaMask Snap. The Snap acts as a secure enclave, managing a dedicated x402 protocol key derived from the user’s seed phrase. It performs decryption within a sandboxed environment, ensuring private keys are never exposed to the client-side DApp.
Primary Objectives:
- Ease of Use: Provide a “Web2-like” experience where encrypted data is handled transparently in the background, removing the need for manual decryption steps or repetitive signing requests.
- Seamless MetaMask Integration: Leverage the native Snap environment to provide secure, sandboxed cryptography without requiring users to install separate wallet software or manage external keys.
- Explorer Verification: Enable independent verification of balances via standard block explorers like Blockscout, allowing users to decrypt their on-chain data directly within the explorer interface using the Snap.
2. Cryptographic Primitives
The architecture relies on the ECIES (Elliptic Curve Integrated Encryption Scheme) standard, utilizing the secp256k1 curve typical of Ethereum.
| Component | Primitive | Function |
|---|---|---|
| Curve | secp256k1 | Standard Ethereum Curve: y^2 = x^3 + 7 (mod p) |
| Key Exchange | ECDH | Deriving Shared Secret ‘S’ from keys. |
| KDF | SHA-256 | Deriving Symmetric Key ‘K’ from ‘S’. |
| Encryption | AES-256-CBC | Encrypting payload with ‘K’. |
| Entropy | CSPRNG | Generating Ephemeral Keys and IVs. |
3. Mathematical Model
This section defines the operations using standard text notation.
3.1 Domain Parameters
Let the curve E be defined over finite field F_p with generator point G of order n .
-
User Private Key:
d_U(integer in range [1, n-1]), derived via BIP-44. -
User Public Key:
Q_U = d_U * G(Point multiplication).
3.2 Key Agreement (ECDH)
Encryption requires an ephemeral key pair generated by the sender (the Encryptor Contract).
-
Ephemeral Private Key:
r= random integer in range [1, n-1]. -
Ephemeral Public Key:
R = r * G. -
Shared Secret Point:
S = r * Q_U. LetShave coordinates(x_S, y_S).
Note: The user derives the same point S using their private key d_U:
S = d_U * R
Proof of equality:
S = r * (d_U * G) = d_U * (r * G) = (r * d_U) * G
3.3 Key Derivation Function (KDF)
To convert the elliptic curve point S into a usable symmetric encryption key K_enc, we apply a standard hashing function to the x-coordinate.
K_enc = SHA-256(Bytes(x_S))
-
x_S: The 32-byte x-coordinate of point
S. - K_enc: The resulting 256-bit AES key.
3.4 Encryption / Decryption
Let M be the payload (Hex String of balance) and IV be a random 16-byte vector.
Encryption:
C = AES-CBC-Encrypt(Key=K_enc, IV=IV, Data=PKCS7(M))
Decryption:
M = PKCS7-Decrypt(AES-CBC-Decrypt(Key=K_enc, IV=IV, Data=C))
4. On-Chain Storage Specification
To minimize storage costs, the IUserEncryptor Solidity library must not store JSON strings. Instead, it must pack the encryption artifacts into a compact bytes array.
4.1 Packed Byte Layout
The userBalances mapping stores a single byte array constructed as follows:
[ IV (16 bytes) ] [ Ephemeral Public Key (33 bytes) ] [ Ciphertext (N bytes) ]
- Offset 0x00: IV (16 bytes) — Initialization Vector for AES-CBC.
- Offset 0x10: Ephemeral Public Key (33 bytes) — Compressed secp256k1 public key (0x02/0x03 prefix).
- Offset 0x31: Ciphertext (Variable length) — The encrypted payload (AES-256-CBC output).
4.2 Payload Structure
To maintain consistency with standard hex encoding practices in Web3, the payload MUST be the Hexadecimal String representation of the balance.
-
Format: ASCII/UTF-8 String starting with
0x. -
Example: If balance is
100, payload is string"0x64". -
Solidity Generation:
Strings.toHexString(balance)(using OpenZeppelin Strings library).
Payload = Bytes(String("0x..."))
5. Protocol Workflow
Phase 1: Key Registration (One-Time)
The user authorizes the Snap to derive their identity and registers it on-chain.
-
Client: Calls Snap RPC method
x402_getPublicKey. -
Snap: Derives
secp256k1public key from wallet entropy (BIP-44). -
Client: Sends the resulting key (as bytes) to the smart contract:
x402.registerPublicKey(bytes). -
Contract: Stores key in
publicKeys[user].
Phase 2: Encryption (On-Chain)
During a BITE Phase 2 execution (onDecrypt):
- BITE
UserEncryptorconverts theuint256balance to a Hex String. - Generates random
randIV. - Computes shared secret
x_Sfrom User Public KeyQ_U. - Derives
K_enc = SHA-256(x_S). - Encrypts:
C = AES(M, K_enc, IV). - Packs data and writes to storage.
Phase 3: Decryption (Client-Side)
- Fetch: Client retrieves packed bytes from contract.
-
Invoke Snap: Client calls
x402_decryptwith packed bytes. - Snap:
- Unpacks IV (
IV), Ephemeral Key (R), Ciphertext (C). - Multiplies
Rby private keyd_Uto getS. - Hashes
x_Sto reproduceK_enc. - Decrypts
Cto getM.
- Return: Snap returns plaintext Hex String.
6. Solidity Interface Definition
The IUserEncryptor library facilitates the packing scheme.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IUserEncryptor {
/**
* @notice Encrypts data using ECIES (secp256k1 + AES-256-CBC).
* @param publicKey The user's secp256k1 public key (33 bytes compressed or 65 uncompressed).
* @param data The plaintext data (Hex String bytes).
* @return packedData A byte array containing:
* [0..16]: IV
* [16..49]: Ephemeral Public Key (Compressed)
* [49..end]: Ciphertext
*/
function encryptWithKey(
bytes calldata publicKey,
bytes calldata data
) external view returns (bytes memory packedData);
}
7. MetaMask Snap Implementation
7.1 Snap Manifest (snap.manifest.json)
{
"version": "1.0.0",
"description": "x402 Protocol Balance Decryptor",
"proposedName": "x402-snap",
"repository": {
"type": "git",
"url": "[https://github.com/machinepay/x402-snap.git](https://github.com/machinepay/x402-snap.git)"
},
"source": {
"shasum": "...",
"location": {
"npm": {
"filePath": "dist/bundle.js",
"packageName": "x402-snap",
"registry": "[https://registry.npmjs.org/](https://registry.npmjs.org/)"
}
}
},
"initialPermissions": {
"snap_dialog": {},
"snap_getBip44Entropy": [
{
"coinType": 402
}
]
},
"manifestVersion": "0.1.0"
}
7.2 Snap Logic (index.ts)
Dependencies:
@metamask/snaps-sdk@metamask/key-tree@noble/secp256k1@noble/ciphers@noble/hashes
import { OnRpcRequestHandler } from '@metamask/snaps-sdk';
import { getBIP44AddressKeyDeriver } from '@metamask/key-tree';
import { secp256k1 } from '@noble/curves/secp256k1';
import { cbc, aes } from '@noble/ciphers/aes';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
// Custom Coin Type for x402 Protocol
const X402_COIN_TYPE = 402;
/**
* Derives the private encryption key (secp256k1) from the wallet seed.
*/
async function getEncryptionPrivateKey() {
const node = await snap.request({
method: 'snap_getBip44Entropy',
params: { coinType: X402_COIN_TYPE },
});
const deriveAddress = await getBIP44AddressKeyDeriver(node);
const derivedKey = await deriveAddress(0);
if (!derivedKey.privateKey) {
throw new Error('Unable to derive private key');
}
// Remove 0x prefix and return bytes
return hexToBytes(derivedKey.privateKey.replace(/^0x/, ''));
}
export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => {
const privKey = await getEncryptionPrivateKey();
switch (request.method) {
/**
* x402_getPublicKey
* Returns: Compressed Hex String (0x02... or 0x03...)
*/
case 'x402_getPublicKey':
const pubKey = secp256k1.getPublicKey(privKey, true);
return "0x" + bytesToHex(pubKey);
/**
* x402_decrypt
* Params: { packedBytesHex: string }
*/
case 'x402_decrypt':
const { packedBytesHex } = request.params as { packedBytesHex: string };
if (!packedBytesHex || packedBytesHex === "0x") return "0";
// 1. Unpack
const raw = hexToBytes(packedBytesHex.replace(/^0x/, ''));
if (raw.length < 49) throw new Error("Invalid data length");
const iv = raw.subarray(0, 16);
const ephemPublicKey = raw.subarray(16, 49);
const ciphertext = raw.subarray(49);
// 2. ECDH: Compute Shared Point S
// S = privKey * ephemPublicKey
const sharedPoint = secp256k1.getSharedSecret(privKey, ephemPublicKey);
// 3. KDF: SHA256(x_coordinate)
// sharedPoint is 33 bytes (compressed). Slice [1..33] to get X.
const x_coordinate = sharedPoint.subarray(1, 33);
const encryptionKey = sha256(x_coordinate);
// 4. AES-256-CBC Decrypt
const decipher = cbc(aes, encryptionKey, iv);
const decrypted = decipher.decrypt(ciphertext);
// 5. Decode to UTF-8 String
// Note: PKCS7 padding is handled automatically by cbc/aes usually,
// but if strip is needed, apply PKCS7 unpad here.
// @noble/ciphers usually returns padded bytes, we assume standard strip.
return new TextDecoder().decode(decrypted);
default:
throw new Error('Method not found.');
}
};
8. DApp Client Integration
const SNAP_ID = 'npm:x402-snap';
async function connectSnap() {
await window.ethereum.request({
method: 'wallet_requestSnaps',
params: { [SNAP_ID]: {} },
});
}
async function getSnapPublicKey() {
return await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: { method: 'x402_getPublicKey' },
},
});
}
async function decryptBalanceWithSnap(encryptedDataHex) {
if (!encryptedDataHex || encryptedDataHex === "0x") return 0n;
try {
const decryptedString = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: {
method: 'x402_decrypt',
params: { packedBytesHex: encryptedDataHex }
},
},
});
// Expected output: "0x..."
return BigInt(decryptedString);
} catch (err) {
console.error("Snap decryption failed:", err);
throw err;
}
}
9. User Experience (UX) Flow
This section details the interaction lifecycle from the end-user’s perspective, ensuring clarity on what actions are manual versus automated.
9.1 Initial Setup (One-Time)
- Connect Wallet: The user connects MetaMask to the x402 DApp.
-
Detection: The DApp detects that the
x402-snapis not installed. - Installation Prompt: The DApp triggers the Snap installation request.
-
User Action: A MetaMask popup appears listing permissions (specifically
snap_getBip44Entropyto derive encryption keys). The user clicks “Connect” or “Install”.
9.2 Key Registration (One-Time per Account)
-
Status Check: The DApp queries the smart contract
publicKeys[userAddress]to check for an existing key. - Prompt: If no key is found, the UI displays a “Setup Confidential Balance” or “Register Key” button.
- User Action: User clicks “Register”.
- Snap Invocation: The DApp silently calls the Snap to derive the public key (no popup if Snap is connected).
-
Transaction: The DApp initiates a standard Ethereum transaction
x402.registerPublicKey(...). - User Action: User signs and confirms the transaction in MetaMask to pay gas.
9.3 Routine Usage (Seamless Decryption)
- Dashboard View: The user navigates to their dashboard to view token balances.
- Data Fetch: The DApp fetches the encrypted balance (opaque hex string) from the blockchain.
-
Decryption: The DApp invokes
x402_decrypton the Snap.
- Note: Because the Snap runs in a secure sandbox and the DApp has been granted connection permissions, this step typically occurs silently without a popup, providing a seamless “web2-like” experience.
-
Result: The UI updates from a masked state (e.g.,
******) to the actual plaintext balance (e.g.,100.00 x402).
10. Token Transfer Data Flow
This section illustrates the lifecycle of a transfer event, detailing how the contract updates state for both parties ensuring only they can decrypt their respective new balances.
10.1 Transaction Initiation (Sender: Alice)
Alice wants to transfer 50 x402 to Bob.
- Input Construction (Client-Side):Alice’s Client constructs a BITE Phase 1 payload. The inputs (Recipient: Bob, Amount: 50) are encrypted using the BITE Protocol’s input encryption scheme. This produces an opaque bytes array (e.g., 0x89ab…).
- MetaMask Request (DApp to Wallet):The DApp initiates the transaction using the standard Ethereum JSON-RPC method eth_sendTransaction.
await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{
from: '0xAliceAddress...',
to: '0xBITE_MAGIC_NUMBER_OR_CONTRACT',
data: '0x89ab...' // Encrypted Phase 1 Payload
}],
});
- User Confirmation (Visual):MetaMask opens a confirmation popup.
- Destination: The user sees the transaction is interacting with the x402 Contract (or the specific BITE Protocol address).
-
Payload: The “Hex Data” field displays the encrypted payload (
0x89ab...). Since input encryption conceals the transfer details (Recipient/Amount) from the network, they are also concealed in this view unless a specific “Transaction Insight” Snap is used to decode them for the user. - Gas Fee: The standard network gas fee is displayed.
- Signing & Broadcast:Alice clicks “Confirm”. MetaMask uses her standard Ethereum Private Key (secp256k1 signing key) to sign the transaction and broadcasts it to the network.
10.2 On-Chain Execution (Smart Contract)
The BITE Protocol decrypts the input and executes the transfer logic in Phase 2.
- Logic: The contract verifies Alice has >= 50 tokens.
- Calculation:
AliceNewBal = AliceOldBal - 50BobNewBal = BobOldBal + 50
- Key Retrieval: The contract retrieves the registered public keys:
-
Q_Alice←publicKeys[Alice] -
Q_Bob←publicKeys[Bob]
- Re-Encryption (Dual Output):
-
For Alice: The contract calls
encryptWithKey(Q_Alice, toHex(AliceNewBal)). This generates a unique ephemeral key pairr_A,R_Aand a uniqueIV_A. -
For Bob: The contract calls
encryptWithKey(Q_Bob, toHex(BobNewBal)). This generates a unique ephemeral key pairr_B,R_Band a uniqueIV_B.
- State Update:
userBalances[Alice] = PackedBytes(IV_A, R_A, Cipher_A)userBalances[Bob] = PackedBytes(IV_B, R_B, Cipher_B)
10.3 Recipient Synchronization (Recipient: Bob)
Bob logs in to his dashboard.
-
Detection: Bob’s client detects a change in the
userBalances[Bob]mapping. -
Fetch: The client downloads the new
PackedBytes. -
Decryption: The Snap uses Bob’s private key
d_Bobto derive the shared secret fromR_Band decryptsCipher_B. - Display: Bob sees his balance has increased by 50.
11. Explorer Verification (Blockscout Integration)
This section describes how a user can verify their encrypted balance directly on a standard block explorer (Blockscout) without relying on the DApp frontend. This utilizes the browser’s developer console to invoke the installed Snap.
11.1 The Privacy Challenge
Block explorers like Blockscout are designed to show public state. For x402 balances, the “Read Contract” tab will display the getUserEncryptedBalance output as a long hexadecimal string (e.g., 0x3a1f...).
- Problem: The user cannot verify if this hex string represents the correct balance (e.g., 100) just by looking at it.
-
Solution: Use the
x402-snapvia the browser console to decrypt this data in-place.
11.2 Verification Workflow
- Navigate: Go to the x402 Token Contract address on Blockscout.
- Read Contract: Click on the “Read Contract” tab.
-
Connect Wallet: Click “Connect Wallet” (MetaMask) to ensure the
window.ethereumprovider is injected into the Blockscout page context. -
Query: Find the
getUserEncryptedBalancefunction, enter your wallet address, and click “Query”. -
Result: Blockscout displays the opaque
bytesresult (e.g.,0x89ab...). - Decrypt: Open the Browser Developer Tools (F12 → Console) and run the integration snippet below.
11.3 Console Decryption Snippet
Copy and paste the following code into the Blockscout page console. It utilizes the global window.ethereum object to communicate with your installed Snap.
// 1. Define your Snap ID (Must match the ID used in the DApp)
const SNAP_ID = 'npm:x402-snap';
// 2. Function to decrypt the hex string
async function verifyBalance(encryptedHex) {
if (!encryptedHex.startsWith('0x')) {
console.error("Error: Input must be a hex string starting with 0x");
return;
}
console.log("Invoking x402 Snap for decryption...");
try {
const decryptedAmount = await window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: SNAP_ID,
request: {
method: 'x402_decrypt',
params: { packedBytesHex: encryptedHex }
},
},
});
// Parse the result
const balance = BigInt(decryptedAmount);
console.log("------------------------------------------------");
console.log("✅ DECRYPTION SUCCESSFUL");
console.log("------------------------------------------------");
console.log(`Encrypted Data: ${encryptedHex.substring(0, 20)}...`);
console.log(`Decrypted Hex: ${decryptedAmount}`);
console.log(`Token Balance: ${balance.toString()}`);
console.log("------------------------------------------------");
alert(`Verified Balance: ${balance.toString()}`);
} catch (err) {
console.error("❌ Decryption Failed:", err);
console.warn("Ensure the x402-snap is installed and you are the owner of this data.");
}
}
// 3. Usage Instruction
console.log("Setup Complete. To verify, copy the result from Blockscout and run:");
console.log('verifyBalance("0xYourEncryptedBytesHere...")');