DEV Community

Cover image for Building PromptVault: An Encrypted AI Prompt Marketplace with Fully Homomorphic Encryption
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building PromptVault: An Encrypted AI Prompt Marketplace with Fully Homomorphic Encryption

The Problem

AI prompts have become digital assets. A carefully crafted prompt can save
hours of iteration — the difference between a generic ChatGPT response and
something genuinely useful. People invest real time into engineering these
prompts, and a marketplace is the natural next step.

But there's a fundamental tension: a buyer needs to see the prompt to know
it's valuable, but the creator can't reveal it without getting paid.

Traditional marketplaces solve this with "trust me" — you pay, and the seller
sends you the content. That works until it doesn't. Chargebacks, fake
prompts, sellers who disappear after the sale.

PromptVault replaces trust with cryptography.


Why Fully Homomorphic Encryption?

Fully Homomorphic Encryption (FHE) is a cryptographic primitive that lets you
compute on encrypted data without ever decrypting it. The CoFHE coprocessor
from Fhenix brings this to Ethereum: smart contracts can manipulate encrypted
values using operations like FHE.add(), FHE.mul(), and — most importantly
for our use case — FHE.allow().

FHE.allow(encryptedValue, address) grants a specific wallet the ability to
decrypt a ciphertext. The smart contract can do this atomically: the buyer
pays, and the contract calls FHE.allow() in the same transaction. No second
step, no trust required.

This is the key enabling technology: the encryption key never appears in
plaintext anywhere — not on-chain, not in an API response, not in any
database.
It exists only transiently in the browser of the authorised buyer
or creator, reconstructed from FHE ciphertexts stored on-chain.


The Architecture Decision: AES + FHE Hybrid

Pure FHE has a constraint: each encrypted value is limited to 128 bits
(euint128). A typical AI prompt is 200–2,000 bytes. Encrypting it directly
with FHE is impractical.

The solution is a two-layer encryption model:

Layer Algorithm What it encrypts Where stored
1 AES-256-GCM Prompt body (any length) IPFS (Pinata)
2 FHE (CoFHE) AES-256 key (split into 2×128b halves) Sepolia blockchain

This hybrid approach removes the size limit entirely. Encrypt the prompt body
with a fast symmetric cipher (AES-256-GCM), then use FHE to gate access to
the symmetric key. The prompt ciphertext on IPFS is public — it's useless
without the AES key, which is FHE-gated on-chain.

The 256-bit AES key is split into two 128-bit halves (key0 and key1),
each stored as an euint128 on-chain. To reconstruct: bytes32(key0 << 128 | key1) in big-endian byte order.


The Smart Contract

PromptVault.sol

The Solidity contract is the core of the system. It uses @fhenixprotocol/cofhe-contracts for the FHE.sol library.

Deployed address: 0xc369bd84AE4a4468DA5635619e62F942BeaF5DA3 on Sepolia.

Listing Flow

function listPrompt(
    InEuint128 calldata encryptedKey0,
    InEuint128 calldata encryptedKey1,
    string     calldata title,
    string     calldata category,
    string     calldata metadataURI,
    string     calldata promptCID,
    uint256             priceWei
) external returns (uint256 listingId)
Enter fullscreen mode Exit fullscreen mode

The creator encrypts their AES key halves in the browser using the CoFHE SDK,
sending InEuint128 structs (ciphertext + ZK proof) to the contract. The
contract validates the ZK proofs via FHE.asEuint128(), stores the encrypted
handles, and sets two ACL entries:

  • FHE.allowThis() — the contract retains access so it can grant access to buyers later
  • FHE.allowSender() — the creator can verify their own listing

The prompt body (AES ciphertext) goes to IPFS via Pinata. The CID is stored
on-chain as a string.

Purchase Flow

function purchasePrompt(uint256 listingId) external payable
Enter fullscreen mode Exit fullscreen mode

The buyer sends ETH equal to priceWei. The contract:

  1. Verifies the listing is active, the buyer hasn't already purchased, and the buyer isn't the creator
  2. Calls FHE.allow(keyPart0, buyer) and FHE.allow(keyPart1, buyer) — permanently granting decryption access
  3. Records the purchase
  4. Splits payment: 97.5% to creator, 2.5% platform fee

