šŸ›’ Buyer Flow

Learn how buyers discover, purchase, and download datasets using zkLogin authentication and blockchain payments.

Overview

The buyer journey consists of four main phases:

  • Browse: Discover datasets on the marketplace
  • Purchase: Buy dataset with zkLogin transaction
  • Download: Access encrypted data securely
  • Review: Rate and review the dataset

Complete Purchase Flow

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ Buyer   │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜
     │ 1. Browse marketplace
     │ 2. Click "Purchase"
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  zkLogin Hook    │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
     │ 3. Build transaction (PTB)
     │ 4. Sign with zkProof
     │ 5. Execute on SUI
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  SUI Blockchain  │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
     │ 6. Purchase created on-chain
     │ 7. Escrow locks funds
     │ 8. Returns tx_digest
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Backend API     │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
     │ 9. Verify transaction
     │ 10. Record purchase
     │ 11. Queue fulfillment job
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Job Worker      │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
     │ 12. Download from Walrus
     │ 13. Decrypt data
     │ 14. Re-encrypt for buyer
     │ 15. Upload to Walrus
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  WebSocket       │
ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
     │ 16. Notify buyer "Ready to download"
     ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ Buyer   │ Downloads decrypted file
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Phase 1: Browse Marketplace

Marketplace Page

'use client';

import { useState, useEffect } from 'react';
import { DataPodCard } from '@/components/DataPodCard';

export default function MarketplacePage() {
  const [datapods, setDatapods] = useState([]);
  const [filters, setFilters] = useState({
    category: '',
    minPrice: 0,
    maxPrice: 1000,
    search: ''
  });

  useEffect(() => {
    fetchDatapods();
  }, [filters]);

  const fetchDatapods = async () => {
    const query = new URLSearchParams(filters).toString();
    const response = await fetch(`/api/marketplace/datapods?${query}`);
    const data = await response.json();
    setDatapods(data.datapods);
  };

  return (
    <div>
      <h1>Marketplace</h1>
      
      {/* Filters */}
      <div>
        <input 
          placeholder="Search datasets..."
          value={filters.search}
          onChange={(e) => setFilters({...filters, search: e.target.value})}
        />
        
        <select 
          value={filters.category}
          onChange={(e) => setFilters({...filters, category: e.target.value})}
        >
          <option value="">All Categories</option>
          <option value="finance">Finance</option>
          <option value="healthcare">Healthcare</option>
          <option value="ecommerce">E-commerce</option>
        </select>
      </div>

      {/* DataPod Grid */}
      <div className="grid grid-cols-3 gap-6">
        {datapods.map(datapod => (
          <DataPodCard key={datapod.id} datapod={datapod} />
        ))}
      </div>
    </div>
  );
}

DataPod Detail Page

