Account Page
Fetch balances, withdraw earnings, and remove spent upload tokens on-chain.
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}What’s happening:
findByUploaderPublicKeyuses the{ $gte: now }comparison to return only unexpired UTXO references.findByUploaderPublicKeyExpireduses{ $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}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}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}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:
POST /balance– return the authenticated user’s current balancePOST /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}What’s happening:
getBalancereads the user's current satoshi total, defaulting to0if there is no existing entry.setBalancesets 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})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})What’s happening:
- Both routes read
req.auth.identityKey, injected byAuthMiddleware. /balancesimply returns the current total./withdrawresets 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:
fetchData– call/balance,findByUploaderPublicKey, andfindByUploaderPublicKeyExpiredhandleWithdraw– POST/withdrawand update local statehandleRemoveFile– 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}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}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}What’s happening:
fetchDataloads your balance and your active and expired uploads.handleWithdrawcalls/withdrawto receive the satoshis that were stored on the key server and redeems them into your own personal wallet.handleRemoveFilespends 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.jsonNow users can see their own uploads, withdraw earnings, and remove tokens, implementing a complete Account experience.

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