Account Page

Fetch balances, withdraw earnings, and remove spent upload tokens on-chain.

Read as Markdown

This page shows how to add "Account" functionality so that an uploader can:

  • View and withdraw their accumulated satoshis balance
  • See a list of all files they’ve uploaded, both active and expired
  • Remove and spend any of their uploaded file tokens

1. Backend: Uploader Queries

1.1. Add findByUploaderPublicKey and findByUploaderPublicKeyExpired

In backend/src/lookup-services/MarketStorage.ts, add methods that filter records by publicKey and by whether retentionPeriod is before or after now:

1// backend/src/lookup-services/MarketStorage.ts
2
3// … existing methods …
4
5/**
6 * Returns all active (not yet expired) files uploaded by a given publicKey.
7 */
8async findByUploaderPublicKey(
9  publicKey: string
10): Promise<UTXOReference[]> {
11  return await this.records
12    .find({
13      creatorPublicKey: publicKey,
14      retentionPeriod: { $gte: Math.floor(Date.now()) }
15    })
16    .project<UTXOReference>({ txid: 1, outputIndex: 1 })
17    .toArray()
18    .then(results =>
19      results.map(rec => ({
20        txid: rec.txid,
21        outputIndex: rec.outputIndex
22      }))
23    )
24}
25
26/**
27 * Returns all expired files uploaded by a given publicKey.
28 */
29async findByUploaderPublicKeyExpired(
30  publicKey: string
31): Promise<UTXOReference[]> {
32  return await this.records
33    .find({
34      creatorPublicKey: publicKey,
35      retentionPeriod: { $lt: Math.floor(Date.now()) }
36    })
37    .project<UTXOReference>({ txid: 1, outputIndex: 1 })
38    .toArray()
39    .then(results =>
40      results.map(rec => ({
41      txid: rec.txid,
42      outputIndex: rec.outputIndex
43      }))
44    )
45}
Copied!

What’s happening:

  • findByUploaderPublicKey uses the { $gte: now } comparison to return only unexpired UTXO references.
  • findByUploaderPublicKeyExpired uses { $lt: now } to return only expired ones.
  • Both return UTXOReference[] so that the lookup service can format responses as BEEF.

1.2. Add uploader‐specific queries to MarketLookupService

Now extend the lookup method to work with two new query types. Add these helpers with the others after the class:

1// backend/src/lookup-services/MarketLookupServiceFactory.ts
2
3function isUploaderFilesQuery(query: any): query is {
4  type: 'findUploaderFiles'; value: { publicKey: string }
5} {
6  return (
7    typeof query === 'object' &&
8    query.type === 'findUploaderFiles' &&
9    query.value &&
10    typeof query.value.publicKey === 'string'
11  )
12}
13
14function isUploaderFilesQueryExpired(query: any): query is {
15  type: 'findUploaderFilesExpired'; value: { publicKey: string }
16} {
17  return (
18    typeof query === 'object' &&
19    query.type === 'findUploaderFilesExpired' &&
20    query.value &&
21    typeof query.value.publicKey === 'string'
22  )
23}
Copied!

And insert these checks alongside the existing queries:

1async lookup(question: LookupQuestion): Promise<LookupAnswer | LookupFormula> {
2  // … other lookup cases …
3
4  // uploader → return all active uploads for this publicKey
5  if (isUploaderFilesQuery(query)) {
6    const { publicKey } = query.value
7    return await this.storage.findByUploaderPublicKey(publicKey)
8  }
9
10  // uploaderExpired → return all expired uploads for this publicKey
11  if (isUploaderFilesQueryExpired(query)) {
12    const { publicKey } = query.value
13    return await this.storage.findByUploaderPublicKeyExpired(publicKey)
14  }
15
16  throw new Error('Unknown query type')
17}
Copied!

1.3. Implement outputSpent

The outputSpent method is called when a transaction that is being signed and redeemed is being broadcasted, making the UTXO on the lookup service obsolete and to be removed:

1async outputSpent(payload: OutputSpent): Promise<void> {
2  if (payload.mode !== 'none') throw new Error('Invalid mode')
3  const { topic, txid, outputIndex } = payload
4  if (topic !== 'tm_demo_market') throw new Error(`Invalid topic "${topic}" for this service.`)
5  await this.storage.deleteRecord(txid, outputIndex)
6}
Copied!