Key design property: FHE.allow() is permanent. Once a buyer gets access,
it cannot be revoked. This means buyers keep access even if the creator
delists — a deliberate trade-off for atomicity (no revoke means no race
conditions on access control).

Revenue Model

The platform takes 250 basis points (2.5%). The feeRecipient is set to the
deployer in the constructor. Earnings accumulate in a mapping and can be
withdrawn at any time via withdrawEarnings(). The contract follows the
checks-effects-interactions pattern — zeroing the balance before the external
call to prevent re-entrancy.

Key View Functions

  • getListing(listingId) — returns all metadata (title, category, price, sales count, etc.) but not the key handles
  • getKeyHandles(listingId) — returns the euint128 handles, but only for the creator or a verified purchaser (reverts with NotAuthorised otherwise)
  • getListings(offset, limit) — paginated listing IDs
  • hasPurchased(listingId, buyer) — check purchase status
  • pendingEarnings(account) — unpaid earnings balance

Error Handling

The contract uses custom errors (solidity revert) instead of string messages
for gas efficiency: ListingDoesNotExist, AlreadyPurchased,
IncorrectPayment, CreatorCannotBuyOwnPrompt, and ten more covering every
edge case.

Test Coverage

41 tests across 9 describe blocks, covering deployment, listing creation with
all validation paths, purchase flow (success and all error states), delisting,
earnings and withdrawals, authorised key handle access, and paginated
enumeration.


The Frontend

Stack

Layer Choice
Framework Next.js 14 (App Router)
Blockchain Wagmi v2 / viem 2.x
FHE @cofhe/sdk 0.5.2
State @tanstack/react-query (server reads)
Styling Tailwind CSS v3 + shadcn/ui v4
IPFS Pinata (JWT-based upload)
WYSIWYG @uiw/react-md-editor
Crypto Web Crypto API (SubtleCrypto)

Data Flow: Listing a Prompt

This is the most complex operation in the application, involving multiple
cryptographic operations across different systems:

browser → AES-256 keygen → split into 2 halves → encrypt prompt with AES
    → upload ciphertext to IPFS → get CID
    → encrypt key halves with CoFHE SDK → get InEuint128 structs
    → wallet.writeContract("listPrompt", [encKey0, encKey1, ..., cid, price])
Enter fullscreen mode Exit fullscreen mode

The useListPrompt hook orchestrates this entire pipeline. Key detail: the
AES key is generated using the browser's crypto.subtle.generateKey() with
{ name: "AES-GCM", length: 256 } — the same primitive used by Signal,
WhatsApp, and every modern browser. The key is exported as raw bytes, split
into two bigint values (128 bits each), and encrypted by the CoFHE SDK
before being sent to the contract.

Data Flow: Purchasing a Prompt

buyer → wallet.writeContract("purchasePrompt", id, { value })
    → FHE.allow(key0, buyer) + FHE.allow(key1, buyer) [atomic tx]
    → contract.read.getKeyHandles(id) → euint128 handles
    → CoFHE SDK decrypts handles → two bigint halves
    → reconstruct AES-256 key from halves
    → download ciphertext from IPFS
    → AES-GCM decrypt → plaintext
Enter fullscreen mode Exit fullscreen mode

A critical detail: getKeyHandles() is a view function — it doesn't cost
gas. The buyer only pays gas for the purchasePrompt transaction. The key
handles are fetched after the transaction confirms, and the CoFHE SDK decrypts
them locally in the browser. The AES key is materialised as a browser-native
CryptoKey object and never persisted — no localStorage, no cookies, no
network send. When the user navigates away, the key is gone.

SSR Safety and the CoFHE SDK

The CoFHE SDK uses IndexedDB internally (for cached FHE keys and WASM
modules), which doesn't exist on the server. The client is initialised via a
dynamic import("@cofhe/sdk/web") guarded by typeof window === "undefined".
The singleton pattern in cofhe.ts ensures a single client instance with
lazy initialisation:

