Store & Details Pages

Fetch and decode UTXO listings to browse files and complete secure purchases.

Read as Markdown

This page shows how to add browsing and purchasing functionality to your marketplace application. We'll be adding the functionality to:

Backend

  • Add a findStore query to return active listings
  • Add a findDetails query to return a single listing by fileUrl

Key Server

  • Configure the payment middleware for /purchase/:fileUrl
  • Implement the /purchase/:fileUrl route to update balances and return the decryption key

Frontend

  • Wire up a Store page that calls findStore and decodes UTXO fields
  • Wire up a Details page that calls and decodes findDetails, and submits a purchase to the key server

1. Backend: Store & Details Queries

1.1. Add findStore and findByName to MarketStorage

In backend/src/lookup-services/MarketStorage.ts, add these methods that check for and return active listings:

1// backend/src/lookup-services/MarketStorage.ts
2
3// … existing methods …
4
5/**
6 * Returns all active listings
7 */
8async findStore(): Promise<UTXOReference[]> {
9  return await this.records.find({ retentionPeriod: { $gte: Math.floor(Date.now()) } })
10    .project<UTXOReference>({ txid: 1, outputIndex: 1 })
11    .toArray()
12    .then(results => results.map(record => ({
13      txid: record.txid,
14      outputIndex: record.outputIndex
15    })))
16}
17
18/**
19 * Returns all listings by name
20 */
21async findByName(name: string): Promise<UTXOReference[]> {
22  return await this.records.find({
23    name: { $regex: name, $options: 'i' },
24    retentionPeriod: {
25      $gte: Math.floor(Date.now())
26    }
27  })
28    .project<UTXOReference>({
29      txid: 1,
30      outputIndex: 1
31    })
32    .toArray()
33    .then(results => results.map(record => ({
34      txid: record.txid,
35      outputIndex: record.outputIndex
36    })))
37}
Copied!

What’s happening:

  • For both of these methods, use a MongoDB $gte to compare the retentionPeriod of all of the stored UTXOs with the current time and return the txid and outputIndex of those listings.
  • findStore returns all of the active listings stored in the database, while findByName returns the listings with the requested name.

1.2. Add findDetails to MarketLookupService

In backend/src/lookup-services/MarketLookupServiceFactory.ts, add the helper functions after the class and update the lookup method to handle both store and details queries:

1// Query type checks
2
3function isNameSearchQuery(query: any): query is {
4  type: 'findByName', value: { name: string }
5} {
6  return (
7    typeof query === 'object' &&
8    query.type === 'findByName' &&
9    query.value &&
10    typeof query.value.name === 'string'
11  )
12}
13
14function isDetailsQuery(query: any): query is {
15  type: 'findDetails'; value: { txid: string, outputIndex: number }
16} {
17  return (
18    typeof query === 'object' &&
19    query.type === 'findDetails' &&
20    query.value &&
21    typeof query.value.txid === 'string' &&
22    typeof query.value.outputIndex === 'number'
23  )
24}
Copied!
1// backend/src/lookup-services/MarketLookupServiceFactory.ts
2export class MarketLookupService implements LookupService {
3  readonly admissionMode: AdmissionMode = 'locking-script'
4  readonly spendNotificationMode: SpendNotificationMode = 'none'
5
6  constructor(public storage: MarketStorage) { }
7
8  // … existing outputAdmittedByTopic and outputSpent …
9
10  async lookup(question: LookupQuestion): Promise<LookupAnswer | LookupFormula> {
11    // … existing lookup queries …
12    
13    // store → return all listings that are not expired
14    if (query === 'findStore') {
15      return await this.storage.findStore();
16    }
17
18    // store → return the listings that match the name and are not expired
19    if (isNameSearchQuery(query)) {
20      const { name } = query.value
21      return await this.storage.findByName(name)
22    }
23
24    // details → returns the txid and outputIndex that will be formatted into BEEF
25    if (isDetailsQuery(query)) {
26      const { txid, outputIndex } = query.value
27      console.log(txid, outputIndex)
28      return [{ txid, outputIndex }]
29    }
30
31    throw new Error('Unknown query type')
32  }
33
34  // … existing getDocumentation & getMetaData …
35}
Copied!