Note that there is also the outputEvicted method, which can be invoked by the administrator of the Overlay via the LARS and CARS CLI menu. This method is used to remove outputs stored on the overlay that are in breach of the terms and conditions or are violations of the law.


2. Key Server: Balance & Withdraw

We’ll add two new routes:

  1. POST /balance – return the authenticated user’s current balance
  2. POST /withdraw – creates and returns a transaction, spending the satoshis stored on the key server and allowing the frontend to reallocate those satoshis to the withdrawer's wallet

2.1. Extend KeyStorage with getBalance and setBalance

In key-server/src/KeyStorage.ts, add these two methods:

1// key-server/src/KeyStorage.ts
2
3export class KeyStorage {
4  private readonly balances: Collection<BalanceRecord>
5  // … existing constructor, records collection, etc …
6
7  /**
8   * Return the caller’s current balance.
9   */
10  async getBalance(publicKey: string): Promise<number> {
11    const res = await this.balances.findOne({ publicKey })
12    return res?.balance || 0
13  }
14
15  /**
16   * Sets the publicKey's balance to a new value.
17   */
18  async setBalance(publicKey: string, newBalance: number): Promise<void> {
19    await this.balances.updateOne(
20      { publicKey },
21      { $set: { balance: newBalance } },
22      { upsert: true }
23    )
24  }
25}
Copied!

What’s happening:

  • getBalance reads the user's current satoshi total, defaulting to 0 if there is no existing entry.
  • setBalance sets the user's satoshi count to match the newBalance.

2.2. Expose /balance and /withdraw

Next, register two new Express endpoints. Both expect a publicKey in the JSON body, which must exactly match the authenticated identityKey injected by AuthMiddleware.

1// key-server/src/index.ts
2
3/**
4 * POST /balance
5 * Returns the caller’s current balance.
6 */
7app.post('/balance', async (req: Request, res: Response): Promise<any> => {
8  try {
9    const { publicKey } = req.body
10    if (!publicKey  ||  publicKey  !== (req  as  any).auth?.identityKey)
11      return res.status(400).json({ error: 'Missing publicKey in body' })
12
13    // retrieve balance from storage
14    const balance = await keyStorage.getBalance(publicKey)
15    console.log('User Balance:', balance)
16    return res.status(200).json({
17      success: true,
18      balance
19    })
20  } catch (error) {
21    console.error('Error retrieving balance:', error)
22  }
23})
Copied!
1/**
2 * POST /withdraw
3 * send satoshis stored on the server to the uploader’s own wallet.
4 */
5app.post('/withdraw', async (req, res): Promise<any> => {
6  try {
7    const { publicKey } = req.body
8    if (!publicKey || publicKey !== (req as any).auth?.identityKey)
9      return res.status(400).json({ error: 'Missing publicKey in body' })
10
11    // get the public identity key of the server
12    const senderIdentityKey = PrivateKey.fromHex(SERVER_PRIVATE_KEY).toPublicKey().toString()
13
14    const derivationPrefix = randomBytes(10).toString('base64');
15    const derivationSuffix = randomBytes(10).toString('base64');
16
17    const { publicKey: derivedPublicKey } = await wallet.getPublicKey({
18      protocolID: [2, '3241645161d8'],
19      keyID: `${derivationPrefix} ${derivationSuffix}`,
20      counterparty: publicKey
21    })
22
23    // get the stored balance of the withdrawer
24    const amount = await keyStorage.getBalance(publicKey)
25    if (amount <= 0) {
26      return res.status(400).json({ error: 'No balance to withdraw' })
27    }
28
29    const lockingScript = new P2PKH().lock(
30      PublicKey.fromString(derivedPublicKey)
31        .toAddress())
32      .toHex()
33
34    // create an on-chain payment to the withdrawer's own identityKey
35    const { tx } = await wallet.createAction({
36      description: `Market withdrawal from user: ${wallet.getPublicKey.toString()}`,
37      outputs: [{
38        satoshis: amount,
39        lockingScript,
40        customInstructions: JSON.stringify({
41          derivationPrefix,
42          derivationSuffix,
43          payee: senderIdentityKey
44        }),
45        outputDescription: 'Market account withdraw'
46      }],
47      options: {
48        randomizeOutputs: false
49      }
50    })
51    if (!tx) {
52      throw new Error('Error creating action')
53    }
54
55    // set the withdrawer's balance to 0
56    await keyStorage.setBalance(publicKey, 0)
57
58    // return the information required for the frontend to redeem the token
59    return res.status(200).json({
60      message: 'Withdraw partial tx created',
61      transaction: Utils.toArray(tx, 'base64'),
62      derivationPrefix,
63      derivationSuffix,
64      amount,
65      senderIdentityKey
66    })
67  } catch (error) {
68    console.error('Error withdrawing balance:', error)
69  }
70})
Copied!

