Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Build a marketplace in 15 minutes · Boson Protocol
Skip to content

Build a marketplace in 15 minutes

What you'll build: a Next.js app that creates a seller, publishes an offer, lets a buyer commit, and redeems the voucher — all on Base Sepolia.

Time: 15 minutes. Stack: Next.js 15, TypeScript, ethers v5, Boson Core SDK. Network: Base Sepolia (configId: "staging-84532-0").

1. Scaffold

pnpm create next-app boson-market --typescript --app --no-tailwind --no-eslint
cd boson-market
pnpm add @bosonprotocol/core-sdk @bosonprotocol/ethers-sdk @bosonprotocol/metadata @bosonprotocol/ipfs-storage ethers@^5

2. Create the SDK once, reuse it

// src/lib/boson.ts
import { CoreSDK } from "@bosonprotocol/core-sdk"
import { EthersAdapter } from "@bosonprotocol/ethers-sdk"
import { IpfsMetadataStorage } from "@bosonprotocol/ipfs-storage"
import { validateMetadata } from "@bosonprotocol/metadata"
import { ethers } from "ethers"
 
const CONFIG_ID = "staging-84532-0" as const
const ENV_NAME = "staging" as const
 
export function getSdkForSigner(privateKey: string) {
  const provider = new ethers.providers.JsonRpcProvider(
    process.env.NEXT_PUBLIC_RPC_URL,
  )
  const wallet = new ethers.Wallet(privateKey, provider)
 
  return {
    sdk: CoreSDK.fromDefaultConfig({
      web3Lib: new EthersAdapter(provider, wallet),
      envName: ENV_NAME,
      configId: CONFIG_ID,
      metadataStorage: new IpfsMetadataStorage(validateMetadata, { url: process.env.IPFS_URL! }),
    }),
    wallet,
  }
}

3. Create a seller and publish an offer

// scripts/seed.ts
import { ethers } from "ethers"
import { getSdkForSigner } from "../src/lib/boson"
 
const { sdk, wallet } = getSdkForSigner(process.env.SELLER_PRIVATE_KEY!)
 
// 1. Create the on-chain seller (if you don't already have one)
const tx1 = await sdk.createSeller({
  assistant: wallet.address,
  admin: wallet.address,
  treasury: wallet.address,
  contractUri: "ipfs://Qm…",
  royaltyPercentage: "0",
  authTokenId: "0",
  authTokenType: 0,
  metadataUri: "ipfs://Qm…",
})
const receipt1 = await tx1.wait()
await sdk.waitForGraphNodeIndexing(receipt1.blockNumber)
 
const seller = (await sdk.getSellersByAddress(wallet.address))[0]
console.log("Seller created:", seller.id)
 
// 2. Build and pin metadata. There is no productV1MetadataFactoryconstruct
//    the PRODUCT_V1 object directly per the schema. See ReferenceMetadata.
const uuid = crypto.randomUUID()
const metadata = {
  schemaUrl: "https://schema.org/Product",
  type: "PRODUCT_V1" as const,
  uuid,
  name: "Test T-shirt",
  description: "A shirt for testing.",
  externalUrl: `https://example.com/${uuid}`,
  licenseUrl: `https://example.com/${uuid}/license`,
  image: "https://example.com/shirt.jpg",
  attributes: [
    { traitType: "Type", value: "Apparel" },
    { traitType: "Size", value: "M" },
  ],
  product: {
    uuid, version: 1,
    title: "Test T-shirt", description: "A shirt for testing.",
    productionInformation_brandName: "Your brand",
    details_offerCategory: "PHYSICAL",
    visuals_images: [{ url: "https://example.com/shirt.jpg", tag: "product" }],
  },
  seller: { defaultVersion: 1, name: "Your brand", contactLinks: [{ tag: "email", url: "mailto:hi@example.com" }] },
  shipping: { returnPeriod: "14" },
  exchangePolicy: { uuid: crypto.randomUUID(), version: 1, label: "fairExchangePolicy", template: "ipfs://Qm-template", sellerContactMethod: "email", disputeResolverContactMethod: "email" },
}
const metadataUri = await sdk.storeMetadata(metadata)
 
