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-endpointThe 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 productV1MetadataFactory — construct
// 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
completeExchangecalls in a periodic job + monitoring. - See Going to production.