Metamask-Friendly MachinePay Balance Encryption

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).

  1. Ephemeral Private Key: r = random integer in range [1, n-1].
  2. Ephemeral Public Key: R = r * G.
  3. Shared Secret Point: S = r * Q_U . Let S have 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.

  1. Client: Calls Snap RPC method x402_getPublicKey.
  2. Snap: Derives secp256k1 public key from wallet entropy (BIP-44).
  3. Client: Sends the resulting key (as bytes) to the smart contract: x402.registerPublicKey(bytes).
  4. Contract: Stores key in publicKeys[user].

Phase 2: Encryption (On-Chain)

During a BITE Phase 2 execution (onDecrypt):

  1. BITE UserEncryptor converts the uint256 balance to a Hex String.
  2. Generates random r and IV.
  3. Computes shared secret x_S from User Public Key Q_U .
  4. Derives K_enc = SHA-256(x_S) .
  5. Encrypts: C = AES(M, K_enc, IV).
  6. Packs data and writes to storage.

Phase 3: Decryption (Client-Side)

  1. Fetch: Client retrieves packed bytes from contract.
  2. Invoke Snap: Client calls x402_decrypt with packed bytes.
  3. Snap:
  • Unpacks IV (IV ), Ephemeral Key (R ), Ciphertext (C ).
  • Multiplies R by private key d_U to get S .
  • Hashes x_S to reproduce K_enc .
  • Decrypts C to get M.
  1. 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)

  1. Connect Wallet: The user connects MetaMask to the x402 DApp.
  2. Detection: The DApp detects that the x402-snap is not installed.
  3. Installation Prompt: The DApp triggers the Snap installation request.
  4. User Action: A MetaMask popup appears listing permissions (specifically snap_getBip44Entropy to derive encryption keys). The user clicks “Connect” or “Install”.

9.2 Key Registration (One-Time per Account)

  1. Status Check: The DApp queries the smart contract publicKeys[userAddress] to check for an existing key.
  2. Prompt: If no key is found, the UI displays a “Setup Confidential Balance” or “Register Key” button.
  3. User Action: User clicks “Register”.
  4. Snap Invocation: The DApp silently calls the Snap to derive the public key (no popup if Snap is connected).
  5. Transaction: The DApp initiates a standard Ethereum transaction x402.registerPublicKey(...).
  6. User Action: User signs and confirms the transaction in MetaMask to pay gas.

9.3 Routine Usage (Seamless Decryption)

  1. Dashboard View: The user navigates to their dashboard to view token balances.
  2. Data Fetch: The DApp fetches the encrypted balance (opaque hex string) from the blockchain.
  3. Decryption: The DApp invokes x402_decrypt on 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.
  1. 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.

  1. 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…).
  2. 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
  }],
});
  1. 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.
  1. 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.

  1. Logic: The contract verifies Alice has >= 50 tokens.
  2. Calculation:
  • AliceNewBal = AliceOldBal - 50
  • BobNewBal = BobOldBal + 50
  1. Key Retrieval: The contract retrieves the registered public keys:
  • Q_AlicepublicKeys[Alice]
  • Q_BobpublicKeys[Bob]
  1. Re-Encryption (Dual Output):
  • For Alice: The contract calls encryptWithKey(Q_Alice, toHex(AliceNewBal)). This generates a unique ephemeral key pair r_A , R_A and a unique IV_A.
  • For Bob: The contract calls encryptWithKey(Q_Bob, toHex(BobNewBal)). This generates a unique ephemeral key pair r_B , R_B and a unique IV_B.
  1. 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.

  1. Detection: Bob’s client detects a change in the userBalances[Bob] mapping.
  2. Fetch: The client downloads the new PackedBytes.
  3. Decryption: The Snap uses Bob’s private key d_Bob to derive the shared secret from R_B and decrypts Cipher_B.
  4. 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-snap via the browser console to decrypt this data in-place.

11.2 Verification Workflow

  1. Navigate: Go to the x402 Token Contract address on Blockscout.
  2. Read Contract: Click on the “Read Contract” tab.
  3. Connect Wallet: Click “Connect Wallet” (MetaMask) to ensure the window.ethereum provider is injected into the Blockscout page context.
  4. Query: Find the getUserEncryptedBalance function, enter your wallet address, and click “Query”.
  5. Result: Blockscout displays the opaque bytes result (e.g., 0x89ab...).
  6. 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...")');