// 3. Create the offer
const tx2 = await sdk.createOffer({
  price: "1000000", // 1 USDC (6 decimals)
  sellerDeposit: "0",
  buyerCancelPenalty: "0",
  quantityAvailable: "10",
  exchangeToken: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // USDC on Base Sepolia
  metadataUri,
  metadataHash: metadataUri.replace(/^ipfs:\/\//, ""),  // IPFS CID is the hash
  validFromDateInMS: Date.now(),
  validUntilDateInMS: Date.now() + 30 * 24 * 60 * 60 * 1000,
  voucherRedeemableFromDateInMS: Date.now(),
  voucherRedeemableUntilDateInMS: 0,
  voucherValidDurationInMS: 30 * 24 * 60 * 60 * 1000,
  disputePeriodDurationInMS: 7 * 24 * 60 * 60 * 1000,
  resolutionPeriodDurationInMS: 7 * 24 * 60 * 60 * 1000,
  collectionIndex: "0",
  disputeResolverId: "<your-dr-id>",                 // look up via sdk.getDisputeResolvers(); must support USDC
  agentId: "0",
  feeLimit: ethers.constants.MaxUint256.toString(),  // accept any fee
})
await tx2.wait()
console.log("Offer published.")

Run it:

SELLER_PRIVATE_KEY=0x... \
NEXT_PUBLIC_RPC_URL=https://sepolia.base.org \
IPFS_URL=https://your-pinning-endpoint \
pnpm tsx scripts/seed.ts

Use an authenticated pinning endpoint for IPFS_URL (Pinata, web3.storage, your own Kubo node, …). The protocol default points at Infura, which now requires a project id.

4. Commit as a buyer

In a Next.js route handler:

// src/app/api/commit/route.ts
import { NextResponse } from "next/server"
import { getSdkForSigner } from "@/lib/boson"
 
const USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"  // Base Sepolia USDC
 
export async function POST(req: Request) {
  const { offerId } = await req.json()
  const { sdk } = getSdkForSigner(process.env.BUYER_PRIVATE_KEY!)
 
  // Approve the ERC-20 token if needed. First arg is the TOKEN address.
  await (await sdk.approveExchangeToken(USDC, /* amount */ "1000000")).wait()
 
  // CommitcommitToOffer(offerId, overrides?); buyer is the signer.
  const tx = await sdk.commitToOffer(offerId)
  const receipt = await tx.wait()
 
  return NextResponse.json({ txHash: receipt.transactionHash })
}

5. Redeem the voucher

import { getSdkForSigner } from "@/lib/boson"
const { sdk } = getSdkForSigner(process.env.BUYER_PRIVATE_KEY!)
const tx = await sdk.redeemVoucher(exchangeId)
await tx.wait()

What just happened

You created an on-chain seller, pinned product metadata to IPFS, published an offer, had a buyer approve a token and commit (which mints a voucher rNFT to the buyer), then redeemed the voucher (which transitions the exchange from COMMITTED to REDEEMED and starts the dispute window).

Common gotchas

  • waitForGraphNodeIndexing is mandatory after writes if your next step reads from the subgraph. The subgraph lags 1–3 blocks. See Concepts → Eventing & indexing.
  • ERC-20 approval is per-token, not per-offer. Approving 1 USDC means you can commit to 1 USDC of any USDC-priced offers — until your allowance runs out.
  • Validity dates are milliseconds, not seconds. All *InMS params are ms since epoch. Double-check.
  • feeLimit is the max protocol fee you'll accept, in token base units. Pass MaxUint256 to accept any fee; passing 0 reverts with TotalFeeExceedsLimit.
  • disputeResolverId is env-specific — it must exist on your chain and support the offer's exchangeToken. Look up the active set via sdk.getDisputeResolvers() before publishing.

Next