What’s happening:

  • findStore → when query.type === 'findStore', we return the array of UTXO references for active listings.
  • findDetails → when query.type === 'findDetails', we return the txid and outputIndex, which the lookupService will verify is an unspent token and return the output's BEEF.

2. Frontend: Store Page

2.1. Update App.tsx to point “/” to Store

In frontend/src/App.tsx, change the default route to render Store.tsx instead of the placeholder Home.tsx:

1// frontend/src/App.tsx
2import Store from './pages/Store'
3
4 function App() {
5   return (
6     <Layout>
7       <Routes>
8-        <Route path="/" element={<Home />} />
9+        <Route path="/" element={<Store />} />
10         <Route path="/upload" element={<UploadFile />} />
11         <Route path="/details/:fileUrl" element={<Details />} />
12         <Route path="/account" element={<Account />} />
13       </Routes>
14     </Layout>
15   )
16 }
Copied!

2.2. Implement decodeOutputs.ts

Implement frontend/src/utils/decodeOutputs.ts to decode fields from each UTXO:

1// frontend/src/utils/decodeOutputs.ts
2import { Transaction, PushDrop, Utils } from '@bsv/sdk'
3
4export interface DecodedOutput {
5  fileUrl?: string
6  name?: string
7  description?: string
8  satoshis?: number
9  creatorPublicKey?: string
10  size?: number
11  txid: string
12  outputIndex: number
13  retentionPeriod?: number
14  coverUrl?: string
15}
16
17type FieldKey = keyof DecodedOutput
18
19export async function decodeOutput(
20  beef: number[],
21  outputIndex: number,
22  fields: FieldKey[]
23): Promise<DecodedOutput> {
24  const decodedTx = Transaction.fromBEEF(beef)
25  const outputs = decodedTx.outputs[outputIndex]
26  const decoded = PushDrop.decode(outputs.lockingScript)
27  const data = decoded.fields
28
29  const record: DecodedOutput = {
30    txid: decodedTx.id('hex'),
31    outputIndex,
32  }
33
34  for (const key of fields) {
35    switch (key) {
36      case 'fileUrl':
37        record.fileUrl = Utils.toUTF8(Utils.toArray(data[0]))
38        break
39      case 'name':
40        record.name = Utils.toUTF8(Utils.toArray(data[1]))
41        break
42      case 'description':
43        record.description = Utils.toUTF8(Utils.toArray(data[2]))
44        break
45      case 'satoshis':
46        record.satoshis = parseInt(Utils.toUTF8(Utils.toArray(data[3])), 10)
47        break
48      case 'creatorPublicKey':
49        record.creatorPublicKey = Utils.toUTF8(Utils.toArray(data[4]))
50        break
51      case 'size':
52        record.size = parseInt(Utils.toUTF8(Utils.toArray(data[5])), 10)
53        break
54      case 'retentionPeriod':
55        record.retentionPeriod = parseInt(
56          Utils.toUTF8(Utils.toArray(data[6])),
57          10
58        )
59        break
60      case 'coverUrl':
61        record.coverUrl = Utils.toUTF8(Utils.toArray(data[7]))
62        break
63      case 'txid':
64      case 'outputIndex':
65      default:
66        break
67    }
68  }
69  return record
70}
71
72export async function decodeOutputs(
73  outputs: Array<{ beef: number[]; outputIndex: number }>,
74  fields: FieldKey[]
75): Promise<DecodedOutput[]> {
76  return Promise.all(
77    outputs.map(({ beef, outputIndex }) =>
78      decodeOutput(beef, outputIndex, fields)
79    )
80  )
81}
Copied!

What’s happening:

  • We loop over each UTXO’s beef and outputIndex, decode the PushDrop script, and then map the requested fields into a DecodedOutput object.
  • For the store page, we only need the { name, satoshis, coverUrl, retentionPeriod } fields and we can only request those to be extracted.

