Source Code

;; BitPay Marketplace Contract
;; Enables buying and selling of obligation NFTs for invoice factoring

;; Constants
(define-constant contract-owner tx-sender)
(define-constant err-not-authorized (err u401))
(define-constant err-listing-not-found (err u404))
(define-constant err-already-listed (err u409))
(define-constant err-invalid-price (err u400))
(define-constant err-invalid-stream (err u400))
(define-constant err-not-nft-owner (err u403))
(define-constant err-payment-failed (err u402))
(define-constant err-transfer-failed (err u403))
(define-constant err-listing-inactive (err u410))
(define-constant err-no-pending-purchase (err u411))
(define-constant err-purchase-expired (err u412))
(define-constant err-buyer-mismatch (err u413))
(define-constant err-payment-id-mismatch (err u414))
(define-constant err-not-expired (err u415))
(define-constant err-already-pending (err u416))

;; Data Variables
(define-data-var total-listings uint u0)
(define-data-var total-sales uint u0)
(define-data-var total-volume uint u0)
(define-data-var marketplace-fee-bps uint u100) ;; Platform fee: 1% = 100 basis points

;; Data Maps
(define-map listings
  uint ;; stream-id
  {
    seller: principal,
    price: uint,
    listed-at: uint,
    active: bool,
  }
)

(define-map user-listings
  principal
  (list 50 uint) ;; List of stream-ids listed by user
)

(define-map sales-history
  uint ;; sale-id
  {
    stream-id: uint,
    seller: principal,
    buyer: principal,
    price: uint,
    sold-at: uint,
    payment-id: (optional (string-ascii 64)),
  }
)

;; Pending purchases for gateway-assisted buying
(define-map pending-purchases
  uint ;; stream-id
  {
    buyer: principal,
    payment-id: (string-ascii 64),
    initiated-at: uint,
    expires-at: uint,
  }
)

;; Authorized backend principals who can complete gateway purchases
(define-map authorized-backends
  principal
  bool
)

;; Read-only functions

;; Get listing details for a stream
;; @param stream-id: ID of the stream
;; @returns: Optional listing data
(define-read-only (get-listing (stream-id uint))
  (map-get? listings stream-id)
)

;; Check if a stream is currently listed
;; @param stream-id: ID of the stream
;; @returns: true if active listing exists, false otherwise
(define-read-only (is-listed (stream-id uint))
  (match (get-listing stream-id)
    listing (get active listing)
    false
  )
)

;; Get all listings created by a user
;; @param user: Principal address
;; @returns: List of stream IDs listed by user
(define-read-only (get-user-listings (user principal))
  (default-to (list) (map-get? user-listings user))
)

;; Get sale history by sale ID
;; @param sale-id: ID of the sale
;; @returns: Optional sale data
(define-read-only (get-sale-history (sale-id uint))
  (map-get? sales-history sale-id)
)

;; Get marketplace statistics
;; @returns: (ok stats) with total listings, sales, and volume
(define-read-only (get-marketplace-stats)
  (ok {
    total-listings: (var-get total-listings),
    total-sales: (var-get total-sales),
    total-volume: (var-get total-volume),
  })
)

;; Calculate marketplace fee for a given price
;; @param price: Sale price in sats
;; @returns: Fee amount in sats
(define-read-only (calculate-marketplace-fee (price uint))
  (/ (* price (var-get marketplace-fee-bps)) u10000)
)

;; Get current marketplace fee in basis points
;; @returns: (ok fee-bps)
(define-read-only (get-marketplace-fee-bps)
  (ok (var-get marketplace-fee-bps))
)

;; Calculate seller proceeds after marketplace fee
;; @param price: Sale price in sats
;; @returns: Net proceeds to seller in sats
(define-read-only (calculate-seller-proceeds (price uint))
  (- price (calculate-marketplace-fee price))
)

;; Get pending purchase details for a stream
;; @param stream-id: ID of the stream
;; @returns: Optional pending purchase data
(define-read-only (get-pending-purchase (stream-id uint))
  (map-get? pending-purchases stream-id)
)

;; Check if a stream has a pending purchase
;; @param stream-id: ID of the stream
;; @returns: true if pending purchase exists, false otherwise
(define-read-only (is-pending-purchase (stream-id uint))
  (is-some (get-pending-purchase stream-id))
)

;; Check if a principal is an authorized backend
;; @param backend: Principal to check
;; @returns: true if authorized, false otherwise
(define-read-only (is-authorized-backend (backend principal))
  (default-to false (map-get? authorized-backends backend))
)