export default function DataPodPage({ params }: { params: { id: string } }) {
  const [datapod, setDatapod] = useState(null);
  const [reviews, setReviews] = useState([]);

  useEffect(() => {
    // Fetch DataPod details
    fetch(`/api/marketplace/datapods/${params.id}`)
      .then(res => res.json())
      .then(data => setDatapod(data));

    // Fetch reviews
    fetch(`/api/review/datapod/${params.id}`)
      .then(res => res.json())
      .then(data => setReviews(data.reviews));
  }, [params.id]);

  if (!datapod) return <div>Loading...</div>;

  return (
    <div>
      <h1>{datapod.title}</h1>
      <p>{datapod.description}</p>

      {/* Metadata */}
      <div>
        <p>Price: {datapod.price_sui} SUI</p>
        <p>Category: {datapod.category}</p>
        <p>Size: {formatBytes(datapod.file_size)}</p>
        <p>Seller: {datapod.seller.name}</p>
        <p>Rating: {datapod.average_rating || 'No ratings yet'}</p>
      </div>

      {/* Sample Data */}
      <div>
        <h2>Sample Data</h2>
        <table>
          {/* Render sample_data */}
        </table>
      </div>

      {/* Purchase Button */}
      <PurchaseModal datapod={datapod} />

      {/* Reviews */}
      <div>
        <h2>Reviews ({reviews.length})</h2>
        {reviews.map(review => (
          <div key={review.id}>
            <p>ā˜… {review.rating}/5</p>
            <p>{review.comment}</p>
            <p>By {review.buyer.name}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Phase 2: Purchase with zkLogin

Purchase Modal Component

'use client';

import { useState } from 'react';
import { useZKLogin } from '@/hooks/useZKLogin';

export function PurchaseModal({ datapod }) {
  const [isOpen, setIsOpen] = useState(false);
  const [isPurchasing, setIsPurchasing] = useState(false);
  const { executePurchaseTransaction } = useZKLogin();

  const handlePurchase = async () => {
    setIsPurchasing(true);

    try {
      // 1. Execute blockchain transaction
      const txResult = await executePurchaseTransaction({
        datapodId: datapod.id,
        priceSui: datapod.price_sui,
        sellerAddress: datapod.seller.sui_address
      });

      console.log('Transaction digest:', txResult.digest);

      // 2. Verify and record purchase on backend
      const response = await fetch('/api/buyer/purchase', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${localStorage.getItem('token')}`
        },
        body: JSON.stringify({
          datapod_id: datapod.id,
          payment_tx_digest: txResult.digest
        })
      });

      const { purchase } = await response.json();

      // 3. Navigate to buyer dashboard
      router.push('/buyer');
      
      toast.success('Purchase successful! Processing your data...');
    } catch (error) {
      console.error('Purchase failed:', error);
      toast.error('Purchase failed: ' + error.message);
    } finally {
      setIsPurchasing(false);
    }
  };

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        Purchase for {datapod.price_sui} SUI
      </button>

      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2>Confirm Purchase</h2>
        <p>Dataset: {datapod.title}</p>
        <p>Price: {datapod.price_sui} SUI</p>
        
        <button onClick={handlePurchase} disabled={isPurchasing}>
          {isPurchasing ? 'Processing...' : 'Confirm Purchase'}
        </button>
      </Modal>
    </>
  );
}

zkLogin Hook (useZKLogin)

import { Transaction } from '@mysten/sui/transactions';
import { SuiClient } from '@mysten/sui/client';

export function useZKLogin() {
  const executePurchaseTransaction = async ({
    datapodId,
    priceSui,
    sellerAddress
  }) => {
    // 1. Get ephemeral keypair and zkProof
    const ephemeralKeyPair = getStoredEphemeralKey();
    const zkProof = getStoredZKProof();
    
    if (!ephemeralKeyPair || !zkProof) {
      throw new Error('Not authenticated. Please login again.');
    }

    // 2. Create transaction block
    const tx = new Transaction();
    
    // Convert SUI to MIST (1 SUI = 1,000,000,000 MIST)
    const priceInMist = Math.floor(priceSui * 1_000_000_000);
    
    // 3. Split coins for payment
    const [paymentCoin] = tx.splitCoins(tx.gas, [
      tx.pure.u64(priceInMist)
    ]);
    
    // 4. Call purchase contract
    tx.moveCall({
      target: `${PACKAGE_ID}::purchase::create_purchase`,
      arguments: [
        tx.pure.address(datapodId),
        tx.pure.address(sellerAddress),
        paymentCoin,
        tx.pure.u64(priceInMist)
      ]
    });
    
    // 5. Create escrow
    tx.moveCall({
      target: `${PACKAGE_ID}::escrow::create_escrow`,
      arguments: [
        tx.object(PURCHASE_REGISTRY),
        paymentCoin,
        tx.pure.address(sellerAddress)
      ]
    });
    
    // 6. Set sender
    const userAddress = getUserSuiAddress();
    tx.setSender(userAddress);
    
    // 7. Sign with zkLogin
    const suiClient = new SuiClient({ url: SUI_RPC_URL });
    
    const { bytes, signature: userSignature } = await tx.sign({
      client: suiClient,
      signer: ephemeralKeyPair
    });
    
    // 8. Create zkLogin signature
    const zkLoginSignature = getZkLoginSignature({
      inputs: zkProof,
      maxEpoch: zkProof.maxEpoch,
      userSignature
    });
    
    // 9. Execute transaction
    const result = await suiClient.executeTransactionBlock({
      transactionBlock: bytes,
      signature: zkLoginSignature,
      options: {
        showEffects: true,
        showObjectChanges: true
      }
    });
    
    if (result.effects.status.status !== 'success') {
      throw new Error('Transaction failed on-chain');
    }
    
    return result;
  };

  return { executePurchaseTransaction };
}

Backend: Verify Purchase

async function createPurchase(req, res) {
  const { datapod_id, payment_tx_digest } = req.body;
  const buyerId = req.user.id;

  // 1. Validate DataPod exists
  const datapod = await DataPod.findByPk(datapod_id);
  if (!datapod || !datapod.published) {
    return res.status(404).json({ error: 'DataPod not found' });
  }

  // 2. Verify transaction on-chain
  const txData = await BlockchainService.getTransaction(payment_tx_digest);
  
  if (!txData) {
    return res.status(400).json({ error: 'Transaction not found' });
  }

  if (txData.effects.status.status !== 'success') {
    return res.status(400).json({ error: 'Transaction failed' });
  }

  // 3. Verify payment amount
  const paidAmount = extractPaymentAmount(txData);
  const expectedAmount = datapod.price_sui * 1e9; // Convert to MIST
  
  if (paidAmount < expectedAmount) {
    return res.status(400).json({ error: 'Insufficient payment' });
  }

  // 4. Verify sender
  if (txData.sender !== req.user.sui_address) {
    return res.status(403).json({ error: 'Address mismatch' });
  }

  // 5. Check for duplicate
  let purchase = await Purchase.findOne({
    where: { payment_tx_digest }
  });

  if (purchase) {
    return res.json({ purchase }); // Idempotent
  }

  // 6. Create purchase record
  purchase = await Purchase.create({
    buyer_id: buyerId,
    datapod_id,
    payment_tx_digest,
    amount_sui: datapod.price_sui,
    status: 'processing'
  });

  // 7. Update DataPod purchase count
  await datapod.increment('purchase_count');

  // 8. Queue fulfillment job
  await fulfillmentQueue.add('fulfill-purchase', {
    purchaseId: purchase.id
  });

  res.json({ purchase });
}

Phase 3: Data Fulfillment (Background)

Fulfillment Job Worker

import { Worker } from 'bullmq';
import { WalrusService } from '../services/walrus.service';
import crypto from 'crypto';

const worker = new Worker('fulfill-purchase', async (job) => {
  const { purchaseId } = job.data;

  // 1. Get purchase and datapod
  const purchase = await Purchase.findByPk(purchaseId, {
    include: [{ model: DataPod }]
  });

  const datapod = purchase.datapod;

  // 2. Download encrypted file from Walrus
  const encryptedBlob = await WalrusService.downloadFromWalrus(
    datapod.walrus_blob_id
  );

  // 3. Get original encryption key
  const encryptionKey = await EncryptionKeyStore.get(datapod.id);
  const iv = Buffer.from(datapod.encryption_iv, 'hex');

  // 4. Decrypt data
  const decipher = crypto.createDecipheriv('aes-256-cbc', encryptionKey, iv);
  const decryptedData = Buffer.concat([
    decipher.update(encryptedBlob),
    decipher.final()
  ]);

  // 5. Generate new encryption key for buyer
  const buyerKey = crypto.randomBytes(32);
  const buyerIv = crypto.randomBytes(16);

  // 6. Re-encrypt for buyer
  const cipher = crypto.createCipheriv('aes-256-cbc', buyerKey, buyerIv);
  const reencryptedData = Buffer.concat([
    cipher.update(decryptedData),
    cipher.final()
  ]);

  // 7. Upload to Walrus
  const walrusResult = await WalrusService.uploadToWalrus(
    reencryptedData,
    'purchases'
  );

  // 8. Store encrypted key for buyer (encrypt with buyer's public key if available)
  const encryptedKey = buyerKey.toString('base64');

  // 9. Update purchase record
  await purchase.update({
    encrypted_blob_id: walrusResult.cid,
    decryption_key: encryptedKey,
    decryption_iv: buyerIv.toString('hex'),
    status: 'completed',
    completed_at: new Date()
  });

  // 10. Notify buyer via WebSocket
  io.to(`user:${purchase.buyer_id}`).emit('purchase.completed', {
    purchaseId: purchase.id,
    datapodTitle: datapod.title
  });
});

Phase 4: Download Data

Download Button

function DownloadButton({ purchase }) {
  const handleDownload = async () => {
    try {
      // 1. Request download URL
      const response = await fetch(`/api/buyer/download/${purchase.id}`, {
        headers: {
          Authorization: `Bearer ${localStorage.getItem('token')}`
        }
      });

      const { downloadUrl, filename } = await response.json();

      // 2. Download file
      const fileResponse = await fetch(downloadUrl);
      const blob = await fileResponse.blob();

      // 3. Trigger browser download
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);

      toast.success('Download started!');
    } catch (error) {
      console.error('Download failed:', error);
      toast.error('Download failed');
    }
  };

  return (
    <button 
      onClick={handleDownload}
      disabled={purchase.status !== 'completed'}
    >
      {purchase.status === 'completed' ? 'Download' : 'Processing...'}
    </button>
  );
}

Backend: Generate Download URL

async function getDownloadUrl(req, res) {
  const { purchaseId } = req.params;
  const userId = req.user.id;

  // 1. Verify ownership
  const purchase = await Purchase.findOne({
    where: { id: purchaseId, buyer_id: userId },
    include: [{ model: DataPod }]
  });

  if (!purchase) {
    return res.status(404).json({ error: 'Purchase not found' });
  }

  if (purchase.status !== 'completed') {
    return res.status(400).json({ error: 'Purchase not ready' });
  }

  // 2. Generate signed download token
  const downloadToken = jwt.sign(
    { purchaseId, userId },
    process.env.DOWNLOAD_SECRET,
    { expiresIn: '1h' }
  );

  // 3. Create download URL
  const downloadUrl = `${process.env.API_URL}/download/${downloadToken}`;

  res.json({
    downloadUrl,
    filename: `${purchase.datapod.title}.csv`,
    expiresIn: 3600
  });
}

async function downloadFile(req, res) {
  const { token } = req.params;

  // 1. Verify token
  const decoded = jwt.verify(token, process.env.DOWNLOAD_SECRET);
  
  // 2. Get purchase
  const purchase = await Purchase.findByPk(decoded.purchaseId, {
    include: [{ model: DataPod }]
  });

  // 3. Fetch from Walrus
  const encryptedBlob = await WalrusService.downloadFromWalrus(
    purchase.encrypted_blob_id
  );

  // 4. Decrypt
  const key = Buffer.from(purchase.decryption_key, 'base64');
  const iv = Buffer.from(purchase.decryption_iv, 'hex');
  
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  const data = Buffer.concat([
    decipher.update(encryptedBlob),
    decipher.final()
  ]);

  // 5. Stream to client
  res.setHeader('Content-Type', purchase.datapod.file_type);
  res.setHeader('Content-Disposition', `attachment; filename="${purchase.datapod.title}"`);
  res.send(data);
}

Phase 5: Submit Review

async function submitReview(purchaseId, rating, comment) {
  const response = await fetch(`/api/buyer/purchase/${purchaseId}/review`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${localStorage.getItem('token')}`
    },
    body: JSON.stringify({ rating, comment })
  });

  const { review } = await response.json();
  return review;
}

šŸŽ‰ Purchase Complete!

Now you understand the complete buyer flow. Check out the Smart Contracts to see how purchases are handled on-chain, or explore the API Reference for all endpoints.