What’s happening:

  1. Both routes read req.auth.identityKey, injected by AuthMiddleware.
  2. /balance simply returns the current total.
  3. /withdraw resets the balance on-server and uses your server’s funded wallet to send those satoshis back to the uploader’s own BSV identity key for them to redeem.

3. Frontend: Account Page Hooks

On frontend/src/pages/Account.tsx, you only need to wire up three functions:

  1. fetchData – call /balance, findByUploaderPublicKey, and findByUploaderPublicKeyExpired
  2. handleWithdraw – POST /withdraw and update local state
  3. handleRemoveFile – spend the token on-chain and broadcast that spend
1// frontend/src/pages/Account.tsx
2// … inside your component, after setting up state, wallet, authFetch, lookupResolver …
3
4// 1. Fetch balance & file lists
5const fetchData = async () => {
6  setLoading(true)
7  try {
8    // get the balance from the key server
9    let publicKey = (await wallet.getPublicKey({ identityKey: true }))
10    const response = await authFetch.fetch(`${constants.keyServer}/balance`, {
11      method: 'POST',
12      body: JSON.stringify(publicKey),
13      headers: {
14        'Content-Type': 'application/json'
15      }
16    })
17    if (!response) {
18      throw new Error('Error fetching account balance')
19    }
20    const { balance } = await response.json()
21
22    setBalance(balance || 0)
23
24    // get the unexpired files from the overlay
25    const filesResponse = await lookupResolver.query({
26      service: 'ls_demo_market',
27      query: {
28        type: 'findUploaderFiles',
29        value: {
30          publicKey: publicKey.publicKey.toString()
31        }
32      }
33    })
34    if (filesResponse.type !== 'output-list') 
35      throw new Error('Lookup answer must be a output-list')
36
37    // get the expired files from the overlay
38    const expiredResponse = await lookupResolver.query({
39      service: 'ls_demo_market',
40      query: {
41        type: 'findUploaderFilesExpired',
42        value: {
43          publicKey: publicKey.publicKey.toString()
44        }
45      }
46    })
47    if (expiredResponse.type !== 'output-list')
48      throw new Error('Lookup answer must be a output-list')
49
50    // decode the overlay outputs
51    const filesData = await decodeOutputs(filesResponse.outputs, fields)
52    const expiredData = await decodeOutputs(expiredResponse.outputs, expiredFields)
53    
54    setFiles(filesData as UploadedFile[])
55    setExpiredFiles(expiredData as ExpiredFile[])
56  } catch (err) {
57    console.error('Error fetching account data:', err)
58  } finally {
59    setLoading(false)
60  }
61}
Copied!
1// 2. Withdraw earnings
2const handleWithdraw = async () => {
3  setLoading(true)
4  try {
5    // hit the /withdraw endpoint to facilitate the transaction
6    const publicKey = await wallet.getPublicKey({ identityKey: true })
7    const response = await authFetch.fetch(`${constants.keyServer}/withdraw`, {
8      method: 'POST',
9      body: JSON.stringify(publicKey),
10      headers: {
11        'Content-Type': 'application/json'
12      }
13    })
14    if (!response.ok) {
15      console.error('Error parsing balance result')
16      return
17    }
18  
19    // redeem the transaction and 
20    const paymentData = await response.json() as WithdrawResponse
21    const processedTx = await wallet.internalizeAction({
22      tx: Utils.toArray(paymentData.transaction, 'base64') as AtomicBEEF,
23      outputs: [{
24        paymentRemittance: {
25          derivationPrefix: paymentData.derivationPrefix,
26          derivationSuffix: paymentData.derivationSuffix,
27          senderIdentityKey: paymentData.senderIdentityKey
28        },
29        outputIndex: 0,
30        protocol: 'wallet payment'
31      }],
32      description: `Withdraw from Marketplace`
33    })
34
35    console.log('Withdraw successful!', processedTx)
36
37    setBalance(0)
38  } catch (err) {
39    console.error('Error when withdrawing:', err)
40  } finally {
41    setLoading(false)
42  }
43}
Copied!
1// 3. Remove (spend) a file token
2const handleRemoveFile = async (txid: string, outputIndex: number) => {
3  setLoading(true)
4  try {
5    // check that the requested file is stored on the overlay and on-chain
6    const response = await lookupResolver.query({
7      service: 'ls_demo_market',
8      query: {
9        type: 'findDetails',
10        value: {
11          txid,
12          outputIndex: outputIndex
13        }
14      }
15    })
16    if (response.type !== 'output-list') throw new Error('Lookup answer must be an output-list')
17    if (response.outputs.length != 1) throw new Error('Must be a single existing UTXO')
18
19    // process the inputs of the transaction to be redeemed
20    const inputBEEF = response.outputs[0].beef
21    const outpoint = `${txid}.${outputIndex}`
22
23    const { signableTransaction } = await wallet.createAction({
24      inputBEEF,
25      inputs: [{
26        outpoint,
27        unlockingScriptLength: 74, // PushDrop scripts tend to be around 74
28        inputDescription: 'Marketplace upload token',
29      }],
30      description: 'Spending token from marketplace'
31    })
32    if (!signableTransaction) throw new Error('Token must be spendable')
33
34    // sign the action and redeem the tokens that were stored on the key server
35    const unlocker = pushdrop.unlock([2, 'marketplace'], '1', 'anyone', undefined, true)
36    const partialTx = Transaction.fromAtomicBEEF(signableTransaction.tx)
37    const unlockingScript = await unlocker.sign(partialTx, 0)
38
39    const action = await wallet.signAction({
40      reference: signableTransaction.reference,
41      spends: {
42        0: {
43          unlockingScript: unlockingScript.toHex()
44        }
45      }
46    });
47
48    console.log(action)
49
50    if (!action) throw new Error('Spend token failed.')
51
52    // Removing the file from the backend database
53    const broadcaster = new TopicBroadcaster(['tm_demo_market'], {
54      networkPreset: window.location.hostname === 'localhost' ? 'local' : 'mainnet'
55    })
56    const spend = await broadcaster.broadcast(Transaction.fromAtomicBEEF(action.tx as number[]))
57    console.log('Token Spent:', spend)
58
59    setFiles(prev => prev.filter(f => !(f.txid === txid && f.outputIndex === outputIndex)))
60    setExpiredFiles(prev => prev.filter(f => !(f.txid === txid && f.outputIndex === outputIndex)))
61  } catch (err) {
62    console.error('Delete file error:', err)
63  } finally {
64    setLoading(false)
65  }
66}
Copied!