;; Get count of active listings
;; @returns: (ok total-listings)
(define-read-only (get-active-listings-count)
  (ok (var-get total-listings))
)

;; Get detailed listing information including fees
;; @param stream-id: ID of the stream
;; @returns: (ok listing-details) or error
(define-read-only (get-listing-details (stream-id uint))
  (match (get-listing stream-id)
    listing (ok {
      stream-id: stream-id,
      seller: (get seller listing),
      price: (get price listing),
      listed-at: (get listed-at listing),
      active: (get active listing),
      marketplace-fee: (calculate-marketplace-fee (get price listing)),
      seller-proceeds: (calculate-seller-proceeds (get price listing)),
    })
    err-listing-not-found
  )
)

;; Public functions

;; List an obligation NFT for sale
;; @param stream-id: ID of the stream to list
;; @param price: Asking price in sats
;; @returns: (ok true) on success
(define-public (list-nft
    (stream-id uint)
    (price uint)
  )
  (let (
      (listing-check (get-listing stream-id))
      (nft-owner-response (unwrap! (contract-call? .bitpay-obligation-nft-v4 get-owner stream-id)
        err-invalid-stream
      ))
      (nft-owner (unwrap! nft-owner-response err-not-nft-owner))
    )
    ;; Validations
    (asserts! (> price u0) err-invalid-price)
    (asserts! (is-eq nft-owner tx-sender) err-not-nft-owner)
    (asserts! (is-none listing-check) err-already-listed)
    (asserts! (not (is-pending-purchase stream-id)) err-already-pending)

    ;; Create listing
    (map-set listings stream-id {
      seller: tx-sender,
      price: price,
      listed-at: stacks-block-height,
      active: true,
    })

    ;; Update user listings
    (match (map-get? user-listings tx-sender)
      existing-listings (map-set user-listings tx-sender
        (unwrap! (as-max-len? (append existing-listings stream-id) u50)
          err-not-authorized
        ))
      (map-set user-listings tx-sender (list stream-id))
    )

    ;; Update stats
    (var-set total-listings (+ (var-get total-listings) u1))

    ;; Emit event
    (print {
      event: "market-nft-listed",
      stream-id: stream-id,
      seller: tx-sender,
      price: price,
      listed-at: stacks-block-height,
    })

    (ok true)
  )
)

;; Update listing price
;; @param stream-id: ID of the stream listing to update
;; @param new-price: New asking price in sats
;; @returns: (ok true) on success
(define-public (update-listing-price
    (stream-id uint)
    (new-price uint)
  )
  (let ((listing (unwrap! (get-listing stream-id) err-listing-not-found)))
    ;; Validations
    (asserts! (> new-price u0) err-invalid-price)
    (asserts! (is-eq (get seller listing) tx-sender) err-not-authorized)
    (asserts! (get active listing) err-listing-not-found)

    ;; Update listing
    (map-set listings stream-id (merge listing { price: new-price }))

    ;; Emit event
    (print {
      event: "market-listing-price-updated",
      stream-id: stream-id,
      seller: tx-sender,
      old-price: (get price listing),
      new-price: new-price,
    })

    (ok true)
  )
)

;; Cancel listing
;; @param stream-id: ID of the stream listing to cancel
;; @returns: (ok true) on success
(define-public (cancel-listing (stream-id uint))
  (let ((listing (unwrap! (get-listing stream-id) err-listing-not-found)))
    ;; Validations
    (asserts! (is-eq (get seller listing) tx-sender) err-not-authorized)
    (asserts! (get active listing) err-listing-not-found)
    (asserts! (not (is-pending-purchase stream-id)) err-already-pending)

    ;; Deactivate listing
    (map-set listings stream-id (merge listing { active: false }))

    ;; Emit event
    (print {
      event: "market-listing-cancelled",
      stream-id: stream-id,
      seller: tx-sender,
    })

    (ok true)
  )
)

;; ========================================
;; OPTION 1: Direct On-Chain Purchase
;; ========================================

