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)
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
The buyer sends ETH equal to priceWei. The contract:
- Verifies the listing is active, the buyer hasn't already purchased, and the buyer isn't the creator
- Calls
FHE.allow(keyPart0, buyer)andFHE.allow(keyPart1, buyer)— permanently granting decryption access - Records the purchase
- 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 theeuint128handles, but only for the creator or a verified purchaser (reverts withNotAuthorisedotherwise) -
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])
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
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);
}
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")
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
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
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.Field size consideration:
euint128is sufficient for AES-128, but
for AES-256 we need two handles. If the CoFHE protocol addseuint256
support, the smart contract could be simplified to store a single handle.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
- Fhenix CoFHE — FHE coprocessor for Ethereum
- Hardhat — Smart contract development
- Next.js 14 — React framework
- Wagmi — Ethereum hooks for React
- shadcn/ui — Component library
- Pinata — IPFS pinning service
Screenshots
Code and more: https://www.dailybuild.xyz/project/156-promptvault





Top comments (0)