What’s happening:

  • fetchData loads your balance and your active and expired uploads.
  • handleWithdraw calls /withdraw to receive the satoshis that were stored on the key server and redeems them into your own personal wallet.
  • handleRemoveFile spends the PushDrop token so the lookup service removes your file and the token is no longer unspent.

4. Summary

With these additions, the Account page now allows an authenticated uploader to:

  • View & withdraw their accumulated balance from users purchasing their files
  • See their listings: both active and expired listings
  • Remove spend any of their file tokens, broadcasting a spend transaction so the lookup service deletes it

The directory tree should now resemble:

1my-marketplace/
2├── backend/
3│   ├── src/
4│   │   ├── lookup-services/
5│   │   │   ├── MarketLookupServiceFactory.ts  ← add uploader queries
6│   │   │   └── MarketStorage.ts               ← add methods to facilitate the queries
7│   │   ├── topic-managers/
8│   │   │   └── MarketTopicManager.ts
9│   │   ├── types.ts
10│   │   └── … other backend files …
11│   └── package.json
12├── frontend/
13│   ├── src/
14│   │   ├── pages/
15│   │   │   └── Account.tsx     ← fetchData, handleWithdraw, handleRemoveFile
16│   │   ├── utils/
17│   │   │   └── decodeOutputs.ts
18│   │   ├── App.tsx
19│   │   └── … other frontend files …
20│   └── package.json
21├── key-server/
22│   ├── src/
23│   │   ├── index.ts       ← add /balance and /withdraw handlers
24│   │   ├── KeyStorage.ts  ← add getAndResetBalance, getBalance
25│   │   └── types.ts
26│   └── package.json
27├── deployment-info.json
28└── package.json
Copied!

Now users can see their own uploads, withdraw earnings, and remove tokens, implementing a complete Account experience.

Upload Files Page Success

Next up: we’ll cover the Next Steps of this project, including the steps to deploy, and optional features like search filters!

Was this page helpful?