;; Buy an obligation NFT directly with crypto wallet (atomic transaction)
;; @param stream-id: ID of the stream to purchase
;; @returns: (ok sale-id) on success
(define-public (buy-nft (stream-id uint))
  (let (
      (listing (unwrap! (get-listing stream-id) err-listing-not-found))
      (seller (get seller listing))
      (price (get price listing))
      (marketplace-fee (calculate-marketplace-fee price))
      (seller-proceeds (calculate-seller-proceeds price))
      (sale-id (var-get total-sales))
      (treasury-address (unwrap! (contract-call? .bitpay-treasury-v4 get-contract-address)
        err-payment-failed
      ))
    )
    ;; Validations
    (asserts! (get active listing) err-listing-inactive)
    (asserts! (not (is-eq tx-sender seller)) err-not-authorized)
    (asserts! (not (is-pending-purchase stream-id)) err-already-pending)

    ;; Payment: buyer to seller (minus marketplace fee)
    (try! (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
      transfer seller-proceeds tx-sender seller none
    ))

    ;; Payment: buyer to treasury (marketplace fee)
    (try! (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
      transfer marketplace-fee tx-sender treasury-address none
    ))

    ;; Notify treasury to update its accounting
    (try! (as-contract (contract-call? .bitpay-treasury-v4 collect-marketplace-fee marketplace-fee)))

    ;; Transfer obligation NFT: seller to buyer
    (try! (contract-call? .bitpay-obligation-nft-v4 transfer stream-id seller tx-sender))

    ;; Update stream sender: seller to buyer
    (try! (as-contract (contract-call? .bitpay-core-v4 update-stream-sender stream-id tx-sender)))

    ;; Deactivate listing
    (map-set listings stream-id (merge listing { active: false }))

    ;; Record sale
    (map-set sales-history sale-id {
      stream-id: stream-id,
      seller: seller,
      buyer: tx-sender,
      price: price,
      sold-at: stacks-block-height,
      payment-id: none,
    })

    ;; Update stats
    (var-set total-sales (+ sale-id u1))
    (var-set total-volume (+ (var-get total-volume) price))

    ;; Emit event
    (print {
      event: "market-direct-purchase-completed",
      stream-id: stream-id,
      buyer: tx-sender,
      seller: seller,
      price: price,
      sale-id: sale-id,
    })

    (ok sale-id)
  )
)

;; ========================================
;; OPTION 2: Gateway-Assisted Purchase
;; ========================================

;; Step 1: Initiate purchase through payment gateway
;; Called by buyer when they start checkout on external gateway
;; @param stream-id: ID of the stream to purchase
;; @param payment-id: Unique payment identifier from gateway
;; @returns: (ok true) on success
;; #[allow(unchecked_data)]
(define-public (initiate-purchase
    (stream-id uint)
    (payment-id (string-ascii 64))
  )
  (let (
      (listing (unwrap! (get-listing stream-id) err-listing-not-found))
      (expiry (+ stacks-block-height u1008)) ;; ~1 week expiry (144 blocks/day * 7)
    )
    ;; Validations
    (asserts! (get active listing) err-listing-inactive)
    (asserts! (not (is-eq tx-sender (get seller listing))) err-not-authorized)
    (asserts! (not (is-pending-purchase stream-id)) err-already-pending)

    ;; Create pending purchase record
    (map-set pending-purchases stream-id {
      buyer: tx-sender,
      payment-id: payment-id,
      initiated-at: stacks-block-height,
      expires-at: expiry,
    })

    ;; Emit event for monitoring
    (print {
      event: "market-purchase-initiated",
      stream-id: stream-id,
      buyer: tx-sender,
      payment-id: payment-id,
      expires-at: expiry,
    })

    (ok true)
  )
)

