Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Recipe — E2E seller flow · Boson Protocol
Skip to content

Recipe: End-to-end seller flow

A complete seller lifecycle in one script. Adapted from agentic-commerce/e2e/boson/tests/complete-marketplace-journeys.test.ts.

Stack: TypeScript, ethers v5, @bosonprotocol/core-sdk. Network: Base Sepolia.

Setup

pnpm add @bosonprotocol/core-sdk @bosonprotocol/ethers-sdk @bosonprotocol/metadata @bosonprotocol/ipfs-storage ethers@^5
export PRIVATE_KEY=0x...
export RPC_URL=https://sepolia.base.org
# IPFS — use an authenticated pinning endpoint. The protocol default points at
# Infura, which now requires a project id. Pinata, web3.storage, or your own
# Kubo node all work; pass an authenticated URL via process.env.IPFS_URL.
export IPFS_URL=https://your-pinning-endpoint

The script

import { CoreSDK } from "@bosonprotocol/core-sdk"
import { EthersAdapter } from "@bosonprotocol/ethers-sdk"
import { IpfsMetadataStorage } from "@bosonprotocol/ipfs-storage"
import { validateMetadata } from "@bosonprotocol/metadata"
import { getConfigFromConfigId } from "@bosonprotocol/common"
import { ethers } from "ethers"
import fs from "node:fs"
 
const CONFIG_ID = "staging-84532-0" as const
const ENV_NAME = "staging" as const
 
async function main() {
  const provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL)
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider)
 
  const sdk = CoreSDK.fromDefaultConfig({
    web3Lib: new EthersAdapter(provider, wallet),
    envName: ENV_NAME,
    configId: CONFIG_ID,
    metadataStorage: new IpfsMetadataStorage(validateMetadata, { url: process.env.IPFS_URL! }),
  })
 
  // 1. Create the seller (if not already created)
  let seller = (await sdk.getSellersByAddress(wallet.address))[0]
  if (!seller) {
    const tx = 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 receipt = await tx.wait()
    await sdk.waitForGraphNodeIndexing(receipt.blockNumber)
    seller = (await sdk.getSellersByAddress(wallet.address))[0]
  }
  console.log("seller.id =", seller.id)
 
  // 2. Build & pin metadata. There's no productV1MetadataFactoryconstruct
  //    the PRODUCT_V1 object directly per the schema.
  const uuid = crypto.randomUUID()
  const metadata = {
    schemaUrl: "https://schema.org/Product",
    type: "PRODUCT_V1" as const,
    uuid,
    name: "Test mug",
    description: "Stoneware, 12 oz.",
    externalUrl: `https://example.com/${uuid}`,
    licenseUrl: `https://example.com/${uuid}/license`,
    image: "https://example.com/mug.jpg",
    attributes: [
      { traitType: "Material", value: "Stoneware" },
      { traitType: "Size", value: "12 oz" },
    ],
    product: {
      uuid, version: 1,
      title: "Test mug", description: "Stoneware, 12 oz.",
      productionInformation_brandName: "Your brand",
      details_offerCategory: "PHYSICAL",
      visuals_images: [{ url: "https://example.com/mug.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 offerTx = await sdk.createOffer({
    price: "1000000",
    sellerDeposit: "0",
    buyerCancelPenalty: "0",
    quantityAvailable: "10",
    exchangeToken: USDC_ADDRESS,
    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: "5",                                // your chain's DR; look up via sdk.getDisputeResolvers()
    agentId: "0",
    feeLimit: ethers.constants.MaxUint256.toString(),     // accept any fee
  })
  const offerReceipt = await offerTx.wait()
  await sdk.waitForGraphNodeIndexing(offerReceipt.blockNumber)
  console.log("offer published.")
 
  // 4. Listen for VoucherRedeemed and "deliver". No sdk.getDiamondContract() —
  //    build the contract yourself using the relevant facet ABI.
  const exchangeAbi = JSON.parse(fs.readFileSync(
    "node_modules/@bosonprotocol/common/dist/cjs/abis/IBosonExchangeHandler.json", "utf8"
  ))
  const config = getConfigFromConfigId(CONFIG_ID)
  const diamond = new ethers.Contract(config.contracts.protocolDiamond, exchangeAbi, provider)
  diamond.on("VoucherRedeemed", async (offerId, exchangeId) => {
    console.log("redeemed:", exchangeId.toString())
    // (your delivery system here)
  })
 
  // 5. After the dispute window, complete & withdraw
  // (in production, schedule this as a periodic job)
  const redeemed = await sdk.getExchanges({
    where: { seller: seller.id, state: "REDEEMED" },
  })
  for (const ex of redeemed) {
    if (Date.now() > Number(ex.disputePeriodEnd) * 1000) {
      await (await sdk.completeExchange(ex.id)).wait()
    }
  }
 
  // getFunds takes a subgraph query-vars object, not a positional id.
  const funds = await sdk.getFunds({ fundsFilter: { accountId: seller.id } })
  await (await sdk.withdrawFunds(
    seller.id,
    funds.map(f => f.token.address),
    funds.map(f => f.availableAmount),
  )).wait()
}
 
main().catch(console.error)

Production hardening

  • Persist offer / exchange / withdrawal state to your own DB.
  • Use a managed signer (KMS, Vault, Fireblocks, Privy, Turnkey).
  • Wrap completeExchange calls in a periodic job + monitoring.
  • See Going to production.

Related