diff --git a/README.md b/README.md
index ef89d4b..7af2da4 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,37 @@ const mintedNfd = await nfd
})
```
+### Purchasing NFDs (Claiming & Buying)
+
+```typescript
+import { NfdClient } from '@txnlab/nfd-sdk'
+
+const nfd = NfdClient.testNet()
+
+// Get a purchase quote to check eligibility and pricing
+const quote = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .getPurchaseQuote('reserved-nfd.algo')
+
+if (quote.canClaim) {
+ // Claim a reserved NFD (automatically uses signer's address)
+ const claimedNfd = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .claim('reserved-nfd.algo')
+
+ console.log('Successfully claimed:', claimedNfd.name)
+}
+
+if (quote.canBuy) {
+ // Buy an NFD from the secondary market
+ const purchasedNfd = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .buy('forsale-nfd.algo')
+
+ console.log('Successfully purchased:', purchasedNfd.name)
+}
+```
+
### Managing an NFD
```typescript
diff --git a/examples/README.md b/examples/README.md
index c304e0b..2461fe0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -8,6 +8,7 @@ This directory contains example applications demonstrating various features of t
- [API Search](./api-search/): Demonstrates how to use the API client to search for NFDs
- [Reverse Lookup](./reverse-lookup/): Demonstrates how to look up NFDs by wallet address
- [Mint](./mint/): Demonstrates how to mint NFDs
+- [Claim NFD](./claim-nfd/): Demonstrates how to claim NFDs reserved for your wallet address
- [Link Address](./link-address/): Demonstrates how to link addresses to NFDs
- [Set Metadata](./set-metadata/): Demonstrates how to set metadata for NFDs
- [Set Primary NFD](./set-primary-nfd/): Demonstrates how to set a primary NFD for an address
diff --git a/examples/claim-nfd/.gitignore b/examples/claim-nfd/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/examples/claim-nfd/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/claim-nfd/README.md b/examples/claim-nfd/README.md
new file mode 100644
index 0000000..fe8fb41
--- /dev/null
+++ b/examples/claim-nfd/README.md
@@ -0,0 +1,60 @@
+# NFD Claim Example
+
+This example demonstrates how to use the NFD SDK to claim NFDs that are reserved for your wallet address.
+
+## Features
+
+- Connect your wallet using the Wallet Connect protocol
+- Search for NFDs reserved for your address using the NFD API
+- Claim reserved NFDs using the NFD SDK's claiming API
+- Simple and clean UI for managing the claiming process
+
+## Getting Started
+
+1. Install dependencies:
+
+```bash
+npm install
+```
+
+2. Start the development server:
+
+```bash
+npm run dev
+```
+
+3. Open your browser to the provided localhost URL
+
+## How it Works
+
+1. **Connect Wallet**: Use the wallet connection interface to connect your Algorand wallet
+2. **Search Reserved NFDs**: The app automatically searches for NFDs reserved for your connected address
+3. **Claim NFDs**: Click the "Claim" button next to any reserved NFD to claim it to your wallet
+
+## Code Example
+
+Here's how simple it is to claim an NFD with the new API:
+
+```typescript
+// Simple claiming - just connect wallet and call claim()
+const claimedNfd = await nfd
+ .setSigner(address, transactionSigner)
+ .claim('reserved-nfd.algo')
+```
+
+The NFD SDK automatically uses your connected wallet address as the claimer, making the API clean and intuitive.
+
+## Important Notes
+
+- This example runs on Algorand TestNet
+- You need TestNet ALGO in your wallet to pay for transaction fees
+- Reserved NFDs may have a claiming cost (calculated based on the NFD's pricing), plus transaction fees
+- Make sure your wallet is connected to TestNet
+
+## Technology Stack
+
+- **React**: Frontend framework
+- **TypeScript**: Type safety
+- **Vite**: Build tool and dev server
+- **@txnlab/nfd-sdk**: NFD SDK for blockchain interactions
+- **@txnlab/use-wallet-react**: Wallet connection management
diff --git a/examples/claim-nfd/eslint.config.js b/examples/claim-nfd/eslint.config.js
new file mode 100644
index 0000000..37269aa
--- /dev/null
+++ b/examples/claim-nfd/eslint.config.js
@@ -0,0 +1,42 @@
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+import baseConfig from '../../eslint.config.js'
+
+export default tseslint.config(
+ ...baseConfig,
+ {
+ files: ['src/**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ project: './tsconfig.json',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': tseslint.plugin,
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+ {
+ files: ['*.config.{js,ts}'],
+ languageOptions: {
+ parserOptions: {
+ project: './tsconfig.node.json',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': tseslint.plugin,
+ },
+ },
+)
diff --git a/examples/claim-nfd/index.html b/examples/claim-nfd/index.html
new file mode 100644
index 0000000..b5f7178
--- /dev/null
+++ b/examples/claim-nfd/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ NFD Claim Example
+
+
+
+
+
+
diff --git a/examples/claim-nfd/package.json b/examples/claim-nfd/package.json
new file mode 100644
index 0000000..68e4aaa
--- /dev/null
+++ b/examples/claim-nfd/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@txnlab/nfd-sdk-claim-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@algorandfoundation/algokit-utils": "^8.2.2",
+ "@txnlab/nfd-sdk": "^0.8.0",
+ "@txnlab/use-wallet-react": "^4.0.0",
+ "algosdk": "^3.2.0",
+ "lute-connect": "^1.4.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.64",
+ "@types/react-dom": "^18.2.21",
+ "@vitejs/plugin-react": "^4.2.1",
+ "vite": "^4.5.2",
+ "vite-plugin-node-polyfills": "^0.23.0"
+ }
+}
diff --git a/examples/claim-nfd/public/vite.svg b/examples/claim-nfd/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/examples/claim-nfd/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/claim-nfd/src/App.tsx b/examples/claim-nfd/src/App.tsx
new file mode 100644
index 0000000..5291941
--- /dev/null
+++ b/examples/claim-nfd/src/App.tsx
@@ -0,0 +1,279 @@
+import { NfdClient, type Nfd, type SearchResponse } from '@txnlab/nfd-sdk'
+import { useWallet } from '@txnlab/use-wallet-react'
+import { useState, useEffect, useCallback } from 'react'
+
+import { WalletMenu } from './Connect'
+
+/**
+ * Initialize the NFD client for TestNet
+ */
+const nfd = NfdClient.testNet()
+
+export function App() {
+ const [reservedNfds, setReservedNfds] = useState([])
+ const [error, setError] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [claimingNfd, setClaimingNfd] = useState(null)
+ const [claimedNfds, setClaimedNfds] = useState>(new Set())
+
+ const { activeAddress, transactionSigner } = useWallet()
+
+ /**
+ * Search for NFDs reserved for the active address
+ */
+ const searchReservedNfds = useCallback(async () => {
+ if (!activeAddress) return
+
+ setIsLoading(true)
+ setError('')
+
+ try {
+ // Search for NFDs reserved for the active address
+ const searchResults: SearchResponse = await nfd.api.search({
+ reservedFor: activeAddress,
+ view: 'full',
+ limit: 100, // Get up to 100 reserved NFDs
+ })
+
+ console.log('searchResults', searchResults)
+ setReservedNfds(searchResults.nfds)
+ } catch (err) {
+ console.error('Error searching for reserved NFDs:', err)
+ setError('Failed to load reserved NFDs. Please try again.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [activeAddress])
+
+ /**
+ * Claim a reserved NFD
+ */
+ const claimNfd = async (nfdName: string) => {
+ if (!activeAddress || !transactionSigner) {
+ setError('Please connect your wallet first')
+ return
+ }
+
+ setClaimingNfd(nfdName)
+ setError('')
+
+ try {
+ // Use the simplified API to claim the NFD directly
+ const claimedNfd = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .claim(nfdName)
+
+ console.log('Successfully claimed NFD:', claimedNfd)
+
+ // Add to claimed NFDs set
+ setClaimedNfds((prev) => new Set(prev).add(nfdName))
+
+ // Refresh the list of reserved NFDs
+ await searchReservedNfds()
+ } catch (err) {
+ console.error('Error claiming NFD:', err)
+ setError(
+ `Failed to claim ${nfdName}. ${err instanceof Error ? err.message : 'Please try again.'}`,
+ )
+ } finally {
+ setClaimingNfd(null)
+ }
+ }
+
+ // Load reserved NFDs when wallet connects
+ useEffect(() => {
+ if (activeAddress) {
+ searchReservedNfds()
+ } else {
+ setReservedNfds([])
+ setError('')
+ setClaimedNfds(new Set())
+ }
+ }, [activeAddress, searchReservedNfds])
+
+ return (
+
+
NFD Claim Tool
+
Claim NFDs that are reserved for your wallet address
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {activeAddress && (
+
+
+
Connected Address
+
+ {activeAddress}
+
+
+
+
+
+ {isLoading ? 'Searching...' : 'Refresh Reserved NFDs'}
+
+
+
+
+
Reserved NFDs ({reservedNfds.length})
+
+ {isLoading && (
+
+ Searching for NFDs reserved for your address...
+
+ )}
+
+ {!isLoading && reservedNfds.length === 0 && (
+
+ No NFDs are currently reserved for your address.
+
+ )}
+
+ {reservedNfds.length > 0 && (
+
+ {reservedNfds.map((nfdData) => (
+
+
+
+
+ {nfdData.name}
+
+
+ State: {nfdData.state}
+
+ {nfdData.reservedFor && (
+
+ Reserved for: {nfdData.reservedFor}
+
+ )}
+ {claimedNfds.has(nfdData.name) && (
+
+ ✓ Successfully claimed!
+
+ )}
+
+
+
+ {!claimedNfds.has(nfdData.name) && (
+ claimNfd(nfdData.name)}
+ disabled={claimingNfd === nfdData.name}
+ style={{
+ padding: '8px 16px',
+ background:
+ claimingNfd === nfdData.name
+ ? '#6c757d'
+ : '#28a745',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor:
+ claimingNfd === nfdData.name
+ ? 'not-allowed'
+ : 'pointer',
+ fontSize: '14px',
+ }}
+ >
+ {claimingNfd === nfdData.name
+ ? 'Claiming...'
+ : 'Claim NFD'}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ {!activeAddress && (
+
+
+ Please connect your wallet to search for and claim reserved NFDs.
+
+
+ )}
+
+ )
+}
diff --git a/examples/claim-nfd/src/Connect.tsx b/examples/claim-nfd/src/Connect.tsx
new file mode 100644
index 0000000..cf13821
--- /dev/null
+++ b/examples/claim-nfd/src/Connect.tsx
@@ -0,0 +1,103 @@
+import { useWallet, type Wallet } from '@txnlab/use-wallet-react'
+import { useState } from 'react'
+
+export const WalletMenu = () => {
+ const { wallets, activeWallet } = useWallet()
+
+ if (activeWallet) {
+ return
+ }
+
+ return
+}
+
+const WalletList = ({ wallets }: { wallets: Wallet[] }) => {
+ return (
+
+
Connect Wallet
+
+ {wallets.map((wallet) => (
+
+ ))}
+
+
+ )
+}
+
+const WalletOption = ({ wallet }: { wallet: Wallet }) => {
+ const [connecting, setConnecting] = useState(false)
+
+ const handleConnect = async () => {
+ setConnecting(true)
+ try {
+ await wallet.connect()
+ } catch (error) {
+ console.error('Failed to connect:', error)
+ } finally {
+ setConnecting(false)
+ }
+ }
+
+ return (
+
+
+ Connect {wallet.metadata.name}
+
+ )
+}
+
+const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => {
+ return (
+
+
+
+
{wallet.metadata.name}
+
+
+ {wallet.accounts.length > 1 && (
+
wallet.setActiveAccount(e.target.value)}
+ style={{ maxWidth: '640px', padding: '2px' }}
+ >
+ {wallet.accounts.map((account) => (
+
+ {account.address}
+
+ ))}
+
+ )}
+
+ {wallet.activeAccount && (
+
+ Active Account: {wallet.activeAccount.address}
+
+ )}
+
+
+ Disconnect
+
+
+ )
+}
diff --git a/examples/claim-nfd/src/index.tsx b/examples/claim-nfd/src/index.tsx
new file mode 100644
index 0000000..377116a
--- /dev/null
+++ b/examples/claim-nfd/src/index.tsx
@@ -0,0 +1,30 @@
+import {
+ WalletId,
+ WalletManager,
+ WalletProvider,
+} from '@txnlab/use-wallet-react'
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import { App } from './App'
+
+const walletManager = new WalletManager({
+ wallets: [
+ {
+ id: WalletId.LUTE,
+ options: { siteName: 'NFD SDK Mint Example' },
+ },
+ ],
+ defaultNetwork: 'testnet',
+ options: {
+ resetNetwork: true,
+ },
+})
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/examples/claim-nfd/tsconfig.app.json b/examples/claim-nfd/tsconfig.app.json
new file mode 100644
index 0000000..358ca9b
--- /dev/null
+++ b/examples/claim-nfd/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/claim-nfd/tsconfig.json b/examples/claim-nfd/tsconfig.json
new file mode 100644
index 0000000..a7fc6fb
--- /dev/null
+++ b/examples/claim-nfd/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/claim-nfd/tsconfig.node.json b/examples/claim-nfd/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/examples/claim-nfd/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/claim-nfd/vite.config.ts b/examples/claim-nfd/vite.config.ts
new file mode 100644
index 0000000..e42dc7c
--- /dev/null
+++ b/examples/claim-nfd/vite.config.ts
@@ -0,0 +1,15 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+import { nodePolyfills } from 'vite-plugin-node-polyfills'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [
+ react(),
+ nodePolyfills({
+ globals: {
+ Buffer: true,
+ },
+ }),
+ ],
+})
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index 1108bdc..a5773c7 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -110,6 +110,37 @@ const mintedNfd = await nfd
})
```
+### Purchasing NFDs (Claiming & Buying)
+
+```typescript
+import { NfdClient } from '@txnlab/nfd-sdk'
+
+const nfd = NfdClient.testNet()
+
+// Get a purchase quote to check eligibility and pricing
+const quote = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .getPurchaseQuote('reserved-nfd.algo')
+
+if (quote.canClaim) {
+ // Claim a reserved NFD (automatically uses signer's address)
+ const claimedNfd = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .claim('reserved-nfd.algo')
+
+ console.log('Successfully claimed:', claimedNfd.name)
+}
+
+if (quote.canBuy) {
+ // Buy an NFD from the secondary market
+ const purchasedNfd = await nfd
+ .setSigner(activeAddress, transactionSigner)
+ .buy('forsale-nfd.algo')
+
+ console.log('Successfully purchased:', purchasedNfd.name)
+}
+```
+
### Managing an NFD
```typescript
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index c458537..2be97fa 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -12,6 +12,7 @@ import {
NfdMintQuote,
NfdMintQuoteParams,
} from './modules/minting'
+import { PurchasingModule, NfdPurchaseQuote } from './modules/purchasing'
import type {
Nfd,
@@ -46,6 +47,7 @@ export class NfdClient {
// Core modules
private readonly _lookup: LookupModule
private readonly _minting: MintingModule
+ private readonly _purchasing: PurchasingModule
private _signer: TransactionSignerAccount | null = null
@@ -57,6 +59,7 @@ export class NfdClient {
// Initialize modules
this._lookup = new LookupModule(this)
this._minting = new MintingModule(this)
+ this._purchasing = new PurchasingModule(this)
}
/**
@@ -138,6 +141,14 @@ export class NfdClient {
return new NfdManager(this, nameOrAppId)
}
+ /**
+ * Get access to purchasing and claiming functionality
+ * @returns A purchasing module instance
+ */
+ public purchasing(): PurchasingModule {
+ return new PurchasingModule(this)
+ }
+
/**
* Resolve an NFD by name or application ID by reading directly from the blockchain
* @param nameOrAppId - The NFD name or application ID to resolve
@@ -182,6 +193,63 @@ export class NfdClient {
}
}
+ /**
+ * Get a quote for purchasing an NFD
+ * @param nameOrAppId - The NFD name or application ID to get a quote for
+ * @returns A detailed purchase quote including price and eligibility
+ * @throws If the quote cannot be generated or signer is not set
+ */
+ public async getPurchaseQuote(
+ nameOrAppId: string | number | bigint,
+ ): Promise {
+ if (!this._signer) {
+ throw new Error('Signer must be set before getting purchase quote')
+ }
+
+ return this._purchasing.getPurchaseQuote(
+ nameOrAppId,
+ this._signer.addr.toString(),
+ )
+ }
+
+ /**
+ * Claim an NFD that is reserved for the claimer
+ * @param nameOrAppId - The NFD name or application ID to claim
+ * @returns The claimed NFD record
+ * @throws If the claim operation fails or signer is not set
+ */
+ public async claim(nameOrAppId: string | number | bigint): Promise {
+ if (!this._signer) {
+ throw new Error('Signer must be set before claiming NFD')
+ }
+
+ try {
+ return await this._purchasing.claim(nameOrAppId)
+ } finally {
+ // Reset signer after operation
+ this._signer = null
+ }
+ }
+
+ /**
+ * Buy an NFD from the secondary market
+ * @param nameOrAppId - The NFD name or application ID to buy
+ * @returns The purchased NFD record
+ * @throws If the buy operation fails or signer is not set
+ */
+ public async buy(nameOrAppId: string | number | bigint): Promise {
+ if (!this._signer) {
+ throw new Error('Signer must be set before buying NFD')
+ }
+
+ try {
+ return await this._purchasing.buy(nameOrAppId)
+ } finally {
+ // Reset signer after operation
+ this._signer = null
+ }
+ }
+
/**
* Resolve an address to find its associated NFD
* @param address - The address to resolve
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 2455080..3df704b 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -18,6 +18,7 @@ export { NfdApiClient } from './api-client'
// Export modules
export { NfdManager } from './modules/manager'
+export { PurchasingModule } from './modules/purchasing'
// Export module types
export type {
@@ -26,6 +27,8 @@ export type {
NfdMintParams,
} from './modules/minting'
+export type { NfdPurchaseQuote } from './modules/purchasing'
+
// Export NFD utility functions
export {
isValidName,
diff --git a/packages/sdk/src/modules/purchasing.ts b/packages/sdk/src/modules/purchasing.ts
new file mode 100644
index 0000000..737711e
--- /dev/null
+++ b/packages/sdk/src/modules/purchasing.ts
@@ -0,0 +1,295 @@
+import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount'
+import { Address } from 'algosdk'
+
+import { Nfd } from '../types'
+import { parseTransactionError } from '../utils/error-parser'
+
+import { BaseModule } from './base'
+
+/**
+ * Response structure for purchase quote requests
+ */
+export interface NfdPurchaseQuote {
+ /** The NFD name being quoted */
+ nfdName: string
+ /** The address of the buyer */
+ buyer: string
+ /** Whether this NFD can be claimed (is reserved for the buyer) */
+ canClaim: boolean
+ /** Whether this NFD can be bought (is for sale) */
+ canBuy: boolean
+ /** The price to purchase in microAlgos (calculated amount for claims, sellAmount for purchases) */
+ price: bigint
+ /** The address the NFD is reserved for (if any) */
+ reservedFor?: string
+ /** The current sell amount in microAlgos (if for sale) */
+ sellAmount?: bigint
+ /** The current state of the NFD */
+ state: string
+ /** Whether the buyer is authorized to make this purchase */
+ authorized: boolean
+ /** Reason why purchase is not authorized (if applicable) */
+ authorizationError?: string
+}
+
+/**
+ * Module for handling NFD purchasing operations (claiming and buying)
+ */
+export class PurchasingModule extends BaseModule {
+ /**
+ * Validate an Algorand address
+ * @private
+ */
+ private validateAddress(address: string, paramName: string): void {
+ try {
+ Address.fromString(address)
+ } catch {
+ throw new Error(`Invalid ${paramName}: ${address}`)
+ }
+ }
+
+ /**
+ * Get a purchase quote for an NFD
+ * @param nameOrAppId - The NFD name or application ID
+ * @param buyer - The buyer address
+ * @returns Detailed purchase quote including eligibility and pricing
+ * @throws If the NFD cannot be resolved or quote cannot be generated
+ */
+ public async getPurchaseQuote(
+ nameOrAppId: string | number | bigint,
+ buyer: string,
+ ): Promise {
+ // Validate buyer address
+ this.validateAddress(buyer, 'buyer')
+
+ // Resolve NFD to get current information
+ const nfd = await this.client.resolve(nameOrAppId, { view: 'full' })
+
+ // Check if buyer can claim this NFD (is reserved for them)
+ // Check all possible locations for reservation info
+ const reservedForAddress =
+ nfd.reservedFor ||
+ nfd.properties?.internal?.reservedOwner ||
+ nfd.properties?.verified?.reservedFor
+ const isReservedForBuyer = reservedForAddress === buyer
+
+ // NFD can be claimed if it's in 'reserved' state OR if it's 'forSale' but reserved for the buyer
+ const canClaim =
+ (nfd.state === 'reserved' || nfd.state === 'forSale') &&
+ isReservedForBuyer
+
+ // Check if buyer can buy this NFD (is for sale and not specifically reserved)
+ const canBuy = nfd.state === 'forSale' && !reservedForAddress
+
+ let authorized = false
+ let authorizationError: string | undefined
+
+ // Determine authorization
+ if (nfd.state === 'reserved' || nfd.state === 'forSale') {
+ if (reservedForAddress && !isReservedForBuyer) {
+ authorized = false
+ authorizationError = `NFD is reserved for ${reservedForAddress}, but buyer is ${buyer}`
+ } else if (canClaim || canBuy) {
+ authorized = true
+ } else {
+ authorized = false
+ authorizationError = `NFD is not available for purchase (state: ${nfd.state})`
+ }
+ } else {
+ authorized = false
+ authorizationError = `NFD is not available for purchase (state: ${nfd.state})`
+ }
+
+ // Determine price
+ let price = BigInt(0)
+ if (canClaim && nfd.sellAmount) {
+ // For claims, calculate the amount based on sellAmount minus mintingKickoffAmount
+ const sellAmount = Number(nfd.sellAmount)
+ const mintingKickoffAmount =
+ Number(nfd.properties?.internal?.mintingKickoffAmount) || 0
+ const claimAmount = Math.max(sellAmount - mintingKickoffAmount, 0)
+ price = BigInt(claimAmount)
+ } else if (canBuy && nfd.sellAmount) {
+ price = BigInt(nfd.sellAmount)
+ }
+
+ return {
+ nfdName: nfd.name,
+ buyer,
+ canClaim,
+ canBuy,
+ price,
+ reservedFor: reservedForAddress,
+ sellAmount: nfd.sellAmount ? BigInt(nfd.sellAmount) : undefined,
+ state: nfd.state || 'unknown',
+ authorized,
+ authorizationError,
+ }
+ }
+
+ /**
+ * Claim an NFD that is reserved for the caller
+ * @param nameOrAppId - The NFD name or application ID to claim
+ * @returns The claimed NFD record
+ * @throws If the claim operation fails
+ */
+ public async claim(nameOrAppId: string | number | bigint): Promise {
+ // Ensure a signer is set
+ const signer = this.requireSigner()
+
+ const claimer = signer.addr.toString()
+
+ // Validate claimer address
+ this.validateAddress(claimer, 'claimer')
+
+ // Get purchase quote to validate eligibility
+ const quote = await this.getPurchaseQuote(nameOrAppId, claimer)
+
+ if (!quote.canClaim) {
+ throw new Error(
+ quote.authorizationError ||
+ `Cannot claim NFD: ${quote.nfdName} (state: ${quote.state})`,
+ )
+ }
+
+ if (!quote.authorized) {
+ throw new Error(
+ quote.authorizationError ||
+ `Not authorized to claim NFD: ${quote.nfdName}`,
+ )
+ }
+
+ // Resolve NFD to get app ID
+ const nfd = await this.client.resolve(nameOrAppId, { view: 'full' })
+ if (!nfd.appID) {
+ throw new Error(`Cannot determine app ID for NFD: ${nfd.name}`)
+ }
+
+ // Get the NFD instance client
+ const nfdInstanceClient = this.getInstanceClient(BigInt(nfd.appID), claimer)
+
+ try {
+ // Create payment transaction with calculated claim amount
+ const paymentTxn = await this.algorand.createTransaction.payment({
+ sender: claimer,
+ receiver: nfdInstanceClient.appAddress,
+ amount: AlgoAmount.MicroAlgos(quote.price),
+ })
+
+ // Execute the purchase (claim) transaction using transaction group pattern
+ await nfdInstanceClient
+ .newGroup()
+ .purchase({
+ args: { payment: paymentTxn },
+ staticFee: AlgoAmount.MicroAlgos(4000), // 0.004 ALGO
+ })
+ .send({ populateAppCallResources: true })
+
+ // Return the updated NFD record
+ return this.client.resolve(nfd.appID, { view: 'full' })
+ } catch (error) {
+ throw new Error(`Failed to claim NFD: ${parseTransactionError(error)}`)
+ }
+ }
+
+ /**
+ * Buy an NFD from the secondary market
+ * @param nameOrAppId - The NFD name or application ID to buy
+ * @returns The purchased NFD record
+ * @throws If the buy operation fails
+ */
+ public async buy(nameOrAppId: string | number | bigint): Promise {
+ // Ensure a signer is set
+ const signer = this.requireSigner()
+
+ const buyer = signer.addr.toString()
+
+ // Validate buyer address
+ this.validateAddress(buyer, 'buyer')
+
+ // Get purchase quote to validate eligibility and get pricing
+ const quote = await this.getPurchaseQuote(nameOrAppId, buyer)
+
+ if (!quote.canBuy) {
+ throw new Error(
+ quote.authorizationError ||
+ `Cannot buy NFD: ${quote.nfdName} (state: ${quote.state})`,
+ )
+ }
+
+ if (!quote.authorized) {
+ throw new Error(
+ quote.authorizationError ||
+ `Not authorized to buy NFD: ${quote.nfdName}`,
+ )
+ }
+
+ // Resolve NFD to get app ID
+ const nfd = await this.client.resolve(nameOrAppId, { view: 'full' })
+ if (!nfd.appID) {
+ throw new Error(`Cannot determine app ID for NFD: ${nfd.name}`)
+ }
+
+ // Get the NFD instance client
+ const nfdInstanceClient = this.getInstanceClient(BigInt(nfd.appID), buyer)
+
+ try {
+ // Create payment transaction for the purchase amount
+ const paymentTxn = await this.algorand.createTransaction.payment({
+ sender: buyer,
+ receiver: nfdInstanceClient.appAddress,
+ amount: AlgoAmount.MicroAlgos(quote.price),
+ })
+
+ // Execute the purchase transaction using transaction group pattern
+ await nfdInstanceClient
+ .newGroup()
+ .purchase({
+ args: { payment: paymentTxn },
+ staticFee: AlgoAmount.MicroAlgos(4000), // 0.004 ALGO
+ })
+ .send({ populateAppCallResources: true })
+
+ // Return the updated NFD record
+ return this.client.resolve(nfd.appID, { view: 'full' })
+ } catch (error) {
+ throw new Error(`Failed to buy NFD: ${parseTransactionError(error)}`)
+ }
+ }
+
+ /**
+ * Check if an NFD can be claimed by a specific address
+ * @param nameOrAppId - The NFD name or application ID
+ * @param claimer - The address to check claim eligibility for
+ * @returns True if the NFD can be claimed, false otherwise
+ */
+ public async canClaim(
+ nameOrAppId: string | number | bigint,
+ claimer: string,
+ ): Promise {
+ try {
+ const quote = await this.getPurchaseQuote(nameOrAppId, claimer)
+ return quote.canClaim && quote.authorized
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Check if an NFD can be bought by a specific address
+ * @param nameOrAppId - The NFD name or application ID
+ * @param buyer - The address to check buy eligibility for
+ * @returns True if the NFD can be bought, false otherwise
+ */
+ public async canBuy(
+ nameOrAppId: string | number | bigint,
+ buyer: string,
+ ): Promise {
+ try {
+ const quote = await this.getPurchaseQuote(nameOrAppId, buyer)
+ return quote.canBuy && quote.authorized
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts
index 3c051d0..2d18254 100644
--- a/packages/sdk/tests/client.test.ts
+++ b/packages/sdk/tests/client.test.ts
@@ -1,5 +1,6 @@
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { TransactionSigner } from 'algosdk'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NfdClient } from '../src/client'
import { NfdRegistryId } from '../src/constants'
@@ -50,12 +51,43 @@ vi.mock('../src/modules/minting', () => ({
})),
}))
+vi.mock('../src/modules/purchasing', () => ({
+ PurchasingModule: vi.fn().mockImplementation(() => ({
+ getPurchaseQuote: vi.fn().mockResolvedValue({
+ nfdName: 'test.algo',
+ buyer: VALID_ADDRESS,
+ canClaim: true,
+ canBuy: false,
+ price: BigInt(0),
+ reservedFor: VALID_ADDRESS,
+ sellAmount: BigInt(1000000),
+ state: 'reserved' as const,
+ authorized: true,
+ authorizationError: undefined,
+ }),
+ claim: vi.fn().mockResolvedValue({
+ name: 'test.algo',
+ appID: 12345,
+ state: 'owned' as const,
+ owner: VALID_ADDRESS,
+ }),
+ buy: vi.fn().mockResolvedValue({
+ name: 'test.algo',
+ appID: 12345,
+ state: 'owned' as const,
+ owner: VALID_ADDRESS,
+ }),
+ })),
+}))
+
describe('NfdClient', () => {
let client: NfdClient
+ let mockSigner: TransactionSigner
beforeEach(() => {
vi.clearAllMocks()
client = new NfdClient()
+ mockSigner = vi.fn()
})
describe('constructor', () => {
@@ -92,7 +124,6 @@ describe('NfdClient', () => {
describe('setSigner', () => {
it('should set the signer and return the client for chaining', () => {
- const mockSigner = vi.fn()
const result = client.setSigner(VALID_ADDRESS, mockSigner)
expect(result).toBe(client)
expect(client.signer).not.toBeNull()
@@ -119,7 +150,6 @@ describe('NfdClient', () => {
describe('mint', () => {
it('should call the minting module mint method and reset signer', async () => {
// Set a signer first
- const mockSigner = vi.fn()
client.setSigner(VALID_ADDRESS, mockSigner)
expect(client.signer).not.toBeNull()
@@ -134,4 +164,75 @@ describe('NfdClient', () => {
expect(client.signer).toBeNull()
})
})
+
+ describe('Purchasing methods', () => {
+ beforeEach(() => {
+ client.setSigner(VALID_ADDRESS, mockSigner)
+ })
+
+ it('should delegate getPurchaseQuote to purchasing module', async () => {
+ const result = await client.getPurchaseQuote('test.algo')
+
+ expect(result).toEqual({
+ nfdName: 'test.algo',
+ buyer: VALID_ADDRESS,
+ canClaim: true,
+ canBuy: false,
+ price: BigInt(0),
+ reservedFor: VALID_ADDRESS,
+ sellAmount: BigInt(1000000),
+ state: 'reserved',
+ authorized: true,
+ authorizationError: undefined,
+ })
+ })
+
+ it('should delegate claim to purchasing module and reset signer', async () => {
+ const result = await client.claim('test.algo')
+
+ expect(result).toEqual({
+ name: 'test.algo',
+ appID: 12345,
+ state: 'owned',
+ owner: VALID_ADDRESS,
+ })
+ expect(client.signer).toBeNull() // Should reset signer after operation
+ })
+
+ it('should delegate buy to purchasing module and reset signer', async () => {
+ const result = await client.buy('test.algo')
+
+ expect(result).toEqual({
+ name: 'test.algo',
+ appID: 12345,
+ state: 'owned',
+ owner: VALID_ADDRESS,
+ })
+ expect(client.signer).toBeNull() // Should reset signer after operation
+ })
+
+ it('should throw error if signer not set for getPurchaseQuote', async () => {
+ const clientWithoutSigner = NfdClient.testNet()
+
+ await expect(
+ clientWithoutSigner.getPurchaseQuote('test.algo'),
+ ).rejects.toThrow('Signer must be set before getting purchase quote')
+ })
+
+ it('should throw error if signer not set for claim', async () => {
+ const clientWithoutSigner = NfdClient.testNet()
+
+ await expect(clientWithoutSigner.claim('test.algo')).rejects.toThrow(
+ 'Signer must be set before claiming NFD',
+ )
+ })
+
+ it('should throw error if signer not set for buy', async () => {
+ const clientWithoutSigner = NfdClient.testNet()
+
+ await expect(clientWithoutSigner.buy('test.algo')).rejects.toThrow(
+ 'Signer must be set before buying NFD',
+ )
+ })
+ })
})
diff --git a/packages/sdk/tests/purchasing.test.ts b/packages/sdk/tests/purchasing.test.ts
new file mode 100644
index 0000000..f6ae3fb
--- /dev/null
+++ b/packages/sdk/tests/purchasing.test.ts
@@ -0,0 +1,472 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+import { NfdClient } from '../src/client'
+import { PurchasingModule } from '../src/modules/purchasing'
+
+import type { Nfd } from '../src/types'
+
+// Valid Algorand addresses for testing
+const BUYER_ADDRESS =
+ 'ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM'
+const SELLER_ADDRESS =
+ 'BBAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM'
+const OTHER_ADDRESS =
+ 'CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM'
+
+// Mock NFD data for different scenarios
+const mockReservedNfd: Nfd = {
+ name: 'reserved.algo',
+ appID: 123,
+ state: 'reserved',
+ reservedFor: BUYER_ADDRESS,
+ owner: 'some-nfd-account',
+ sellAmount: 500000, // 0.5 ALGO
+ properties: {
+ internal: {
+ mintingKickoffAmount: '100000', // 0.1 ALGO
+ },
+ },
+}
+
+const mockForSaleNfd: Nfd = {
+ name: 'forsale.algo',
+ appID: 456,
+ state: 'forSale',
+ sellAmount: 1000000, // 1 ALGO
+ owner: SELLER_ADDRESS,
+}
+
+const mockForSaleReservedNfd: Nfd = {
+ name: 'forsale-reserved.algo',
+ appID: 789,
+ state: 'forSale',
+ sellAmount: 2000000, // 2 ALGO
+ reservedFor: BUYER_ADDRESS,
+ owner: SELLER_ADDRESS,
+ properties: {
+ internal: {
+ mintingKickoffAmount: '500000', // 0.5 ALGO
+ },
+ },
+}
+
+const mockOwnedNfd: Nfd = {
+ name: 'owned.algo',
+ appID: 321,
+ state: 'owned',
+ owner: OTHER_ADDRESS,
+}
+
+// Mock types
+interface MockAlgorand {
+ createTransaction: {
+ payment: ReturnType
+ }
+ setSigner: ReturnType
+}
+
+interface MockInstanceClient {
+ appAddress: string
+ newGroup: ReturnType
+}
+
+interface MockSigner {
+ addr: { toString: () => string }
+ signer: ReturnType
+}
+
+// Mock Algorand client
+const mockAlgorand: MockAlgorand = {
+ createTransaction: {
+ payment: vi.fn().mockResolvedValue({ id: 'mock-txn' }),
+ },
+ setSigner: vi.fn(),
+}
+
+// Mock NFD instance client
+const mockInstanceClient: MockInstanceClient = {
+ appAddress: 'mock-app-address',
+ newGroup: vi.fn().mockReturnValue({
+ purchase: vi.fn().mockReturnValue({
+ send: vi.fn().mockResolvedValue({}),
+ }),
+ }),
+}
+
+// Mock the dependencies
+vi.mock('algosdk', () => ({
+ isValidAddress: vi.fn((addr: string) => addr.length === 58),
+ Address: {
+ fromString: vi.fn((addr: string) => {
+ // Throw error for invalid addresses (less than 58 characters)
+ if (addr.length !== 58) {
+ throw new Error('Invalid address')
+ }
+ return {
+ toString: () => addr,
+ publicKey: new Uint8Array(32),
+ }
+ }),
+ },
+}))
+
+vi.mock('@algorandfoundation/algokit-utils', () => ({
+ AlgorandClient: {
+ mainNet: vi.fn(() => mockAlgorand),
+ },
+ AlgoAmount: {
+ MicroAlgos: vi.fn((amount) => ({
+ amountInMicroAlgo: typeof amount === 'bigint' ? amount : BigInt(amount),
+ microAlgos: typeof amount === 'bigint' ? amount : BigInt(amount),
+ })),
+ },
+}))
+
+vi.mock('../src/utils/error-parser', () => ({
+ parseTransactionError: vi.fn((error) => error.message || 'Unknown error'),
+}))
+
+describe('PurchasingModule', () => {
+ let client: NfdClient
+ let purchasing: PurchasingModule
+ let mockSigner: MockSigner
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Create mock signer
+ mockSigner = {
+ addr: { toString: () => BUYER_ADDRESS },
+ signer: vi.fn(),
+ }
+
+ // Create client and purchasing module
+ client = new NfdClient()
+ client.setSigner(BUYER_ADDRESS, mockSigner.signer)
+ purchasing = new PurchasingModule(client)
+
+ // Mock client methods
+ vi.spyOn(client, 'resolve').mockImplementation(async (nameOrAppId) => {
+ if (nameOrAppId === 'reserved.algo' || nameOrAppId === 123) {
+ return mockReservedNfd
+ }
+ if (nameOrAppId === 'forsale.algo' || nameOrAppId === 456) {
+ return mockForSaleNfd
+ }
+ if (nameOrAppId === 'forsale-reserved.algo' || nameOrAppId === 789) {
+ return mockForSaleReservedNfd
+ }
+ if (nameOrAppId === 'owned.algo' || nameOrAppId === 321) {
+ return mockOwnedNfd
+ }
+ throw new Error('NFD not found')
+ })
+
+ // Mock getInstanceClient method
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(purchasing as any, 'getInstanceClient').mockReturnValue(
+ mockInstanceClient,
+ )
+ })
+
+ describe('getPurchaseQuote', () => {
+ it('should return a valid quote for a reserved NFD', async () => {
+ const quote = await purchasing.getPurchaseQuote(
+ 'reserved.algo',
+ BUYER_ADDRESS,
+ )
+
+ expect(quote).toEqual({
+ nfdName: 'reserved.algo',
+ buyer: BUYER_ADDRESS,
+ canClaim: true,
+ canBuy: false,
+ price: BigInt(400000), // sellAmount (500000) - mintingKickoffAmount (100000)
+ reservedFor: BUYER_ADDRESS,
+ sellAmount: BigInt(500000),
+ state: 'reserved',
+ authorized: true,
+ authorizationError: undefined,
+ })
+ })
+
+ it('should return a valid quote for an NFD for sale', async () => {
+ const quote = await purchasing.getPurchaseQuote(
+ 'forsale.algo',
+ BUYER_ADDRESS,
+ )
+
+ expect(quote).toEqual({
+ nfdName: 'forsale.algo',
+ buyer: BUYER_ADDRESS,
+ canClaim: false,
+ canBuy: true,
+ price: BigInt(1000000),
+ reservedFor: undefined,
+ sellAmount: BigInt(1000000),
+ state: 'forSale',
+ authorized: true,
+ authorizationError: undefined,
+ })
+ })
+
+ it('should return unauthorized for NFD reserved for someone else', async () => {
+ const quote = await purchasing.getPurchaseQuote(
+ 'reserved.algo',
+ OTHER_ADDRESS,
+ )
+
+ expect(quote).toEqual({
+ nfdName: 'reserved.algo',
+ buyer: OTHER_ADDRESS,
+ canClaim: false,
+ canBuy: false,
+ price: BigInt(0), // No price calculated since not authorized
+ reservedFor: BUYER_ADDRESS,
+ sellAmount: BigInt(500000),
+ state: 'reserved',
+ authorized: false,
+ authorizationError: `NFD is reserved for ${BUYER_ADDRESS}, but buyer is ${OTHER_ADDRESS}`,
+ })
+ })
+
+ it('should return unauthorized for owned NFD', async () => {
+ const quote = await purchasing.getPurchaseQuote(
+ 'owned.algo',
+ BUYER_ADDRESS,
+ )
+
+ expect(quote).toEqual({
+ nfdName: 'owned.algo',
+ buyer: BUYER_ADDRESS,
+ canClaim: false,
+ canBuy: false,
+ price: BigInt(0),
+ reservedFor: undefined,
+ sellAmount: undefined,
+ state: 'owned',
+ authorized: false,
+ authorizationError: 'NFD is not available for purchase (state: owned)',
+ })
+ })
+
+ it('should return a valid quote for an NFD for sale but reserved for buyer', async () => {
+ const quote = await purchasing.getPurchaseQuote(
+ 'forsale-reserved.algo',
+ BUYER_ADDRESS,
+ )
+
+ expect(quote).toEqual({
+ nfdName: 'forsale-reserved.algo',
+ buyer: BUYER_ADDRESS,
+ canClaim: true, // Should be claimable since it's reserved for the buyer
+ canBuy: false, // Cannot buy since it's reserved
+ price: BigInt(1500000), // sellAmount (2000000) - mintingKickoffAmount (500000)
+ reservedFor: BUYER_ADDRESS,
+ sellAmount: BigInt(2000000),
+ state: 'forSale',
+ authorized: true,
+ authorizationError: undefined,
+ })
+ })
+
+ it('should throw error for invalid buyer address', async () => {
+ await expect(
+ purchasing.getPurchaseQuote('reserved.algo', 'invalid-address'),
+ ).rejects.toThrow('Invalid buyer: invalid-address')
+ })
+ })
+
+ describe('claim', () => {
+ it('should successfully claim a reserved NFD', async () => {
+ const result = await purchasing.claim('reserved.algo')
+
+ expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({
+ sender: BUYER_ADDRESS,
+ receiver: 'mock-app-address',
+ amount: expect.objectContaining({
+ amountInMicroAlgo: BigInt(400000), // sellAmount (500000) - mintingKickoffAmount (100000)
+ }),
+ })
+
+ expect(mockInstanceClient.newGroup).toHaveBeenCalled()
+ expect(mockInstanceClient.newGroup().purchase).toHaveBeenCalledWith({
+ args: { payment: { id: 'mock-txn' } },
+ staticFee: expect.objectContaining({
+ amountInMicroAlgo: BigInt(4000),
+ }),
+ })
+ expect(
+ mockInstanceClient.newGroup().purchase().send,
+ ).toHaveBeenCalledWith({
+ populateAppCallResources: true,
+ })
+
+ expect(result).toEqual(mockReservedNfd)
+ })
+
+ it('should throw error if claimer is not authorized', async () => {
+ // Set up a different signer for this test
+ const otherSigner: MockSigner = {
+ addr: { toString: () => OTHER_ADDRESS },
+ signer: vi.fn(),
+ }
+ client.setSigner(OTHER_ADDRESS, otherSigner.signer)
+ const purchasing2 = new PurchasingModule(client)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(purchasing2 as any, 'getInstanceClient').mockReturnValue(
+ mockInstanceClient,
+ )
+
+ await expect(purchasing2.claim('reserved.algo')).rejects.toThrow(
+ 'NFD is reserved for ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM, but buyer is CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM',
+ )
+ })
+
+ it('should successfully claim an NFD that is for sale but reserved for claimer', async () => {
+ const result = await purchasing.claim('forsale-reserved.algo')
+
+ expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({
+ sender: BUYER_ADDRESS,
+ receiver: 'mock-app-address',
+ amount: expect.objectContaining({
+ amountInMicroAlgo: BigInt(1500000), // sellAmount (2000000) - mintingKickoffAmount (500000)
+ }),
+ })
+
+ expect(result).toEqual(mockForSaleReservedNfd)
+ })
+
+ it('should throw error for NFD that cannot be claimed', async () => {
+ await expect(purchasing.claim('forsale.algo')).rejects.toThrow(
+ 'Cannot claim NFD: forsale.algo (state: forSale)',
+ )
+ })
+ })
+
+ describe('buy', () => {
+ it('should successfully buy an NFD for sale', async () => {
+ const result = await purchasing.buy('forsale.algo')
+
+ expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({
+ sender: BUYER_ADDRESS,
+ receiver: 'mock-app-address',
+ amount: expect.objectContaining({
+ amountInMicroAlgo: BigInt(1000000),
+ }),
+ })
+
+ expect(mockInstanceClient.newGroup).toHaveBeenCalled()
+ expect(mockInstanceClient.newGroup().purchase).toHaveBeenCalledWith({
+ args: { payment: { id: 'mock-txn' } },
+ staticFee: expect.objectContaining({
+ amountInMicroAlgo: BigInt(4000),
+ }),
+ })
+ expect(
+ mockInstanceClient.newGroup().purchase().send,
+ ).toHaveBeenCalledWith({
+ populateAppCallResources: true,
+ })
+
+ expect(result).toEqual(mockForSaleNfd)
+ })
+
+ it('should throw error if buyer is not authorized', async () => {
+ // Set up a different signer for this test
+ const otherSigner: MockSigner = {
+ addr: { toString: () => OTHER_ADDRESS },
+ signer: vi.fn(),
+ }
+ client.setSigner(OTHER_ADDRESS, otherSigner.signer)
+ const purchasing2 = new PurchasingModule(client)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(purchasing2 as any, 'getInstanceClient').mockReturnValue(
+ mockInstanceClient,
+ )
+
+ await expect(purchasing2.buy('forsale-reserved.algo')).rejects.toThrow(
+ 'NFD is reserved for ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM, but buyer is CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM',
+ )
+ })
+
+ it('should throw error for NFD that cannot be bought', async () => {
+ await expect(purchasing.buy('owned.algo')).rejects.toThrow(
+ 'NFD is not available for purchase (state: owned)',
+ )
+ })
+ })
+
+ describe('canClaim', () => {
+ it('should return true for claimable NFD', async () => {
+ const result = await purchasing.canClaim('reserved.algo', BUYER_ADDRESS)
+ expect(result).toBe(true)
+ })
+
+ it('should return false for non-claimable NFD', async () => {
+ const result = await purchasing.canClaim('forsale.algo', BUYER_ADDRESS)
+ expect(result).toBe(false)
+ })
+
+ it('should return false on error', async () => {
+ const result = await purchasing.canClaim(
+ 'nonexistent.algo',
+ BUYER_ADDRESS,
+ )
+ expect(result).toBe(false)
+ })
+ })
+
+ describe('canBuy', () => {
+ it('should return true for buyable NFD', async () => {
+ const result = await purchasing.canBuy('forsale.algo', BUYER_ADDRESS)
+ expect(result).toBe(true)
+ })
+
+ it('should return false for non-buyable NFD', async () => {
+ const result = await purchasing.canBuy('owned.algo', BUYER_ADDRESS)
+ expect(result).toBe(false)
+ })
+
+ it('should return false on error', async () => {
+ const result = await purchasing.canBuy('nonexistent.algo', BUYER_ADDRESS)
+ expect(result).toBe(false)
+ })
+ })
+
+ describe('address validation', () => {
+ it('should throw error for invalid buyer address in getPurchaseQuote', async () => {
+ await expect(
+ purchasing.getPurchaseQuote('reserved.algo', 'invalid'),
+ ).rejects.toThrow('Invalid buyer: invalid')
+ })
+
+ it('should throw error for invalid buyer address in buy', async () => {
+ // Mock the signer to return an invalid address when toString() is called
+ const mockInvalidSigner: MockSigner = {
+ addr: { toString: () => 'invalid' },
+ signer: vi.fn(),
+ }
+
+ // Create a new client with a valid signer first
+ const clientWithValidSigner = new NfdClient()
+ clientWithValidSigner.setSigner(BUYER_ADDRESS, mockSigner.signer)
+
+ // Now override the signer property to use our invalid signer
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(clientWithValidSigner as any)._signer = mockInvalidSigner
+
+ const purchasing2 = new PurchasingModule(clientWithValidSigner)
+
+ // Ensure proper mocking for the new instance
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(purchasing2 as any, 'getInstanceClient').mockReturnValue(
+ mockInstanceClient,
+ )
+
+ await expect(purchasing2.buy('forsale.algo')).rejects.toThrow(
+ 'Invalid buyer: invalid',
+ )
+ })
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2523e98..a71577d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,6 +85,46 @@ importers:
specifier: ^0.23.0
version: 0.23.0(rollup@4.34.8)(vite@4.5.9(@types/node@22.13.9))
+ examples/claim-nfd:
+ dependencies:
+ '@algorandfoundation/algokit-utils':
+ specifier: ^8.2.2
+ version: 8.2.2(algosdk@3.2.0)
+ '@txnlab/nfd-sdk':
+ specifier: workspace:*
+ version: link:../../packages/sdk
+ '@txnlab/use-wallet-react':
+ specifier: ^4.0.0
+ version: 4.0.0(algosdk@3.2.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ algosdk:
+ specifier: ^3.2.0
+ version: 3.2.0
+ lute-connect:
+ specifier: ^1.4.1
+ version: 1.4.1
+ react:
+ specifier: ^18.2.0
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.3.1(react@18.3.1)
+ devDependencies:
+ '@types/react':
+ specifier: ^18.2.64
+ version: 18.3.18
+ '@types/react-dom':
+ specifier: ^18.2.21
+ version: 18.3.5(@types/react@18.3.18)
+ '@vitejs/plugin-react':
+ specifier: ^4.2.1
+ version: 4.3.4(vite@4.5.9(@types/node@22.13.9))
+ vite:
+ specifier: ^4.5.2
+ version: 4.5.9(@types/node@22.13.9)
+ vite-plugin-node-polyfills:
+ specifier: ^0.23.0
+ version: 0.23.0(rollup@4.34.8)(vite@4.5.9(@types/node@22.13.9))
+
examples/link-address:
dependencies:
'@algorandfoundation/algokit-utils':