Architecture
SealedIP has four layers. Each layer talks only to its neighbors. The contracts are the source of truth.
Layer 1 — User-facing apps
Web app (web/)
Next.js 15 App Router + RainbowKit + wagmi + viem. The reference UI for bidders and sellers. Lives at sealedip.com.
Key responsibilities:
- Browsing, filtering, and sorting auctions
- Mint-and-list flow for new IPs
- List-existing-IP flow with Story v4 owner lookup
- Sealed-bid placement (delegates to SDK)
- Reserve sealing for sellers (delegates to SDK)
- Watchlist (localStorage per wallet)
- My bids dashboard
TDH2 encryption requires a WASM module (@piplabs/cdr-crypto) that does not
load in browser or edge runtimes. The web app therefore proxies all encryption
calls through Next.js API routes running in Node.
TypeScript SDK (sdk/)
A viem-compatible wrapper around:
- ABIs for SealedAuction and AuctionRevealCondition
placeBid— the bidder's two-call flow (allocate slot, sign, encrypt, submit ciphertext)sealReserve— the seller's flow (read reserveUuid, sign, encrypt, submitEncryptedReserve)settleAuction— orchestrator: wait, trigger, decrypt bids, decrypt reserve, settlegetAuction/getBid/getAllBids— read helpers (returnreserveUuidandreserveHasCiphertext, notreservePrice)encodePayload/decodePayload/signBidDigest/encryptBid/decryptBid
Subpath exports: @sealedip/sdk/constants, @sealedip/sdk/abi/sealed-auction,
@sealedip/sdk/encrypt, @sealedip/sdk/payload, etc.
Layer 2 — Off-chain services
Encryption API
A Node.js server that runs TDH2 encryption against the active CDR public key. The caller signs a bid payload off-chain; the encryption API encrypts the signed payload under the threshold key and returns ciphertext bytes.
The same API handles reserve encryption for sellers. The plaintext format is identical: a 149-byte BidPayload where the signer field is the seller address instead of a bidder address.
Why a separate server: TDH2 encryption uses @piplabs/cdr-crypto WASM, which
does not load in browser or Cloudflare Workers environments.
Pinata / IPFS
When a seller mints a new IP through /create, the IP metadata JSON and the
NFT metadata JSON are pinned to IPFS via Pinata.
The on-chain tokenURI ends up as ipfs://<cid> so any NFT viewer can
dereference it.
If Pinata is unavailable, the SDK falls back to embedding the metadata as a
data:application/json;base64,... URI so the mint never blocks on IPFS infra.
Story v4 API
Story Protocol's official REST API for asset discovery. SealedIP's web app
proxies POST /assets (filtered by ownerAddress) to populate the
IP picker for the list-existing-IP flow.
Read-only. Server-side key only. See the reference notes for API quirks worth knowing.
Layer 3 — On-chain contracts
SealedAuction.sol
The core state machine. Tracks every auction, every bid slot, every CDR vault
uuid, and the sealed reserve vault per auction. Handles createAuction,
submitEncryptedReserve, allocateBidSlot, submitEncryptedBid, trigger,
and settle. Coordinates with the other on-chain pieces.
The auction storage tuple is:
(address seller, address ipId, uint256 licenseTermsId, uint32 reserveUuid, bool reserveHasCiphertext, uint64 deadline, uint8 state, uint16 bidCount).
There is no plaintext reservePrice field.
BidPayload library
Pure-Solidity library for the 149-byte payload format: encoding, decoding,
and signature recovery. The same format is used for both bids (signer =
bidder) and the reserve (signer = seller). Uses the eth-signed-message prefix
so browser personal_sign and SDK private-key signing produce identical
signatures. EIP-2 low-s is enforced.
AuctionRevealCondition.sol
The CDR read condition for every vault in a SealedIP auction: both bid vaults
and the reserve vault. Returns true only when BOTH gates pass:
block.timestamp >= deadline— the deadline has elapsed.SealedAuction.isTriggered(auctionId) == true—trigger()has been called.
SealedAuction registers each vault (bid or reserve) on
AuctionRevealCondition at allocation time via
register(uint32 uuid, uint256 auctionId, uint256 deadline). Registration
is one-shot per uuid; re-registration and zero-deadline revert. CDR validators
call checkReadCondition(uint32 uuid, bytes, bytes, address) before
contributing any partial decryption share.
This replaced the old TimeBasedReadCondition (time-gate only, deleted),
which allowed decryption as soon as the clock passed. The trigger gate
prevents premature decryption even if the clock has elapsed.
Write conditions (important nuance)
SealedAuction passes writeConditionAddr = address(this) to CDR.allocate.
CDR does not block writes in this setup. Write-access rules (caller is the
slot's allocator or the seller; before deadline; single write) are enforced
in SealedAuction's Solidity, not via a custom CDR write condition. A custom
BidWriteCondition was investigated in an on-chain spike and deliberately not
shipped: no functional gain, regression risk. Do not document a custom write
condition as existing.
CDR threshold network
The off-chain validator set plus the on-chain CDR contract at
0xCCCcCC0000000000000000000000000000000005. Holds the threshold key,
processes encryption requests, and publishes partial decryption shares.
Operated by piplabs. SealedIP delegates confidentiality to CDR entirely. The
CDR contract is not verified on Storyscan; the marketplace reads
NEXT_PUBLIC_CDR_THRESHOLD for display only. Real threshold parameters live
in CDR governance.
Story Periphery (SPG) + LicensingModule
Story Protocol's official contracts for minting NFTs, registering IPs, attaching license terms, and issuing license tokens.
RegistrationWorkflows.mintAndRegisterIp— mint-and-list flow for new IPsLicensingModule.attachLicenseTerms— attach PIL terms at listingLicensingModule.mintLicenseTokens— mint the PIL license token to the auction winner at settle
Payment currency is WIP. Royalty policies LAP (Liquid Absolute Percentage) and LRP (Liquid Relative Percentage) are used depending on the preset chosen by the seller. Story's canonical "Non-Commercial Social Remixing" PIL terms id is 1.
Layer 4 — Settlement transport
WIP (Wrapped IP) is the ERC-20 token used for bid deposits and seller
payouts. All transfers happen via standard transfer/transferFrom calls
from the SealedAuction contract.
When settle() runs, the contract makes one transfer per payout: to the
seller, to the winner (as overpayment refund), and to each losing bidder.
Because every transfer reverts on failure, the whole settlement is atomic.
See Atomic settlement.
Trustlessness argument
The central trust claim: a malicious orchestrator can only censor, never forge a winner or pay a non-winner.
What makes this true:
- Every bid signature is re-verified on-chain at settle via
ecrecover. - The deposit cap is enforced on-chain: a bid cannot reveal an amount larger than the escrowed deposit.
- The reserve signature is verified on-chain: the signer must recover to the seller's address; otherwise the entire settle reverts.
- Threshold cryptography means no single party (including the orchestrator) holds the decryption key. t-of-n validators must cooperate.
If the orchestrator censors (refuses to call settle()), any other party
that obtains the decrypted reveals can call settle() themselves.
Censorship auto-refunds all bidders because the state stays Triggered
indefinitely and anyone can retry.
Why this layering matters
- Off-chain services are replaceable. The Encryption API and Pinata could be swapped for any equivalent without touching contracts.
- Contracts are not. Once deployed and adopted, the contract surface is the API. Changes require deploying new versions.
- CDR is a dependency, not a fork. SealedIP does not run validators. If the CDR network changes, we adapt.
For the protocol's per-state behavior, see Lifecycle and the State machine.