Marketplace Program
The CollectorCrypt Marketplace Program is a Solana smart contract that facilitates decentralized trading of NFTs and digital collectibles using USDC.
Program Details
- Program ID:
CcmRKTuZCGJBWQwMHvDYApBRvSZNHqGJXkznqpDTSQUr - Framework: Anchor (Solana)
- Blockchain: Solana
- Currency: USDC (SPL Token)
- Version: 5
Devnet USDC Faucet
Use this faucet to get the USDC token used on devnet: https://spl-token-faucet.com/?token-name=USDC-Dev
Overview
The marketplace supports two types of NFTs:
- Programmable NFTs (pNFTs): Token Metadata standard with enforced royalties and transfer delegates
- Compressed NFTs (cNFTs): Merkle-tree-based assets via Metaplex Bubblegum
Key Features
- Non-custodial: NFTs remain in seller wallets using delegation, not escrow transfers
- Network-agnostic: Environment values injected via initialization
- Deterministic: All PDAs use fixed, reproducible seed schemes
- Whitelisting: Collection-based access control
- Platform fees: Configurable basis points (0.1% - 10%)
- User escrow: USDC escrow system for offer-backed liquidity
Because this is an escrowless marketplace, NFTs remain in your wallet while listed. You are responsible for canceling listings on NFTs you transfer away. While our backend monitors transfers and attempts to auto-cancel listings, this process is not 100% reliable. If you transfer an NFT and later receive it back, the old listing may still be active. Always verify and cancel stale listings manually.
Architecture
Transaction Flows
Listing Flow
- Seller initiates
list_pnftorlist_cnft - Creates Listing PDA
- Delegates NFT to the Listing PDA
Purchase Flow (Direct Buy)
- Buyer initiates
buy_pnftorbuy_cnft - Validates whitelist
- Transfers USDC (price) to seller
- Transfers USDC (fee) to treasury
- Transfers NFT from seller to buyer
- Closes Listing PDA
Offer Flow
- Buyer deposits funds via
deposit_to_user_escrow - Buyer creates offer via
make_offerormake_offer_n_deposit - Creates Offer PDA
- Validates whitelist
Accept Offer Flow
- Seller initiates
accept_offer_for_pnftoraccept_offer_for_cnft - Transfers USDC from escrow (price - fee) to seller
- Transfers USDC from escrow (fee) to treasury
- Transfers NFT from seller to buyer
- Closes Offer PDA
Account Structure
Market (Singleton)
PDA Seeds: ["market"]
Size: 186 bytes
| Field | Type | Description |
|---|---|---|
| version | u32 | Protocol version (currently 5) |
| super_admin | Pubkey | Governance authority |
| market_admin | Pubkey | Operational authority |
| backend_account | Pubkey | Backend co-signer for embedded wallets |
| market_bump | [u8; 1] | PDA bump seed |
| treasury | Pubkey | Fee collection address |
| royalty_fee | u16 | Platform fee in basis points |
| min_offer_amount | u64 | Minimum offer amount (USDC lamports) |
| paused | bool | Global pause flag |
| cnft_depth | u8 | Compressed NFT Merkle tree depth |
| cnft_canopy | u8 | Compressed NFT canopy depth |
| usdc_mint | Pubkey | Validated USDC mint address |
Listing
PDA Seeds: ["listing", asset_id, seller]
Size: 130 bytes
| Field | Type | Description |
|---|---|---|
| seller | Pubkey | Wallet that listed the NFT |
| nft | Pubkey | NFT mint (pNFT) or asset ID (cNFT) |
| listing_bump | [u8; 1] | PDA bump seed |
| price | u64 | Listing price in USDC lamports |
| collection_type | CollectionType | PNFT or CNFT |
| created_at | i64 | Unix timestamp of creation |
| updated_at | i64 | Unix timestamp of last update |
| rent_payer | Pubkey | Who paid rent (for refund on close) |
Offer
PDA Seeds: ["offer", asset_id, buyer]
Size: 129 bytes
| Field | Type | Description |
|---|---|---|
| nft | Pubkey | NFT being offered on |
| buyer | Pubkey | Wallet making the offer |
| offer_bump | [u8; 1] | PDA bump seed |
| price | u64 | Offer amount in USDC lamports |
| created_at | i64 | Unix timestamp of creation |
| updated_at | i64 | Unix timestamp of last update |
| rent_payer | Pubkey | Who paid rent (for refund on close) |
UserEscrow
PDA Seeds: ["user_escrow", user]
Size: 49 bytes
| Field | Type | Description |
|---|---|---|
| user | Pubkey | Escrow owner |
| user_escrow_bump | [u8; 1] | PDA bump seed |
| balance | u64 | Tracked USDC balance |
CollectionWhitelistEntry (V2)
PDA Seeds: ["whitelist_entry", market, collection_type_str, collection]
Size: 50 bytes
| Field | Type | Description |
|---|---|---|
| collection | Pubkey | Whitelisted collection address |
| collection_type | CollectionType | PNFT or CNFT |
| added_at | i64 | Timestamp when whitelisted |
| bump | u8 | PDA bump seed |
Role & Authority Model
Role Hierarchy
| Role | Holder | Capabilities |
|---|---|---|
| SUPER ADMIN | Squads multisig or governance wallet | Transfer super admin role, set market admin, set platform fee (10-1000 bps), set USDC mint, set backend account, set treasury |
| BACKEND ACCOUNT | Backend service | Co-signs transactions for embedded wallets, pays rent for PDA creation (off-chain only) | | TREASURY | Fee recipient | Receives platform fees on every trade |
Program Instructions
Initialization
initialize
Purpose: Create the singleton Market account with initial configuration
Signers: payer (any wallet)
Parameters:
super_admin: Pubkeymarket_admin: Pubkeybackend_account: Pubkeytreasury: Pubkey
Accounts:
payer(mut, signer)market(init PDA)usdc_mint(validated Mint)system_program
Notes:
- One-time only (second call fails)
- Permissionless but singleton PDA ensures only one Market exists
- USDC mint validated as existing SPL Mint account
Listing Instructions
list_pnft
Purpose: Create a listing for a programmable NFT
Signers: seller, rent_payer
Parameters: price: u64 (USDC lamports)
Key Accounts:
sellerrent_payermarketwhitelist_entryasset_id(Mint)listing(init PDA)seller_nft_account- Metadata/edition/token_record accounts
- Authorization rules accounts
Preconditions:
- Marketplace not paused
- Collection whitelisted
- Listing does not already exist
- price > 0
State: Creates Listing PDA. Delegates NFT transfer authority to the Listing PDA.
Important: Because this is an escrowless system, the NFT remains in your wallet. If you transfer the NFT to another wallet, you must cancel the listing. The backend attempts to auto-cancel listings on transfers, but this is not guaranteed.
list_cnft
Purpose: Create a listing for a compressed NFT
Signers: seller, rent_payer
Parameters:
price: u64root: [u8; 32]data_hash: [u8; 32]creator_hash: [u8; 32]asset_data_hash: [u8; 32]collection_hash: [u8; 32]nonce: u64index: u32
Key Accounts:
- seller, rent_payer, market, whitelist_entry
- asset_id, listing (init PDA)
- merkle_tree, tree_config
- Bubblegum/compression programs
State: Creates Listing PDA. Delegates cNFT to Listing PDA. Merkle proof accounts passed via remaining_accounts.
Important: Because this is an escrowless system, the cNFT remains in your wallet. If you transfer the cNFT to another wallet, you must cancel the listing. The backend attempts to auto-cancel listings on transfers, but this is not guaranteed.
update_listing
Purpose: Update the price of an existing listing
Signers: seller
Parameters: new_price: u64
Preconditions:
- Marketplace not paused
- new_price > 0
- Listing exists
- Seller matches listing.seller
State: Updates listing.price and listing.updated_at
cancel_pnft_listing
Purpose: Cancel a pNFT listing and revoke the delegate
Signers: seller
Key Accounts:
- seller
- rent_receiver (validated against listing.rent_payer)
- listing (closed)
- NFT accounts, metadata accounts
State: Revokes TransferV1 delegate from Listing PDA. Closes Listing PDA; rent refunded.
cancel_cnft_listing
Purpose: Cancel a cNFT listing and optionally revoke the delegate
Signers: seller
Parameters: Merkle proof parameters + skip_delegate_revoke: bool
State: If !skip_delegate_revoke, re-delegates cNFT from Listing PDA back to seller. Closes Listing PDA.
Trade Instructions
buy_pnft
Purpose: Purchase a listed pNFT directly
Signers: buyer, rent_payer
Parameters: expected_price: u64
Key Accounts:
- buyer, seller, listing_rent_receiver, rent_payer
- market, whitelist_entry, asset_id, listing
- seller/buyer NFT accounts, usdc_mint
- buyer/seller USDC accounts, treasury
- metadata/edition/token record accounts
Preconditions:
- Marketplace not paused
- listing.price == expected_price (frontrun protection)
- Collection still whitelisted at trade time
State Transitions:
- USDC
pricetransferred from buyer to seller - USDC
feetransferred from buyer to treasury - pNFT transferred from seller to buyer (via Listing PDA)
- Listing PDA closed; rent refunded
Important: Buyer pays price + fee. Seller receives full price.
buy_cnft
Purpose: Purchase a listed cNFT directly
Signers: buyer, rent_payer
Parameters:
expected_price: u64- Merkle proof parameters
flags: u8
State: Same USDC transfer pattern as buy_pnft. cNFT transferred via Bubblegum.
accept_offer_for_pnft
Purpose: Seller accepts a buyer's offer for a pNFT
Signers: seller, rent_payer
Parameters: expected_price: u64
Key Accounts:
- seller, buyer (validated against offer.buyer)
- offer_rent_receiver, market, asset_id
- seller/buyer NFT accounts, offer (closed)
- usdc_mint, buyer_user_escrow_account
- buyer_user_escrow_token_account
- seller USDC account, treasury accounts
- metadata accounts
Preconditions:
- Marketplace not paused
- seller ≠ buyer
- offer.price == expected_price
- Escrow token account balance ≥ tracked balance
- Escrow has sufficient funds
State Transitions:
- Escrow balance decremented by price
- USDC
price - feetransferred from escrow to seller - USDC
feetransferred from escrow to treasury - pNFT transferred from seller to buyer (seller signs directly)
- Offer PDA closed
Important: Seller receives price - fee. Fee is deducted from offer amount.
Note: Whitelist is NOT re-validated at acceptance (validated at offer creation).
accept_offer_for_cnft
Purpose: Seller accepts a buyer's offer for an unlisted cNFT
Signers: seller, rent_payer
State: Same escrow-based USDC flow. cNFT transferred via Bubblegum; seller signs.
accept_offer_for_listed_cnft
Purpose: Seller accepts a buyer's offer for a cNFT that is currently listed
Signers: seller, rent_payer
Key Accounts: All accounts from accept_offer_for_cnft plus listing (closed) and listing_rent_receiver
State: Both Listing PDA and Offer PDA are closed. USDC flow from escrow. cNFT transferred via Bubblegum, signed by Listing PDA.
Offer Instructions
make_offer
Purpose: Create an offer using existing escrow funds
Signers: buyer, rent_payer
Parameters:
collection_type: CollectionTypecollection_hash: [u8; 32]price: u64
Preconditions:
- Marketplace not paused
- seller ≠ buyer
- price ≥ market.min_offer_amount
- Collection whitelisted (V2)
- No existing offer for this (NFT, buyer) pair
State: Creates Offer PDA. Does NOT transfer USDC (requires pre-existing escrow balance).
Note: Escrow balance is not locked per-offer; checked at acceptance time.
make_offer_n_deposit
Purpose: Create an offer and deposit USDC into escrow in a single transaction
Signers: buyer, rent_payer
Parameters: Same as make_offer
State Transitions:
- USDC
pricetransferred from buyer to escrow token account - UserEscrow initialized if needed (idempotent)
- Escrow balance incremented
- Offer PDA created
Note: Preferred instruction for users without pre-existing escrow funds.
update_offer
Purpose: Change the price of an existing offer
Signers: buyer
Parameters: new_price: u64
Preconditions:
- Marketplace not paused
- new_price > min_offer_amount
- new_price ≠ current_price
- Offer exists
- Escrow has sufficient funds for new_price
State: Updates offer.price and offer.updated_at
Note: No USDC movement. Escrow balance check is validation only, not a lock.
cancel_offer
Purpose: Cancel an offer, keeping funds in escrow
Signers: buyer
Parameters: force: bool (currently unused)
State: Closes Offer PDA; rent refunded to rent_receiver. USDC remains in escrow.
cancel_offer_n_withdraw
Purpose: Cancel an offer and withdraw funds from escrow
Signers: buyer
State Transitions:
- Escrow balance decremented by offer.price
- USDC transferred from escrow token account to buyer's USDC account
- Offer PDA closed
User Escrow Instructions
deposit_to_user_escrow
Purpose: Deposit USDC into the user's escrow for backing offers
Signers: user, rent_payer
Parameters: amount: u64
Key Accounts:
- user, rent_payer, market, usdc_mint
- user_escrow_account (init_if_needed PDA)
- user_escrow_token_account (init_if_needed ATA)
- user_usdc_account
State Transitions:
- UserEscrow initialized if first deposit (idempotent)
- USDC transferred from user to escrow token account
- Escrow balance incremented
withdraw_from_user_escrow
Purpose: Withdraw USDC from the user's escrow
Signers: user
Parameters: amount: u64
Preconditions:
- Marketplace not paused
- escrow.balance ≥ amount
State Transitions:
- Escrow balance decremented
- USDC transferred from escrow token account to user (signed by UserEscrow PDA)
Fee Model
Platform Fee Configuration
| Parameter | Value |
|---|---|
| Default | 200 bps (2.00%) |
| Minimum | 10 bps (0.10%) |
| Maximum | 1000 bps (10.00%) |
| Configurable By | super_admin via set_platform_fee |
| Stored In | market.royalty_fee |
Fee Calculation
fee = floor(price * platform_fee_bps / 10000)
All arithmetic uses checked operations with u128 intermediate precision to prevent overflow.
Fee Flow by Transaction Type
Direct Purchase (buy_pnft / buy_cnft)
Buyer's USDC Account
|
|--- price ------> Seller's USDC Account
|--- fee --------> Treasury USDC Account
Offer PDA
const [offerPda] = PublicKey.findProgramAddressSync(
[Buffer.from("offer"), assetId.toBuffer(), buyer.toBuffer()],
PROGRAM_ID
);
// User Escrow PDA
const [userEscrowPda] = PublicKey.findProgramAddressSync(
[Buffer.from("user_escrow"), user.toBuffer()],
PROGRAM_ID
);
// Whitelist Entry PDA (V2)
const collectionTypeStr = collectionType === 'PNFT' ? 'PNFT' : 'CNFT';
const [whitelistEntryPda] = PublicKey.findProgramAddressSync(
[
Buffer.from("whitelist_entry"),
marketPda.toBuffer(),
Buffer.from(collectionTypeStr),
collection.toBuffer(),
],
PROGRAM_ID
);
Integration Flows
Flow 1: List and Sell a pNFT
1. Seller calls list_pnft(price)
-> Listing PDA created
-> NFT delegated to Listing PDA
2. Buyer calls buy_pnft(expected_price)
-> USDC: buyer -> seller (price)
-> USDC: buyer -> treasury (fee)
-> NFT: seller -> buyer (via Listing PDA delegate)
-> Listing PDA closed
Flow 2: Make Offer and Accept
1. Buyer calls deposit_to_user_escrow(amount)
-> USDC moved to escrow
2. Buyer calls make_offer(collection_type, collection_hash, price)
-> Offer PDA created (no USDC movement)
OR: Buyer calls make_offer_n_deposit (combines steps 1+2)
3. Seller calls accept_offer_for_pnft(expected_price)
-> USDC: escrow -> seller (price - fee)
-> USDC: escrow -> treasury (fee)
-> NFT: seller -> buyer
-> Offer PDA closed
Flow 3: Accept Offer on a Listed cNFT
1. Seller has an active listing (list_cnft)
2. Buyer makes an offer (make_offer_n_deposit)
3. Seller calls accept_offer_for_listed_cnft(expected_price, ...)
-> Both Listing PDA and Offer PDA closed
-> USDC from escrow split between seller and treasury
-> cNFT transferred via Listing PDA (as delegate)
Using Anchor Client
import { Program, AnchorProvider } from '@coral-xyz/anchor';
import { Connection, PublicKey } from '@solana/web3.js';
// Connect to cluster
const connection = new Connection('https://api.mainnet-beta.solana.com');
const provider = new AnchorProvider(connection, wallet, {});
const program = new Program(IDL, PROGRAM_ID, provider);
// Example: List a pNFT
await program.methods
.listPnft(new BN(1000000)) // price in USDC lamports
.accounts({
seller: seller.publicKey,
rentPayer: rentPayer.publicKey,
market: marketPda,
whitelistEntry: whitelistEntryPda,
assetId: nftMint,
listing: listingPda,
// ... additional accounts
})
.signers([seller, rentPayer])
.rpc();
Transaction Size Considerations
cNFT operations require Merkle proofs passed via remaining_accounts. The proof size depends on tree depth and canopy:
proof_accounts_needed = depth - canopy
With default parameters (depth=20, canopy=14), this requires 6 proof accounts. Each remaining account adds ~32 bytes to the transaction.
Error Reference
| Code | Name | Description |
|---|---|---|
| 6000 | Unauthorized | Signer does not match required authority |
| 6001 | CollectionAlreadyWhitelisted | Collection is already in the whitelist (V1) |
| 6002 | CollectionNotWhitelisted | Collection is not whitelisted or whitelist entry does not match |
| 6003 | InvalidRoyaltyFee | Legacy error — fee validation |
| 6004 | InvalidMinOfferAmount | Minimum offer amount must be > 0 |
| 6005 | MarketplacePaused | Operation rejected — marketplace is paused |
| 6006 | MarketplaceRunning | Cannot unpause — marketplace is already running |
| 6007 | ListingAlreadyExists | A listing already exists for this (NFT, seller) pair |
| 6008 | ListingNotExists | No active listing found |
| 6009 | ZeroPrice | Price must be greater than 0 |
| 6010 | ArithmeticOverflow | Checked arithmetic operation overflowed |
| 6011 | InsufficientEscrowAmount | Escrow amount insufficient |
| 6012 | InsufficientLockedAmount | Locked amount insufficient |
| 6013 | OfferAlreadyExists | An offer already exists for this (NFT, buyer) pair |
| 6014 | OfferNotExists | No active offer found (offer.price == 0) |
| 6015 | InvalidOfferAmount | Offer below minimum or equal to current price |
| 6016 | OfferToOwnListing | Cannot make offer or trade with yourself |
| 6017 | InvalidCNFTDepth | Depth must be 1-32 |
| 6018 | InvalidCNFTCanopy | Canopy must be 1-depth |
| 6019 | InvalidCNFTProofSize | Proof size out of valid range |
| 6020 | InsufficientEscrowFunds | Escrow balance insufficient for the operation |
| 6021 | PriceMismatch | On-chain price does not match expected_price parameter |
| 6022 | InvalidUsdcMint | Provided mint does not match market.usdc_mint |
| 6023 | InvalidProgramAddress | External program ID does not match expected hardcoded value |
| 6024 | EscrowBalanceMismatch | Tracked escrow balance exceeds actual token account balance |
| 6025 | InvalidMerkleTreeOwner | Merkle tree not owned by SPL Account Compression |
| 6026 | InvalidPlatformFee | Fee must be between 10 and 1000 basis points |
Common Failure Scenarios
| Scenario | Error | Resolution |
|---|---|---|
| Marketplace is paused | MarketplacePaused | Wait for admin to unpause |
| Collection not whitelisted | CollectionNotWhitelisted | Contact admin or verify whitelist_entry PDA |
| Price changed between view and transaction | PriceMismatch | Re-fetch listing/offer price, rebuild transaction |
| Insufficient escrow balance | InsufficientEscrowFunds | Deposit more USDC via deposit_to_user_escrow |
| Duplicate listing | ListingAlreadyExists | Cancel existing listing first |
| Duplicate offer | OfferAlreadyExists | Cancel existing offer first |
| Escrow balance inconsistency | EscrowBalanceMismatch | Indicates tracked balance > token account |
| Offer on own listing | OfferToOwnListing | Cannot offer on your own NFT |
| Zero price listing/offer | ZeroPrice / InvalidOfferAmount | Price must be > 0 |
Security Considerations
PDA Protections
- Deterministic derivation: All PDAs use fixed, predictable seeds with no user-controlled nonces
- Bump stored on-chain: Each PDA stores its bump seed for consistent re-derivation
- Listing as delegate authority: The Listing PDA serves as transfer delegate, ensuring only the program can authorize transfers
Frontrun / Race Condition Protection
Price matching: Both buy_* and accept_offer_for_* require an expected_price parameter. The instruction fails with PriceMismatch if the on-chain price differs, preventing:
- Seller increasing listing price between buyer signature and transaction landing
- Buyer decreasing offer between seller signature and transaction landing
Self-Trade Prevention
accept_offer_for_* and make_offer* enforce seller != buyer via the OfferToOwnListing error.
Escrow Balance Integrity
At offer acceptance, the handler checks:
buyer_user_escrow_token_account.amount >= user_escrow.balance
This detects inconsistency between tracked balance and actual token account. Failure raises EscrowBalanceMismatch.
USDC Mint Validation
Every USDC instruction validates the mint against market.usdc_mint using address constraints, preventing substitution of fake tokens.
External Program ID Validation
All external program accounts are validated via hardcoded address constraints:
- MPL Token Metadata:
metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s - Metaplex Bubblegum:
BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY - SPL Account Compression:
mcmt6YrQEMKw8Mw43FmpRLmf7BqRnFMKmAcbxE3xkAW - SPL Noop:
mnoopTCrg4p8ry25e4bcWA9XZjbNjMTfgYVGGEdRsf3 - MPL Token Auth Rules:
auth9SigNpDKz4sJJ1DfCTuZrZNSAgh9sFD3rboVmgg
Rent Payer Tracking
Listings and offers record who paid for the PDA rent (rent_payer field). On closure, rent is refunded to a rent_receiver account validated via:
address = listing.rent_payer
This ensures rent refunds go to the original payer.
Known Tradeoffs
| Tradeoff | Description |
|---|---|
| Escrowless listings - User responsibility | NFTs remain in seller wallets while listed. Sellers must manually cancel listings when transferring NFTs. The backend attempts to auto-cancel on transfer, but this is not 100% reliable. If an NFT is transferred away and later returned, the original listing may still be active. |
| Escrow not per-offer locked | A user's escrow balance backs all their offers collectively. If a user has 100 USDC in escrow and makes two 60 USDC offers, only one can be accepted. This is by design for capital efficiency. |
| Permissionless initialization | Any wallet can call initialize. Safe due to singleton PDA constraint, but deployer must call promptly. |
| Direct buy fee is additive | Buyers pay price + fee, not price inclusive. Differs from offer model where fee is deducted. |
External Program Dependencies
| Program | ID | Usage |
|---|---|---|
| MPL Token Metadata | metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s | pNFT delegation, transfer, revocation |
| Metaplex Bubblegum | BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY | cNFT delegation, transfer |
| SPL Account Compression | mcmt6YrQEMKw8Mw43FmpRLmf7BqRnFMKmAcbxE3xkAW | Merkle tree verification |
| SPL Noop | mnoopTCrg4p8ry25e4bcWA9XZjbNjMTfgYVGGEdRsf3 | Log wrapper for Bubblegum |
| MPL Token Auth Rules | auth9SigNpDKz4sJJ1DfCTuZrZNSAgh9sFD3rboVmgg | pNFT authorization rules |
| SPL Token | (standard) | USDC transfers |
| SPL Associated Token | (standard) | ATA creation/resolution |
| System Program | (standard) | Account creation, rent |
Support
For technical questions, integration support, or to report issues, please contact the CollectorCrypt development team.