async function initClient() {
  if (typeof window === "undefined") return;
  const [{ createCofheClient, createCofheConfig }, { sepolia }] =
    await Promise.all([
      import("@cofhe/sdk/web"),
      import("@cofhe/sdk/chains"),
    ]);
  const config = createCofheConfig({ supportedChains: [sepolia] });
  cofheClient = createCofheClient(config);
}
Enter fullscreen mode Exit fullscreen mode

Version Debugging: The 0.4.0 → 0.5.2 Migration

During development, we encountered a cryptic error when calling
encryptInputs():

Failed to fetch FHE key and CRS
Caused by: Error serializing FHE publicKey
Error: Custom("invalid value: integer `7809075072243073024`, expected usize")
Enter fullscreen mode Exit fullscreen mode

The value 7809075072243073024 in hex is 0x6C6F6F6B — ASCII for "look".
The CoFHE SDK was receiving a text response (likely an error page or redirect)
instead of binary FHE key data, and the bincode deserializer was trying to
interpret the text as a usize.

The root cause: @cofhe/sdk 0.4.0 had stale testnet coordinator URLs. When
the SDK tried to fetch the FHE public key from the old endpoint, it got an
unexpected response. Upgrading to @cofhe/sdk 0.5.2 (with updated chain
configurations) resolved the issue.

WYSIWYG Markdown Editor

The prompt content field uses @uiw/react-md-editor in live-preview mode.
Users get a full toolbar (bold, italic, headings, lists, code blocks) and see
their rendered markdown below the editor in real-time. The prompt is submitted
as raw markdown text — the AES encryption happens on the raw text, and buyers
see it rendered via react-markdown after decryption.


Security Model

Trust assumptions:
├── Creator is honest about prompt content
├── Buyer has a legitimate wallet (no Sybil defense)
└── Sepolia RPC provider does not inspect traffic

What the contract protects against:
├── Platform operator reading prompt content
├── Third-party blockchain observer reading prompt content
├── Non-purchasers decrypting the AES key
└── Front-running purchase transactions

What is NOT protected:
├── Buyer sharing decrypted content (no DRM)
├── Malicious JavaScript in prompt (no sandbox)
└── Creator listing stolen/plagiarised prompts
Enter fullscreen mode Exit fullscreen mode

The threat model is honest-but-curious for all parties except the contract
itself, which is trustless by design. The hybrid AES+FHE architecture means
a compromise of the IPFS gateway, the RPC provider, or the Vercel deployment
does not leak prompt content. The only way to decrypt is to hold a wallet
that the contract has authorised via FHE.allow().

This isn't DRM. Once a buyer decrypts the prompt, they can share it. We're
solving the discovery-and-payment problem, not preventing piracy.


What I'd Do Differently

  1. CoFHE SDK version pinning: Locking to exact versions and testing
    against the live testnet earlier would have caught the endpoint mismatch
    sooner. The 0.4.0 → 0.5.2 jump required viem upgrades and type casting.

  2. Field size consideration: euint128 is sufficient for AES-128, but
    for AES-256 we need two handles. If the CoFHE protocol adds euint256
    support, the smart contract could be simplified to store a single handle.

  3. Local permit caching: The CoFHE SDK's permit system requires a MetaMask
    signature each session. Caching the permit hash in sessionStorage would
    eliminate the redundant signing step on page reloads.


The Code

All code is open-source on GitHub: harishkotra/promptvault

  • contracts/PromptVault.sol — The FHE-enabled smart contract with ACL-based key gating
  • frontend/src/lib/aes.ts — AES-256-GCM key generation, splitting, reconstruction, encrypt, and decrypt using Web Crypto API
  • frontend/src/lib/cofhe.ts — CoFHE client singleton with SSR safety
  • frontend/src/lib/ipfs.ts — Pinata-based IPFS upload and download
  • frontend/src/hooks/useListPrompt.ts — End-to-end encryption pipeline
  • frontend/src/hooks/usePurchasePrompt.ts — On-chain purchase + local decryption flow
  • frontend/src/lib/contract.ts — Full ABI with complete function and error signatures

Built With

Screenshots

PromptVault 1

PromptVault 2

PromptVault 3

PromptVault 4

PromptVault 5

Code and more: https://www.dailybuild.xyz/project/156-promptvault

Top comments (0)