;; Step 2: Complete purchase after payment confirmed by gateway
;; Called by authorized backend after webhook confirms payment
;; @param stream-id: ID of the stream to complete purchase
;; @param buyer: Principal of the buyer
;; @param payment-id: Payment identifier to verify
;; @returns: (ok sale-id) on success
(define-public (complete-purchase
    (stream-id uint)
    (buyer principal)
    (payment-id (string-ascii 64))
  )
  (let (
      (listing (unwrap! (get-listing stream-id) err-listing-not-found))
      (pending (unwrap! (get-pending-purchase stream-id) err-no-pending-purchase))
      (seller (get seller listing))
      (price (get price listing))
      (sale-id (var-get total-sales))
    )
    ;; Authorization checks
    (asserts! (is-authorized-backend tx-sender) err-not-authorized)
    (asserts! (get active listing) err-listing-inactive)
    (asserts! (is-eq (get buyer pending) buyer) err-buyer-mismatch)
    (asserts! (is-eq (get payment-id pending) payment-id) err-payment-id-mismatch)
    (asserts! (< stacks-block-height (get expires-at pending))
      err-purchase-expired
    )

    ;; Transfer obligation NFT: seller to buyer
    (unwrap!
      (contract-call? .bitpay-obligation-nft-v4 transfer stream-id seller buyer)
      err-transfer-failed
    )

    ;; Update stream sender: seller to buyer
    (unwrap!
      (as-contract (contract-call? .bitpay-core-v4 update-stream-sender stream-id buyer))
      err-transfer-failed
    )

    ;; Deactivate listing and clear pending purchase
    (map-set listings stream-id (merge listing { active: false }))
    (map-delete pending-purchases stream-id)

    ;; Record sale
    (map-set sales-history sale-id {
      stream-id: stream-id,
      seller: seller,
      buyer: buyer,
      price: price,
      sold-at: stacks-block-height,
      payment-id: (some payment-id),
    })

    ;; Update stats
    (var-set total-sales (+ sale-id u1))
    (var-set total-volume (+ (var-get total-volume) price))

    ;; Emit event for chainhook monitoring
    (print {
      event: "market-gateway-purchase-completed",
      stream-id: stream-id,
      buyer: buyer,
      seller: seller,
      price: price,
      payment-id: payment-id,
      sale-id: sale-id,
    })

    (ok sale-id)
  )
)

;; Cancel expired pending purchase
;; Allows cleaning up expired purchase attempts
;; @param stream-id: ID of the stream with expired purchase
;; @returns: (ok true) on success
(define-public (cancel-expired-purchase (stream-id uint))
  (let ((pending (unwrap! (get-pending-purchase stream-id) err-no-pending-purchase)))
    (asserts! (>= stacks-block-height (get expires-at pending)) err-not-expired)
    (map-delete pending-purchases stream-id)

    (print {
      event: "market-purchase-expired",
      stream-id: stream-id,
      buyer: (get buyer pending),
    })

    (ok true)
  )
)

;; ========================================
;; Admin Functions
;; ========================================

;; Add authorized backend principal
;; @param backend: Principal to authorize
;; @returns: (ok true) on success
;; #[allow(unchecked_data)]
(define-public (add-authorized-backend (backend principal))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-not-authorized)
    (map-set authorized-backends backend true)
    (print {
      event: "market-backend-authorized",
      backend: backend,
    })
    (ok true)
  )
)

;; Remove authorized backend principal
;; @param backend: Principal to deauthorize
;; @returns: (ok true) on success
;; #[allow(unchecked_data)]
(define-public (remove-authorized-backend (backend principal))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-not-authorized)
    (map-delete authorized-backends backend)
    (print {
      event: "market-backend-deauthorized",
      backend: backend,
    })
    (ok true)
  )
)

;; Admin function to update marketplace fee
;; @param new-fee-bps: New fee in basis points (max 1000 = 10%)
;; @returns: (ok true) on success
(define-public (set-marketplace-fee (new-fee-bps uint))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-not-authorized)
    (asserts! (<= new-fee-bps u1000) err-invalid-price) ;; Max 10% fee
    (var-set marketplace-fee-bps new-fee-bps)
    (print {
      event: "market-marketplace-fee-updated",
      old-fee: (var-get marketplace-fee-bps),
      new-fee: new-fee-bps,
    })
    (ok true)
  )
)

Functions (21)

FunctionAccessArgs
get-listingread-onlystream-id: uint
is-listedread-onlystream-id: uint
get-user-listingsread-onlyuser: principal
get-sale-historyread-onlysale-id: uint
get-marketplace-statsread-only
calculate-marketplace-feeread-onlyprice: uint
get-marketplace-fee-bpsread-only
calculate-seller-proceedsread-onlyprice: uint
get-pending-purchaseread-onlystream-id: uint
is-pending-purchaseread-onlystream-id: uint
is-authorized-backendread-onlybackend: principal
get-active-listings-countread-only
get-listing-detailsread-onlystream-id: uint
cancel-listingpublicstream-id: uint
buy-nftpublicstream-id: uint
initiate-purchasepublicstream-id: uint, payment-id: (string-ascii 64
complete-purchasepublicstream-id: uint, buyer: principal, payment-id: (string-ascii 64
cancel-expired-purchasepublicstream-id: uint
add-authorized-backendpublicbackend: principal
remove-authorized-backendpublicbackend: principal
set-marketplace-feepublicnew-fee-bps: uint