From 81ab2c70262860295ac312211a3593fadd9ac161 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 8 Jul 2025 01:06:02 -0400 Subject: [PATCH 1/3] feat(sdk): add NFD purchasing and claiming functionality Add comprehensive purchasing module enabling NFD claiming and secondary market purchases through the SDK. Introduces `PurchasingModule` class with methods for getting purchase quotes, claiming reserved NFDs, and buying NFDs from the secondary market. Key features: - `getPurchaseQuote()` - Get pricing and eligibility information - `claim()` - Claim NFDs reserved for specific addresses - `buy()` - Purchase NFDs from secondary market - `canClaim()`/`canBuy()` - Validation helpers for purchase eligibility - Integration with existing `NFDInstanceClient.purchase()` method Includes comprehensive test suite with 23 tests covering all purchase scenarios including validation, pricing calculations, and transaction flows. Tests use proper contract patterns with `newGroup().purchase()` and `staticFee` configuration. Add `claim-nfd` example application demonstrating real-world usage with wallet connection, reserved NFD search, and claiming workflow. Updates examples README with new claim example documentation. Export new purchasing types and integrate purchasing module into main `NfdClient` via `purchasing()` method for consistent API access patterns. --- examples/README.md | 1 + examples/claim-nfd/.gitignore | 24 ++ examples/claim-nfd/README.md | 47 +++ examples/claim-nfd/eslint.config.js | 42 +++ examples/claim-nfd/index.html | 13 + examples/claim-nfd/package.json | 27 ++ examples/claim-nfd/public/vite.svg | 1 + examples/claim-nfd/src/App.tsx | 280 +++++++++++++++ examples/claim-nfd/src/Connect.tsx | 103 ++++++ examples/claim-nfd/src/index.tsx | 30 ++ examples/claim-nfd/tsconfig.app.json | 26 ++ examples/claim-nfd/tsconfig.json | 25 ++ examples/claim-nfd/tsconfig.node.json | 10 + examples/claim-nfd/vite.config.ts | 15 + packages/sdk/src/client.ts | 9 + packages/sdk/src/index.ts | 8 + packages/sdk/src/modules/purchasing.ts | 365 +++++++++++++++++++ packages/sdk/tests/purchasing.test.ts | 463 +++++++++++++++++++++++++ pnpm-lock.yaml | 40 +++ 19 files changed, 1529 insertions(+) create mode 100644 examples/claim-nfd/.gitignore create mode 100644 examples/claim-nfd/README.md create mode 100644 examples/claim-nfd/eslint.config.js create mode 100644 examples/claim-nfd/index.html create mode 100644 examples/claim-nfd/package.json create mode 100644 examples/claim-nfd/public/vite.svg create mode 100644 examples/claim-nfd/src/App.tsx create mode 100644 examples/claim-nfd/src/Connect.tsx create mode 100644 examples/claim-nfd/src/index.tsx create mode 100644 examples/claim-nfd/tsconfig.app.json create mode 100644 examples/claim-nfd/tsconfig.json create mode 100644 examples/claim-nfd/tsconfig.node.json create mode 100644 examples/claim-nfd/vite.config.ts create mode 100644 packages/sdk/src/modules/purchasing.ts create mode 100644 packages/sdk/tests/purchasing.test.ts 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..28831e4 --- /dev/null +++ b/examples/claim-nfd/README.md @@ -0,0 +1,47 @@ +# 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 purchasing module +- 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 + +## Important Notes + +- This example runs on Algorand TestNet +- You need TestNet ALGO in your wallet to pay for transaction fees +- Reserved NFDs can be claimed for free (0 ALGO), but transaction fees still apply +- 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..6fbaf85 --- /dev/null +++ b/examples/claim-nfd/src/App.tsx @@ -0,0 +1,280 @@ +import { NfdClient, type Nfd, type SearchResponse } from '@txnlab/nfd-sdk' +import { useWallet } from '@txnlab/use-wallet-react' +import { useState, useEffect } 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 = 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) + } + } + + /** + * 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 purchasing module to claim the NFD + const claimedNfd = await nfd + .setSigner(activeAddress, transactionSigner) + .purchasing() + .claim(nfdName, { claimer: activeAddress }) + + 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]) + + return ( +
+

NFD Claim Tool

+

Claim NFDs that are reserved for your wallet address