2.3. Add lookup query to Store.tsx

Implement the lookup and decoding of the output from the Overlay in the fetchFiles function in frontend/src/pages/Store.tsx:

1// frontend/src/pages/Store.tsx
2
3const fetchFiles = async (query?: string) => {
4  setLoading(true)
5  try {
6    let response = null
7    if (query && filter === 'name') {
8      response = await lookupResolver.query({
9        service: 'ls_demo_market',
10        query: {
11          type: 'findByName',
12          value: { name: searchTerm }
13        }
14      })
15    } else {
16      response = await lookupResolver.query({
17        service: 'ls_demo_market',
18        query: 'findStore'
19      })
20    }
21    if (response.type !== 'output-list') throw new Error('Lookup answer must be an output-list')
22
23    const fileData = await decodeOutputs(response.outputs, fields)
24    setFiles(fileData as StoreRecord[])
25  } catch (error) {
26    console.error('Error fetching files:', error)
27  } finally {
28    setLoading(false)
29  }
30}
Copied!

What’s happening:

  1. We create a lookupResolver query asking for the
  2. We decode each returned UTXO for the fields needed to render a card.
  3. The UI displays a grid of cards; each links to /details/{txid}.{outputIndex}.

3. Key Server: Purchase Route

3.1. Add payment middleware logic

Start by adding this query function to key-server/src/KeyStorage.ts that returns the details of a stored file:

1async findByQuery(
2  fileUrl: string
3): Promise<KeyRecord[]> {
4  try {
5    return await this.records
6      .find({ fileUrl })
7      .toArray();
8  } catch (error) {
9    console.error('Failed to find records by query:', error);
10    throw error;
11  }
12}
Copied!

Now in key-server/src/index.ts, we want to configure the payment middleware to process a transaction when the /purchase route is accessed:

1// key-server/src/index.ts
2import { createAuthMiddleware } from '@bsv/auth-express-middleware'
3import { createPaymentMiddleware } from '@bsv/payment-express-middleware'
4import { getWallet } from './utils/walletSingleton.js'
5
6// … after getWallet() …
7
8app.use(createAuthMiddleware({
9  wallet,
10  allowUnauthenticated: false,
11  logger: console,
12  logLevel: 'error'
13}))
14
15app.use(createPaymentMiddleware({
16  wallet,
17  calculateRequestPrice: async (req) => {
18    // only request a payment if the url includes /purchase
19    if (!req.url.includes('/purchase')) {
20      return 0
21    }
22    const { fileUrl } = (req.body as any) || {}
23    try {
24      if (!fileUrl) return 0
25      const record = await keyStorage.findByQuery(fileUrl)
26      if (!record || record.length != 1) {
27        return 0
28      }
29      console.log(record[0].satoshis)
30      return record[0].satoshis
31    } catch (e) {
32      return 0
33    }
34  }
35}))
Copied!

What’s happening:

  • All incoming requests pass through an auth check via AuthMiddleware, requiring AuthFetch and a connected BRC-100 wallet to access.
  • PaymentMiddleware is now configured to charge on /purchase/:fileUrl, parsing the fileUrl from the body and querying the key server database for the price of the file.
  • The PaymentMiddleware then charges the purchaser of the file with the cost and stores the satoshis on the key server.

3.2 Add balance collection to the database

In key-server/src/KeyStorage.ts, create a new MongoDB collection to store and to increment the file uploader's balance when anyone buys their files so that they can later withdraw and receive the satoshis:

1// key-server/src/types.ts
2
3export interface BalanceRecord {
4  publicKey: string
5  balance: number
6}
Copied!
1// key-server/src/KeyStorage.ts
2
3import dotenv from 'dotenv'
4import { Collection, Db } from 'mongodb'
5import { BalanceRecord, KeyRecord } from './types.js'
6
7dotenv.config()
8
9const KEY_COLLECTION_NAME = process.env.KEY_COLLECTION_NAME as string
10const BALANCE_COLLECTION_NAME = process.env.BALANCE_COLLECTION_NAME as string
11
12export class KeyStorage {
13  private readonly records: Collection<KeyRecord>
14  private readonly balances: Collection<BalanceRecord>
15
16  constructor(private readonly db: Db) {
17    this.records = db.collection<KeyRecord>(KEY_COLLECTION_NAME)
18    this.balances = db.collection<BalanceRecord>(BALANCE_COLLECTION_NAME)
19  }
20  // …
Copied!

Also add the incrementBalance method that increments the given public key's balance, creating a new entry in the collection if one does not already exist:

1async incrementBalance(
2  publicKey: string, amount: number
3): Promise<void> {
4  if (amount <= 0) return
5
6  await this.balances.updateOne(
7    { publicKey },
8    { $inc: { balance: amount } },
9    { upsert: true }
10  )
11}
Copied!

3.3. Implement /purchase

After the payment middleware processes the transaction and stores the purchaser's satoshis on the server, the /purchase/:fileUrl route will be accessed and will need

In key-server/src/index.ts:

1// key-server/src/index.ts (add after /submit)
2
3app.post('/purchase/:fileUrl', async (req: Request, res: Response): Promise<any> => {
4  try {
5    const { fileUrl } = req.body
6    if (!fileUrl) return res.status(400).json({ error: 'No fileUrl provided' })
7    
8    // lookup matching record
9    const record = await keyStorage.findByQuery(fileUrl)
10    if (!record || record.length != 1) {
11      return res.status(404).json({ error: 'File not found on key storage' })
12    }
13    
14    // increment uploader’s balance
15    const { satoshis, publicKey } = record[0]
16    await keyStorage.incrementBalance(publicKey, satoshis)
17
18    // return decryption key to purchaser
19    const encryptionKey = record[0].encryptionKey
20    return res.status(200).json({
21      success: true,
22      message: `Uploader balance updated. +${satoshis} sats for ${publicKey}`,
23      encryptionKey
24    })
25  } catch (error) {
26    console.error('Error processing purchase:', error)
27    return res.status(500).json({ error: (error as Error).message })
28  }
29})
Copied!

What’s happening:

  1. We read fileUrl from the URL parameter.
  2. We query files collection via findByQuery(fileUrl) (returns the matching record).
  3. We call incrementBalance(publicKey, satoshis) to credit the uploader.
  4. We return { encryptionKey } so the buyer can decrypt the STL file.

4. Frontend: Details Page

4.1. Add lookup query to Details.tsx

First we need to add the query and parse the information of the requisite file. We are getting the txid and outputIndex from the URL of the page:

1// frontend/src/pages/Details.tsx
2
3const fetchDetails = async () => {
4  try {
5    const response = await lookupResolver.query({
6      service: 'ls_demo_market',
7      query: {
8        type: 'findDetails',
9        value: {
10          txid,
11          outputIndex: parseInt(outputIndex || '0', 10)
12        }
13      }
14    })
15    if (response.type !== 'output-list') throw new Error('Lookup answer must be an output-list')
16
17    const fileData = await decodeOutputs(response.outputs, fields)
18    setDetails(fileData[0] as DetailsRecord)
19  } catch (error) {
20    throw new Error(`Error fetching details: ${error}`)
21  } finally {
22    setIsLoading(false)
23  }
24}
Copied!

Next, we want to implement the purchasing and decryption of the file:

1// frontend/src/pages/Details.tsx
2
3const handlePurchase = async () => {
4  try {
5    if (!details) return
6    setIsLoading(true)
7
8    const fileUrl = details.fileUrl
9    if (!fileUrl) {
10      throw new Error('No fileUrl available to purchase!')
11    }
12
13    console.log('File url:', fileUrl)
14
15    const wallet = new WalletClient('auto')
16    const authFetch = new AuthFetch(wallet)
17
18    const keyUrl = `${constants.keyServer}/purchase/${fileUrl}`
19
20    // hit the /purchase endpoint and trigger the payment middleware
21    let payResponse
22    try {
23      payResponse = await authFetch.fetch(
24        keyUrl,
25        {
26          method: 'POST',
27          body: JSON.stringify({ fileUrl }),
28          headers: {
29            'Content-Type': 'application/json'
30          }
31        }
32      )
33    } catch (error) {
34      throw new Error(`Failed to complete purchase: ${error}`)
35    }
36    if (!payResponse) throw new Error('Failed to complete purchase')
37
38    const purchaseResult = await payResponse.json()
39    const encryptionKey = purchaseResult.encryptionKey
40    if (!encryptionKey) {
41      if (!purchaseResult.description) {
42        throw new Error('Purchase was successful, but no encryptionKey was returned.')
43      }
44      else {
45        throw new Error(purchaseResult.description)
46      }
47    }
48
49    const storageDownloader = new StorageDownloader()
50    const { data: encryptedBytes, mimeType } = await storageDownloader.download(fileUrl)
51    console.log('Downloaded file from UHRP, mimeType:', mimeType)
52
53    // decrypting the file
54    const symmetricKey = new SymmetricKey(encryptionKey, 'hex')
55    const decryptedBytes = symmetricKey.decrypt(encryptedBytes) as number[] // test
56
57    const blob = new Blob(
58      [Uint8Array.from(decryptedBytes)],
59      { type: 'model/stl' })
60    const url = URL.createObjectURL(blob)
61    setDecryptedFileURL(url)
62  } catch (error) {
63    console.error('Error during purchase:', error)
64  } finally {
65    setIsLoading(false)
66  }
67}
Copied!

What’s happening:

  1. On purchase, call the key server’s /purchase/:fileUrl endpoint, which causes the payment middleware to facilitate the transaction.
  2. The key server then credits the file uploader’s balance and responds with the encryptionKey on success.
  3. The frontend then decrypts the file using the key and allows the purchaser to download the STL file.

5. Summary

With these changes in place, your marketplace now supports browsing active listings, viewing detailed file information, and securely purchasing and decrypting 3D-printable files.

1my-marketplace/
2├── backend/
3│   ├── src/
4│   │   ├── lookup-services/
5│   │   │   ├── MarketLookupServiceFactory.ts
6│   │   │   └── MarketStorage.ts
7│   │   ├── topic-managers/
8│   │   │   └── MarketTopicManager.ts
9│   │   ├── types.ts
10│   │   └── … other backend files …
11│   └── package.json
12├── frontend/
13│   ├── src/
14│   │   ├── pages/
15│   │   │   ├── Store.tsx
16│   │   │   └── Details.tsx
17│   │   ├── utils/
18│   │   │   ├── decodeOutputs.ts
19│   │   │   └── publishCommitment.ts
20│   │   ├── App.tsx
21│   │   └── … other frontend files …
22│   └── package.json
23├── key-server/
24│   ├── src/
25│   │   ├── index.ts       ← includes /purchase/:fileUrl
26│   │   ├── KeyStorage.ts
27│   │   ├── types.ts
28│   │   └── utils/
29│   │       └── decodeOutputs.ts
30│   └── package.json
31├── deployment-info.json
32└── package.json
Copied!

Backend:

  • findStore returns active listing references.
  • findDetails returns a reference for a given fileUrl.

Frontend:

  • Store.tsx displays active listings by decoding fields: { name, description, satoshis, coverUrl }.
  • Details.tsx shows listing details, decodes { fileUrl, name, description, satoshis, creatorPublicKey, size, retentionPeriod, coverUrl }, and calls /purchase/:fileUrl.

Key Server:

  • /purchase/:fileUrl credits the uploader’s balance and returns the encryptionKey to the buyer.

6. Verify in the UI

Once you’ve uploaded a file and completed the purchase flow, your Store and Details pages will render real data from your Overlay and Key Server.

Store Page

Store Page with Active Listings
Browse all active 3D-printable files in the marketplace

Details Page

Details Page with Purchase Button
View file metadata, purchase, and download the decrypted STL

With these changes, readers can browse all of the listed files, view the details, and purchase to receive the decrypted file to download!

Was this page helpful?