+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {activeAddress && ( +
+
+

Connected Address

+ + {activeAddress} + +
+ +
+ +
+ +
+

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) && ( + + )} +
+
+
+ ))} +
+ )} +
+
+ )} + + {!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 ( + + ) +} + +const ConnectedWallet = ({ wallet }: { wallet: Wallet }) => { + return ( +
+
+ {wallet.metadata.name} + {wallet.metadata.name} +
+ + {wallet.accounts.length > 1 && ( + + )} + + {wallet.activeAccount && ( +
+ Active Account: {wallet.activeAccount.address} +
+ )} + +
+ +
+
+ ) +} 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/src/client.ts b/packages/sdk/src/client.ts index c458537..94ae0b1 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 } from './modules/purchasing' import type { Nfd, @@ -138,6 +139,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 diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2455080..5cccff2 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,13 @@ export type { NfdMintParams, } from './modules/minting' +export type { + NfdClaimParams, + NfdBuyParams, + NfdPurchaseQuoteParams, + 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..8328062 --- /dev/null +++ b/packages/sdk/src/modules/purchasing.ts @@ -0,0 +1,365 @@ +import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' +import { isValidAddress } from 'algosdk' + +import { parseTransactionError } from '../utils/error-parser' + +import { BaseModule } from './base' + +import type { Nfd } from '../types' + +/** + * Configuration options for claiming a reserved NFD + */ +export interface NfdClaimParams { + /** + * The address of the claimer (must match the reservedFor address) + */ + claimer: string +} + +/** + * Configuration options for buying an NFD from the secondary market + */ +export interface NfdBuyParams { + /** + * The address of the buyer + */ + buyer: string + + /** + * Optional maximum amount willing to pay in microAlgos. + * If not provided, will pay the exact sell amount. + * If provided and the sell amount is higher, the transaction will fail. + */ + maxPayment?: bigint | number +} + +/** + * Configuration options for getting a purchase quote + */ +export interface NfdPurchaseQuoteParams { + /** + * The address of the potential buyer/claimer + */ + buyer: string +} + +/** + * Purchase quote information for an NFD + */ +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 NFD purchasing and claiming operations + */ +export class PurchasingModule extends BaseModule { + /** + * Validate that the provided address is valid + * @internal + * @param address - The address to validate + * @param paramName - Name of the parameter being validated (for error messages) + * @throws If the address is invalid + */ + private validateAddress(address: string, paramName: string): void { + if (!isValidAddress(address)) { + throw new Error(`Invalid ${paramName}: ${address}`) + } + } + + /** + * Get detailed purchase information for an NFD + * @param nameOrAppId - The NFD name or application ID + * @param params - Parameters for the quote + * @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, + params: NfdPurchaseQuoteParams, + ): Promise { + const { buyer } = params + + // 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 + * @param params - Parameters for claiming + * @returns The claimed NFD record + * @throws If the claim operation fails + */ + public async claim( + nameOrAppId: string | number | bigint, + params: NfdClaimParams, + ): Promise { + // Ensure a signer is set + const signer = this.requireSigner() + + const { claimer } = params + + // Validate claimer address + this.validateAddress(claimer, 'claimer') + + // Ensure the signer matches the claimer + if (signer.addr.toString() !== claimer) { + throw new Error( + `Signer address (${signer.addr.toString()}) does not match claimer address (${claimer})`, + ) + } + + // Get purchase quote to validate eligibility + const quote = await this.getPurchaseQuote(nameOrAppId, { buyer: 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 + * @param params - Parameters for buying + * @returns The purchased NFD record + * @throws If the buy operation fails + */ + public async buy( + nameOrAppId: string | number | bigint, + params: NfdBuyParams, + ): Promise { + // Ensure a signer is set + const signer = this.requireSigner() + + const { buyer, maxPayment } = params + + // Validate buyer address + this.validateAddress(buyer, 'buyer') + + // Ensure the signer matches the buyer + if (signer.addr.toString() !== buyer) { + throw new Error( + `Signer address (${signer.addr.toString()}) does not match buyer address (${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}`, + ) + } + + // Validate max payment if provided + if (maxPayment !== undefined && quote.price > BigInt(maxPayment)) { + throw new Error( + `NFD price (${quote.price} microAlgos) exceeds maximum payment (${maxPayment} microAlgos)`, + ) + } + + // 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 of the potential claimer + * @returns True if the NFD can be claimed by the address + */ + public async canClaim( + nameOrAppId: string | number | bigint, + claimer: string, + ): Promise { + try { + const quote = await this.getPurchaseQuote(nameOrAppId, { buyer: 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 of the potential buyer + * @returns True if the NFD can be bought by the address + */ + 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/purchasing.test.ts b/packages/sdk/tests/purchasing.test.ts new file mode 100644 index 0000000..47192d8 --- /dev/null +++ b/packages/sdk/tests/purchasing.test.ts @@ -0,0 +1,463 @@ +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 Algorand client +const mockAlgorand = { + createTransaction: { + payment: vi.fn().mockResolvedValue({ id: 'mock-txn' }), + }, + setSigner: vi.fn(), +} as any + +// Mock NFD instance client +const mockInstanceClient = { + appAddress: 'mock-app-address', + newGroup: vi.fn().mockReturnValue({ + purchase: vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue({}), + }), + }), +} as any + +// Mock the dependencies +vi.mock('algosdk', () => ({ + isValidAddress: vi.fn((addr: string) => addr.length === 58), + Address: { + fromString: vi.fn((addr: string) => ({ + 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: any + + 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 + 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: 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: 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', { + buyer: 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: 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: 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', { + buyer: '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', { + claimer: BUYER_ADDRESS, + }) + + 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 = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const purchasing2 = new PurchasingModule(client) + vi.spyOn(purchasing2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect( + purchasing2.claim('reserved.algo', { + claimer: OTHER_ADDRESS, + }), + ).rejects.toThrow( + 'NFD is reserved for ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM, but buyer is CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM', + ) + }) + + it('should throw error if signer does not match claimer', async () => { + await expect( + purchasing.claim('reserved.algo', { + claimer: OTHER_ADDRESS, + }), + ).rejects.toThrow( + `Signer address (${BUYER_ADDRESS}) does not match claimer address (${OTHER_ADDRESS})`, + ) + }) + + it('should successfully claim an NFD that is for sale but reserved for claimer', async () => { + const result = await purchasing.claim('forsale-reserved.algo', { + claimer: BUYER_ADDRESS, + }) + + 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', { + claimer: BUYER_ADDRESS, + }), + ).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', { + buyer: BUYER_ADDRESS, + }) + + 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 respect maxPayment limit', async () => { + await expect( + purchasing.buy('forsale.algo', { + buyer: BUYER_ADDRESS, + maxPayment: 500000, // 0.5 ALGO, less than the 1 ALGO price + }), + ).rejects.toThrow( + 'NFD price (1000000 microAlgos) exceeds maximum payment (500000 microAlgos)', + ) + }) + + it('should throw error if buyer is not authorized', async () => { + // Set up a different signer for this test + const otherSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const purchasing2 = new PurchasingModule(client) + vi.spyOn(purchasing2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect( + purchasing2.buy('forsale-reserved.algo', { + buyer: OTHER_ADDRESS, + }), + ).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', { + buyer: BUYER_ADDRESS, + }), + ).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 claimer address', async () => { + await expect( + purchasing.claim('reserved.algo', { + claimer: 'invalid', + }), + ).rejects.toThrow('Invalid claimer: invalid') + }) + + it('should throw error for invalid buyer address', async () => { + await expect( + purchasing.buy('forsale.algo', { + buyer: 'invalid', + }), + ).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': From 08bf1dca7648e888cc734906d4d6923b5332c1b7 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 8 Jul 2025 01:57:51 -0400 Subject: [PATCH 2/3] refactor(purchasing): simplify API with direct client methods Move purchasing methods directly to `NfdClient` for cleaner API usage. Users can now call `nfd.claim()` and `nfd.buy()` directly instead of accessing the purchasing module. The client automatically uses the signer's address and resets the signer after operations. Before: `nfd.purchasing().claim(name, {claimer: address})` After: `nfd.claim(name)` Remove parameter interfaces `NfdClaimParams` and `NfdBuyParams` from public exports. Update example app to demonstrate the simplified API. --- examples/claim-nfd/README.md | 15 +- examples/claim-nfd/src/App.tsx | 13 +- packages/sdk/src/client.ts | 61 +++++++- packages/sdk/src/index.ts | 7 +- packages/sdk/src/modules/purchasing.ts | 118 ++++----------- packages/sdk/tests/client.test.ts | 107 +++++++++++++- packages/sdk/tests/purchasing.test.ts | 189 +++++++++++++------------ 7 files changed, 308 insertions(+), 202 deletions(-) diff --git a/examples/claim-nfd/README.md b/examples/claim-nfd/README.md index 28831e4..7504dc4 100644 --- a/examples/claim-nfd/README.md +++ b/examples/claim-nfd/README.md @@ -6,7 +6,7 @@ This example demonstrates how to use the NFD SDK to claim NFDs that are reserved - 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 purchasing module +- Claim reserved NFDs using the NFD SDK's claiming API - Simple and clean UI for managing the claiming process ## Getting Started @@ -31,6 +31,19 @@ npm run dev 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 diff --git a/examples/claim-nfd/src/App.tsx b/examples/claim-nfd/src/App.tsx index 6fbaf85..5291941 100644 --- a/examples/claim-nfd/src/App.tsx +++ b/examples/claim-nfd/src/App.tsx @@ -1,6 +1,6 @@ import { NfdClient, type Nfd, type SearchResponse } from '@txnlab/nfd-sdk' import { useWallet } from '@txnlab/use-wallet-react' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { WalletMenu } from './Connect' @@ -21,7 +21,7 @@ export function App() { /** * Search for NFDs reserved for the active address */ - const searchReservedNfds = async () => { + const searchReservedNfds = useCallback(async () => { if (!activeAddress) return setIsLoading(true) @@ -43,7 +43,7 @@ export function App() { } finally { setIsLoading(false) } - } + }, [activeAddress]) /** * Claim a reserved NFD @@ -58,11 +58,10 @@ export function App() { setError('') try { - // Use the purchasing module to claim the NFD + // Use the simplified API to claim the NFD directly const claimedNfd = await nfd .setSigner(activeAddress, transactionSigner) - .purchasing() - .claim(nfdName, { claimer: activeAddress }) + .claim(nfdName) console.log('Successfully claimed NFD:', claimedNfd) @@ -90,7 +89,7 @@ export function App() { setError('') setClaimedNfds(new Set()) } - }, [activeAddress]) + }, [activeAddress, searchReservedNfds]) return (
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 94ae0b1..2be97fa 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -12,7 +12,7 @@ import { NfdMintQuote, NfdMintQuoteParams, } from './modules/minting' -import { PurchasingModule } from './modules/purchasing' +import { PurchasingModule, NfdPurchaseQuote } from './modules/purchasing' import type { Nfd, @@ -47,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 @@ -58,6 +59,7 @@ export class NfdClient { // Initialize modules this._lookup = new LookupModule(this) this._minting = new MintingModule(this) + this._purchasing = new PurchasingModule(this) } /** @@ -191,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 5cccff2..3df704b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -27,12 +27,7 @@ export type { NfdMintParams, } from './modules/minting' -export type { - NfdClaimParams, - NfdBuyParams, - NfdPurchaseQuoteParams, - NfdPurchaseQuote, -} from './modules/purchasing' +export type { NfdPurchaseQuote } from './modules/purchasing' // Export NFD utility functions export { diff --git a/packages/sdk/src/modules/purchasing.ts b/packages/sdk/src/modules/purchasing.ts index 8328062..737711e 100644 --- a/packages/sdk/src/modules/purchasing.ts +++ b/packages/sdk/src/modules/purchasing.ts @@ -1,51 +1,13 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' -import { isValidAddress } from 'algosdk' +import { Address } from 'algosdk' +import { Nfd } from '../types' import { parseTransactionError } from '../utils/error-parser' import { BaseModule } from './base' -import type { Nfd } from '../types' - -/** - * Configuration options for claiming a reserved NFD - */ -export interface NfdClaimParams { - /** - * The address of the claimer (must match the reservedFor address) - */ - claimer: string -} - -/** - * Configuration options for buying an NFD from the secondary market - */ -export interface NfdBuyParams { - /** - * The address of the buyer - */ - buyer: string - - /** - * Optional maximum amount willing to pay in microAlgos. - * If not provided, will pay the exact sell amount. - * If provided and the sell amount is higher, the transaction will fail. - */ - maxPayment?: bigint | number -} - -/** - * Configuration options for getting a purchase quote - */ -export interface NfdPurchaseQuoteParams { - /** - * The address of the potential buyer/claimer - */ - buyer: string -} - /** - * Purchase quote information for an NFD + * Response structure for purchase quote requests */ export interface NfdPurchaseQuote { /** The NFD name being quoted */ @@ -71,35 +33,32 @@ export interface NfdPurchaseQuote { } /** - * Module for NFD purchasing and claiming operations + * Module for handling NFD purchasing operations (claiming and buying) */ export class PurchasingModule extends BaseModule { /** - * Validate that the provided address is valid - * @internal - * @param address - The address to validate - * @param paramName - Name of the parameter being validated (for error messages) - * @throws If the address is invalid + * Validate an Algorand address + * @private */ private validateAddress(address: string, paramName: string): void { - if (!isValidAddress(address)) { + try { + Address.fromString(address) + } catch { throw new Error(`Invalid ${paramName}: ${address}`) } } /** - * Get detailed purchase information for an NFD + * Get a purchase quote for an NFD * @param nameOrAppId - The NFD name or application ID - * @param params - Parameters for the quote + * @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, - params: NfdPurchaseQuoteParams, + buyer: string, ): Promise { - const { buyer } = params - // Validate buyer address this.validateAddress(buyer, 'buyer') @@ -171,31 +130,20 @@ export class PurchasingModule extends BaseModule { /** * Claim an NFD that is reserved for the caller * @param nameOrAppId - The NFD name or application ID to claim - * @param params - Parameters for claiming * @returns The claimed NFD record * @throws If the claim operation fails */ - public async claim( - nameOrAppId: string | number | bigint, - params: NfdClaimParams, - ): Promise { + public async claim(nameOrAppId: string | number | bigint): Promise { // Ensure a signer is set const signer = this.requireSigner() - const { claimer } = params + const claimer = signer.addr.toString() // Validate claimer address this.validateAddress(claimer, 'claimer') - // Ensure the signer matches the claimer - if (signer.addr.toString() !== claimer) { - throw new Error( - `Signer address (${signer.addr.toString()}) does not match claimer address (${claimer})`, - ) - } - // Get purchase quote to validate eligibility - const quote = await this.getPurchaseQuote(nameOrAppId, { buyer: claimer }) + const quote = await this.getPurchaseQuote(nameOrAppId, claimer) if (!quote.canClaim) { throw new Error( @@ -247,31 +195,20 @@ export class PurchasingModule extends BaseModule { /** * Buy an NFD from the secondary market * @param nameOrAppId - The NFD name or application ID to buy - * @param params - Parameters for buying * @returns The purchased NFD record * @throws If the buy operation fails */ - public async buy( - nameOrAppId: string | number | bigint, - params: NfdBuyParams, - ): Promise { + public async buy(nameOrAppId: string | number | bigint): Promise { // Ensure a signer is set const signer = this.requireSigner() - const { buyer, maxPayment } = params + const buyer = signer.addr.toString() // Validate buyer address this.validateAddress(buyer, 'buyer') - // Ensure the signer matches the buyer - if (signer.addr.toString() !== buyer) { - throw new Error( - `Signer address (${signer.addr.toString()}) does not match buyer address (${buyer})`, - ) - } - // Get purchase quote to validate eligibility and get pricing - const quote = await this.getPurchaseQuote(nameOrAppId, { buyer }) + const quote = await this.getPurchaseQuote(nameOrAppId, buyer) if (!quote.canBuy) { throw new Error( @@ -287,13 +224,6 @@ export class PurchasingModule extends BaseModule { ) } - // Validate max payment if provided - if (maxPayment !== undefined && quote.price > BigInt(maxPayment)) { - throw new Error( - `NFD price (${quote.price} microAlgos) exceeds maximum payment (${maxPayment} microAlgos)`, - ) - } - // Resolve NFD to get app ID const nfd = await this.client.resolve(nameOrAppId, { view: 'full' }) if (!nfd.appID) { @@ -330,15 +260,15 @@ export class PurchasingModule extends BaseModule { /** * Check if an NFD can be claimed by a specific address * @param nameOrAppId - The NFD name or application ID - * @param claimer - The address of the potential claimer - * @returns True if the NFD can be claimed by the address + * @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, { buyer: claimer }) + const quote = await this.getPurchaseQuote(nameOrAppId, claimer) return quote.canClaim && quote.authorized } catch { return false @@ -348,15 +278,15 @@ export class PurchasingModule extends BaseModule { /** * Check if an NFD can be bought by a specific address * @param nameOrAppId - The NFD name or application ID - * @param buyer - The address of the potential buyer - * @returns True if the NFD can be bought by the address + * @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 }) + 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 index 47192d8..f6ae3fb 100644 --- a/packages/sdk/tests/purchasing.test.ts +++ b/packages/sdk/tests/purchasing.test.ts @@ -57,32 +57,56 @@ const mockOwnedNfd: Nfd = { 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 = { +const mockAlgorand: MockAlgorand = { createTransaction: { payment: vi.fn().mockResolvedValue({ id: 'mock-txn' }), }, setSigner: vi.fn(), -} as any +} // Mock NFD instance client -const mockInstanceClient = { +const mockInstanceClient: MockInstanceClient = { appAddress: 'mock-app-address', newGroup: vi.fn().mockReturnValue({ purchase: vi.fn().mockReturnValue({ send: vi.fn().mockResolvedValue({}), }), }), -} as any +} // Mock the dependencies vi.mock('algosdk', () => ({ isValidAddress: vi.fn((addr: string) => addr.length === 58), Address: { - fromString: vi.fn((addr: string) => ({ - toString: () => addr, - publicKey: new Uint8Array(32), - })), + 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), + } + }), }, })) @@ -105,7 +129,7 @@ vi.mock('../src/utils/error-parser', () => ({ describe('PurchasingModule', () => { let client: NfdClient let purchasing: PurchasingModule - let mockSigner: any + let mockSigner: MockSigner beforeEach(() => { vi.clearAllMocks() @@ -139,6 +163,7 @@ describe('PurchasingModule', () => { }) // Mock getInstanceClient method + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(purchasing as any, 'getInstanceClient').mockReturnValue( mockInstanceClient, ) @@ -146,9 +171,10 @@ describe('PurchasingModule', () => { describe('getPurchaseQuote', () => { it('should return a valid quote for a reserved NFD', async () => { - const quote = await purchasing.getPurchaseQuote('reserved.algo', { - buyer: BUYER_ADDRESS, - }) + const quote = await purchasing.getPurchaseQuote( + 'reserved.algo', + BUYER_ADDRESS, + ) expect(quote).toEqual({ nfdName: 'reserved.algo', @@ -165,9 +191,10 @@ describe('PurchasingModule', () => { }) it('should return a valid quote for an NFD for sale', async () => { - const quote = await purchasing.getPurchaseQuote('forsale.algo', { - buyer: BUYER_ADDRESS, - }) + const quote = await purchasing.getPurchaseQuote( + 'forsale.algo', + BUYER_ADDRESS, + ) expect(quote).toEqual({ nfdName: 'forsale.algo', @@ -184,9 +211,10 @@ describe('PurchasingModule', () => { }) it('should return unauthorized for NFD reserved for someone else', async () => { - const quote = await purchasing.getPurchaseQuote('reserved.algo', { - buyer: OTHER_ADDRESS, - }) + const quote = await purchasing.getPurchaseQuote( + 'reserved.algo', + OTHER_ADDRESS, + ) expect(quote).toEqual({ nfdName: 'reserved.algo', @@ -203,9 +231,10 @@ describe('PurchasingModule', () => { }) it('should return unauthorized for owned NFD', async () => { - const quote = await purchasing.getPurchaseQuote('owned.algo', { - buyer: BUYER_ADDRESS, - }) + const quote = await purchasing.getPurchaseQuote( + 'owned.algo', + BUYER_ADDRESS, + ) expect(quote).toEqual({ nfdName: 'owned.algo', @@ -222,9 +251,10 @@ describe('PurchasingModule', () => { }) 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: BUYER_ADDRESS, - }) + const quote = await purchasing.getPurchaseQuote( + 'forsale-reserved.algo', + BUYER_ADDRESS, + ) expect(quote).toEqual({ nfdName: 'forsale-reserved.algo', @@ -242,18 +272,14 @@ describe('PurchasingModule', () => { it('should throw error for invalid buyer address', async () => { await expect( - purchasing.getPurchaseQuote('reserved.algo', { - buyer: 'invalid-address', - }), + 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', { - claimer: BUYER_ADDRESS, - }) + const result = await purchasing.claim('reserved.algo') expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ sender: BUYER_ADDRESS, @@ -281,39 +307,24 @@ describe('PurchasingModule', () => { it('should throw error if claimer is not authorized', async () => { // Set up a different signer for this test - const otherSigner = { + 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', { - claimer: OTHER_ADDRESS, - }), - ).rejects.toThrow( + await expect(purchasing2.claim('reserved.algo')).rejects.toThrow( 'NFD is reserved for ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM, but buyer is CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM', ) }) - it('should throw error if signer does not match claimer', async () => { - await expect( - purchasing.claim('reserved.algo', { - claimer: OTHER_ADDRESS, - }), - ).rejects.toThrow( - `Signer address (${BUYER_ADDRESS}) does not match claimer address (${OTHER_ADDRESS})`, - ) - }) - it('should successfully claim an NFD that is for sale but reserved for claimer', async () => { - const result = await purchasing.claim('forsale-reserved.algo', { - claimer: BUYER_ADDRESS, - }) + const result = await purchasing.claim('forsale-reserved.algo') expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ sender: BUYER_ADDRESS, @@ -327,19 +338,15 @@ describe('PurchasingModule', () => { }) it('should throw error for NFD that cannot be claimed', async () => { - await expect( - purchasing.claim('forsale.algo', { - claimer: BUYER_ADDRESS, - }), - ).rejects.toThrow('Cannot claim NFD: forsale.algo (state: forSale)') + 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', { - buyer: BUYER_ADDRESS, - }) + const result = await purchasing.buy('forsale.algo') expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ sender: BUYER_ADDRESS, @@ -365,44 +372,28 @@ describe('PurchasingModule', () => { expect(result).toEqual(mockForSaleNfd) }) - it('should respect maxPayment limit', async () => { - await expect( - purchasing.buy('forsale.algo', { - buyer: BUYER_ADDRESS, - maxPayment: 500000, // 0.5 ALGO, less than the 1 ALGO price - }), - ).rejects.toThrow( - 'NFD price (1000000 microAlgos) exceeds maximum payment (500000 microAlgos)', - ) - }) - it('should throw error if buyer is not authorized', async () => { // Set up a different signer for this test - const otherSigner = { + 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', { - buyer: OTHER_ADDRESS, - }), - ).rejects.toThrow( + 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', { - buyer: BUYER_ADDRESS, - }), - ).rejects.toThrow('NFD is not available for purchase (state: owned)') + await expect(purchasing.buy('owned.algo')).rejects.toThrow( + 'NFD is not available for purchase (state: owned)', + ) }) }) @@ -444,20 +435,38 @@ describe('PurchasingModule', () => { }) describe('address validation', () => { - it('should throw error for invalid claimer address', async () => { + it('should throw error for invalid buyer address in getPurchaseQuote', async () => { await expect( - purchasing.claim('reserved.algo', { - claimer: 'invalid', - }), - ).rejects.toThrow('Invalid claimer: invalid') + purchasing.getPurchaseQuote('reserved.algo', 'invalid'), + ).rejects.toThrow('Invalid buyer: invalid') }) - it('should throw error for invalid buyer address', async () => { - await expect( - purchasing.buy('forsale.algo', { - buyer: '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', + ) }) }) }) From f611a406a92083ee372ed1e1b376a1ae2a9a8391 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 8 Jul 2025 02:03:39 -0400 Subject: [PATCH 3/3] docs: add purchasing examples and fix claiming cost info Add comprehensive examples for the new simplified purchasing API (`claim` and `buy` methods) to both root and package README files. Update `claim-nfd` example documentation to correctly reflect that claiming may have costs based on NFD pricing rather than being free. Examples demonstrate the streamlined API where signer address is automatically used without requiring explicit parameter passing. --- README.md | 31 +++++++++++++++++++++++++++++++ examples/claim-nfd/README.md | 2 +- packages/sdk/README.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) 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/claim-nfd/README.md b/examples/claim-nfd/README.md index 7504dc4..fe8fb41 100644 --- a/examples/claim-nfd/README.md +++ b/examples/claim-nfd/README.md @@ -48,7 +48,7 @@ The NFD SDK automatically uses your connected wallet address as the claimer, mak - This example runs on Algorand TestNet - You need TestNet ALGO in your wallet to pay for transaction fees -- Reserved NFDs can be claimed for free (0 ALGO), but transaction fees still apply +- 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 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