From c39edb7fac91107b761669a37ea1d5e691ce0f68 Mon Sep 17 00:00:00 2001 From: Patrick Bennett Date: Tue, 7 Apr 2026 15:01:26 -0400 Subject: [PATCH 1/5] feat(sdk): add NFD management, offers, verification, and suggestions Expand the SDK with NFD lifecycle management, marketplace offers, property verification, and name suggestion capabilities. These features complete the core NFD owner experience beyond minting and purchasing. NfdManager additions: - Add getRenewalPrice() and renew() for NFD renewal with configurable year count (1-20) - Add listForSale() and cancelSale() for marketplace listing with optional address reservation - Add lockSegment() for toggling segment minting with USD pricing - Add lockVault() for controlling vault opt-in permissions - Add sendToVault() for opting vault into assets with optional transfer (supports both ALGO and ASAs) - Add sendFromVault() for sending assets from vault to a receiver PurchasingModule additions: - Add makeOffer() to post offers on NFDs via the instance contract's postOffer method NfdApiClient additions: - Add suggest() for name suggestion/autocomplete with buyer eligibility filtering - Add verifyRequest() and verifyConfirm() for the two-step property verification flow (twitter, github, domain, email, bluesky, avatar, banner) NfdClient additions: - Add suggest(), makeOffer(), verifyRequest(), verifyConfirm() as top-level convenience methods with signer management - verifyRequest resets signer after use; verifyConfirm does not require a signer Type and export additions: - Add SuggestOptions, ListForSaleOptions, SendToVaultOptions, SendFromVaultOptions, VerifyField, VerifyRequestResult, VerifyConfirmResult types - Add ALGORAND_ZERO_ADDRESS constant for default sale reservation Test updates: - Add client-level tests for makeOffer, suggest, verifyRequest, verifyConfirm including signer reset behavior - Add PurchasingModule tests for makeOffer with amount coercion, missing appID, and error wrapping - Add new manager.test.ts test file (untracked) Other: - Add CLAUDE.md with project documentation for Claude Code --- CLAUDE.md | 95 +++ packages/sdk/src/api-client.ts | 81 ++- packages/sdk/src/client.ts | 80 ++- packages/sdk/src/constants.ts | 4 + packages/sdk/src/index.ts | 7 + packages/sdk/src/modules/manager.ts | 400 ++++++++++++- packages/sdk/src/modules/purchasing.ts | 43 ++ packages/sdk/src/types.ts | 106 +++- packages/sdk/tests/client.test.ts | 98 +++- packages/sdk/tests/modules/manager.test.ts | 551 ++++++++++++++++++ packages/sdk/tests/modules/purchasing.test.ts | 68 +++ 11 files changed, 1527 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/sdk/tests/modules/manager.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c32c9b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +NFDomains SDK (`@txnlab/nfd-sdk`) — a TypeScript SDK for interacting with Non-Fungible Domains (NFDs) on the Algorand blockchain. Provides NFD resolution, minting, purchasing, and management via both on-chain smart contract calls and an HTTP API. + +## Monorepo Structure + +- **`packages/sdk/`** — the SDK package (published as `@txnlab/nfd-sdk`) +- **`examples/`** — React/Vite example apps demonstrating SDK features +- Package manager: **pnpm v9+** with workspaces +- Node version: **22.14.0** (see `.nvmrc`) + +## Commands + +All commands run from the repo root unless noted. + +```bash +pnpm install # Install dependencies +pnpm build # Build the SDK (Vite → dist/esm + dist/cjs) +pnpm test # Run tests once (Vitest) +pnpm test:watch # Run tests in watch mode +pnpm test:coverage # Run tests with v8 coverage +pnpm lint # ESLint across all packages +pnpm format # Prettier --write across all packages +pnpm typecheck # TypeScript type checking (SDK only) +pnpm build:examples # Build all example apps +``` + +Run a single test file: +```bash +pnpm --filter @txnlab/nfd-sdk exec vitest run tests/utils/nfd.test.ts +``` + +## Code Generation + +The SDK has auto-generated code from two sources: + +1. **OpenAPI client** (`src/api/*.gen.ts`) — generated from `src/api/openapi3.yaml` via `@hey-api/client-fetch` +2. **Algorand contract clients** (`src/contracts/NFD*Client.ts`) — generated from ARC-56 JSON specs in `src/contracts/minimal/` + +Regenerate all: `pnpm --filter @txnlab/nfd-sdk generate` + +**Do not hand-edit generated files.** The contract client files (`NFDInstanceClient.ts`, `NFDRegistryClient.ts`) are excluded from tsconfig compilation and are very large (~150KB each). + +## Architecture + +### Client & Module Pattern + +`NfdClient` (`src/client.ts`) is the main entry point. It composes feature modules: + +- **LookupModule** (`modules/lookup.ts`) — resolve NFD names/app IDs, reverse lookups, search +- **MetadataModule** (`modules/metadata.ts`) — avatar/banner image retrieval with IPFS conversion +- **MintingModule** (`modules/minting.ts`) — mint new NFDs with price quoting +- **PurchasingModule** (`modules/purchasing.ts`) — claim reserved NFDs, buy from marketplace +- **NfdManager** (`modules/manager.ts`) — link addresses, set metadata, set primary NFD + +All modules extend `BaseModule` (`modules/base.ts`), which provides access to the Algorand client, registry contract client, instance contract client, and signer management. + +### Key Design Patterns + +- **Fluent signer API**: `nfd.setSigner(addr, signer)` returns the client for chaining. The signer auto-resets after operations. +- **Network presets**: `NfdClient.mainNet()` / `NfdClient.testNet()` with correct registry app IDs and API URLs. +- **Dual output**: Build produces both ESM and CJS bundles. Contract clients are split into a separate `nfd-contracts` chunk. + +### API Client + +`NfdApiClient` (`src/api-client.ts`) wraps the generated OpenAPI client for NFD HTTP API calls (resolve, search, reverse lookup). Configured per-network with MainNet/TestNet base URLs defined in `src/constants.ts`. + +### Constants + +`src/constants.ts` contains registry app IDs (`NfdRegistryId` enum: MAINNET=760937186, TESTNET=84366825) and API base URLs. + +## Code Style + +- **No semicolons**, single quotes, trailing commas (Prettier config in `.prettierrc`) +- **Import ordering** enforced by `eslint-plugin-import-x`: internal (`@/`) before external, alphabetical within groups, newlines between groups +- Path alias: `@/*` maps to `./src/*` +- Unused variables prefixed with `_` are allowed; other unused variables error + +## Commit Conventions + +Angular commit format: `type(scope): subject` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore` +Scopes: `core`, `api`, `contracts`, `metadata`, `purchasing`, etc. + +`feat` → minor version bump, `fix` → patch, `BREAKING CHANGE` in footer → major (manual). + +## CI + +PR checks (`pr.yml`): lint → typecheck → test → build → build:examples. All must pass. +Publishing (`ci.yml`): automatic on push to main/alpha/beta/next branches via TanStack publish config. diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index 2ead793..d7c19e2 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -1,5 +1,12 @@ import { client } from './api/client.gen' -import { nfdGetLookup, nfdGetNfd, nfdSearchV2 } from './api/sdk.gen' +import { + nfdGetLookup, + nfdGetNfd, + nfdSearchV2, + nfdSuggest, + nfdVerifyConfirm, + nfdVerifyRequest, +} from './api/sdk.gen' import { NfdApiBaseUrl, NfdRegistryId } from './constants' import { chunkArray } from './utils/internal/array' @@ -7,8 +14,12 @@ import type { Nfd, SearchOptions, SearchResponse, + SuggestOptions, ReverseLookupOptions, ResolveOptions, + VerifyField, + VerifyRequestResult, + VerifyConfirmResult, } from './types' /** @@ -213,6 +224,74 @@ export class NfdApiClient { } } + /** + * Get name suggestions for NFD registration + * @param name - The name (even partial) to search for + * @param options - Suggestion options including the buyer address + * @returns Array of suggested NFD records + */ + public async suggest(name: string, options: SuggestOptions): Promise { + const response = await nfdSuggest({ + client: this._client, + path: { name }, + query: { + buyer: options.buyer, + limit: options.limit, + view: options.view, + }, + throwOnError: true, + }) + + return (response.data ?? []) as Nfd[] + } + + /** + * Start a verification request for an NFD property + * @param name - The NFD name to verify a property for + * @param sender - The NFD owner's address + * @param field - The field to verify + * @returns Verification request result with challenge and ID + */ + public async verifyRequest( + name: string, + sender: string, + field: VerifyField, + ): Promise { + const response = await nfdVerifyRequest({ + client: this._client, + body: { + name, + sender, + fieldToVerify: field, + }, + throwOnError: true, + }) + + return response.data + } + + /** + * Confirm a verification request + * @param id - The verification request ID + * @param challenge - The challenge value (optional depending on verification type) + * @returns Verification confirmation result + */ + public async verifyConfirm( + id: string, + challenge?: string, + ): Promise { + const response = await nfdVerifyConfirm({ + client: this._client, + path: { id }, + body: { + challenge, + }, + throwOnError: true, + }) + + return response.data + } + /** * Internal method for cache parameter * @private diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index ad410c2..afa8d60 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -13,15 +13,19 @@ import { NfdMintQuote, NfdMintQuoteParams, } from './modules/minting' -import { PurchasingModule, NfdPurchaseQuote } from './modules/purchasing' +import { NfdPurchaseQuote, PurchasingModule } from './modules/purchasing' import type { Nfd, NfdImageResult, ResolveOptions, + ReverseLookupOptions, SearchOptions, SearchResponse, - ReverseLookupOptions, + SuggestOptions, + VerifyConfirmResult, + VerifyField, + VerifyRequestResult, } from './types' /** @@ -398,4 +402,76 @@ export class NfdClient { return this._metadata.getBannerImage(input) } } + /** + * Get name suggestions for NFD registration + * @param name - The name (even partial) to search for + * @param options - Suggestion options including the buyer address + * @returns Array of suggested NFD records + */ + public async suggest(name: string, options: SuggestOptions): Promise { + return this.api.suggest(name, options) + } + + /** + * Make an offer to purchase an NFD from its owner + * @param nameOrAppId - The NFD name or application ID to make an offer on + * @param amount - The offer amount in microAlgos + * @param note - Optional note to the owner + * @returns The NFD record + * @throws If the offer fails or signer is not set + */ + public async makeOffer( + nameOrAppId: string | number | bigint, + amount: bigint | number, + note: string = '', + ): Promise { + if (!this._signer) { + throw new Error('Signer must be set before making an offer') + } + + try { + return await this._purchasing.makeOffer(nameOrAppId, amount, note) + } finally { + this._signer = null + } + } + + /** + * Start a verification request for an NFD property + * @param name - The NFD name to verify a property for + * @param field - The field to verify + * @returns Verification request result with challenge and ID + * @throws If the verification request fails or signer is not set + */ + public async verifyRequest( + name: string, + field: VerifyField, + ): Promise { + if (!this._signer) { + throw new Error('Signer must be set before requesting verification') + } + + try { + return await this.api.verifyRequest( + name, + this._signer.addr.toString(), + field, + ) + } finally { + this._signer = null + } + } + + /** + * Confirm a verification request + * @param id - The verification request ID + * @param challenge - The challenge value (optional depending on verification type) + * @returns Verification confirmation result + */ + public async verifyConfirm( + id: string, + challenge?: string, + ): Promise { + return this.api.verifyConfirm(id, challenge) + } } diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 348f947..48dc979 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -10,6 +10,10 @@ export enum DefaultSender { TESTNET = 'A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE', } +/** The Algorand zero address (all zeros, used as a placeholder for "no address") */ +export const ALGORAND_ZERO_ADDRESS = + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ' + /** The base URLs for the NFD API for each network */ export enum NfdApiBaseUrl { MAINNET = 'https://api.nf.domains', diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 111794a..6bffa95 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -6,12 +6,19 @@ export { NfdRegistryId } from './constants' // Export core/API types export type { + ListForSaleOptions, Nfd, NfdImageResult, ResolveOptions, ReverseLookupOptions, SearchOptions, SearchResponse, + SendFromVaultOptions, + SendToVaultOptions, + SuggestOptions, + VerifyConfirmResult, + VerifyField, + VerifyRequestResult, } from './types' // Export API client diff --git a/packages/sdk/src/modules/manager.ts b/packages/sdk/src/modules/manager.ts index 99693c7..1c3cfa9 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -1,13 +1,19 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import { Address } from 'algosdk' +import { ALGORAND_ZERO_ADDRESS } from '../constants' import { parseTransactionError } from '../utils/error-parser' import { strToUint8Array, concatUint8Arrays } from '../utils/internal/bytes' import { BaseModule } from './base' import type { NfdClient } from '../client' -import type { Nfd } from '../types' +import type { + Nfd, + ListForSaleOptions, + SendToVaultOptions, + SendFromVaultOptions, +} from '../types' /** * Manager for operations on a specific NFD @@ -441,4 +447,396 @@ export class NfdManager extends BaseModule { this._nfd = null return this.getNfd() } + + /** + * Get the renewal price for the NFD (per year, in microAlgos) + * @returns The renewal price per year in microAlgos + * @throws If the price cannot be retrieved + */ + public async getRenewalPrice(): Promise { + const nfd = await this.getNfd() + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId) + + try { + const result = await nfdInstanceClient + .newGroup() + .getRenewPrice() + .simulate({ skipSignatures: true, allowUnnamedResources: true }) + + const price = result.returns[0] + if (price === undefined) { + throw new Error('No price returned') + } + + return BigInt(price) + } catch (error) { + throw new Error( + `Failed to get renewal price: ${parseTransactionError(error)}`, + ) + } + } + + /** + * Renew the NFD + * @param years - Number of years to renew for (1-20, default 1) + * @returns The updated NFD + * @throws If the renewal fails + */ + public async renew(years: number = 1): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + // Get the renewal price per year + const pricePerYear = await this.getRenewalPrice() + const totalPrice = pricePerYear * BigInt(years) + + // Create the payment transaction + const paymentTxn = await this.algorand.createTransaction.payment({ + sender: signer.addr, + receiver: nfdInstanceClient.appAddress, + amount: AlgoAmount.MicroAlgos(totalPrice), + }) + + try { + await nfdInstanceClient + .newGroup() + .renew({ + args: { payment: paymentTxn }, + staticFee: AlgoAmount.MicroAlgos(5000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error(`Failed to renew NFD: ${parseTransactionError(error)}`) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * List the NFD for sale on the marketplace + * @param price - The sale price in microAlgos + * @param options - Optional sale configuration + * @returns The updated NFD + * @throws If the listing fails + */ + public async listForSale( + price: bigint | number, + options: ListForSaleOptions = {}, + ): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can list this NFD for sale') + } + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + const reservedFor = options.reservedFor ?? ALGORAND_ZERO_ADDRESS + + try { + await nfdInstanceClient + .newGroup() + .offerForSale({ + args: { + sellAmount: BigInt(price), + reservedFor, + }, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to list NFD for sale: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * Cancel the sale listing for the NFD + * @returns The updated NFD + * @throws If the cancellation fails + */ + public async cancelSale(): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can cancel the sale of this NFD') + } + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + try { + await nfdInstanceClient + .newGroup() + .cancelSale({ + args: {}, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to cancel sale: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * Lock or unlock segment minting for the NFD + * @param lock - Whether to lock (true) or unlock (false) segment minting + * @param usdPrice - The price in USD cents for minting segments (e.g., 300 = $3.00). Set to 0 if locking. + * @returns The updated NFD + * @throws If the operation fails + */ + public async lockSegment( + lock: boolean, + usdPrice: number = 0, + ): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can lock/unlock segments for this NFD') + } + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + try { + await nfdInstanceClient + .newGroup() + .segmentLock({ + args: { + lock, + usdPrice: BigInt(usdPrice), + }, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to ${lock ? 'lock' : 'unlock'} segments: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * Lock or unlock vault opt-ins for the NFD + * @param lock - Whether to lock (true) or unlock (false) vault opt-ins. + * When locked, only the owner can opt the vault into assets. + * When unlocked, anyone can opt the vault into assets. + * @returns The updated NFD + * @throws If the operation fails + */ + public async lockVault(lock: boolean): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can lock/unlock the vault for this NFD') + } + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + try { + await nfdInstanceClient + .newGroup() + .vaultOptInLock({ + args: { lock }, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to ${lock ? 'lock' : 'unlock'} vault: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * Opt the NFD vault into assets and optionally transfer them + * @param assets - Array of ASA IDs to opt into (use 0 for ALGO) + * @param options - Options for the vault operation + * @returns The updated NFD + * @throws If the operation fails + */ + public async sendToVault( + assets: number[], + options: SendToVaultOptions = {}, + ): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + // Calculate fees based on number of assets + const feePerAsset = 1000n + const baseFee = 3000n + const totalFee = baseFee + feePerAsset * BigInt(assets.length) + + try { + const group = nfdInstanceClient.newGroup() + + // Opt the vault into the assets + group.vaultOptIn({ + args: { assets: assets.map(BigInt) }, + staticFee: AlgoAmount.MicroAlgos(totalFee), + }) + + // If not opt-in only, add asset transfer transactions + if (!options.optInOnly && assets.length > 0) { + const vaultAddress = nfd.nfdAccount + if (!vaultAddress) { + throw new Error('NFD has no vault account') + } + + for (const assetId of assets) { + if (assetId === 0) { + // ALGO transfer + const paymentTxn = + await this.algorand.createTransaction.payment({ + sender: signer.addr, + receiver: vaultAddress, + amount: AlgoAmount.MicroAlgos(options.amount ?? 0n), + note: options.note, + }) + group.addTransaction(paymentTxn) + } else { + // ASA transfer + const assetTxn = + await this.algorand.createTransaction.assetTransfer({ + sender: signer.addr, + receiver: vaultAddress, + assetId: BigInt(assetId), + amount: options.amount ?? 0n, + note: options.note, + }) + group.addTransaction(assetTxn) + } + } + } + + await group.send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to send to vault: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } + + /** + * Send assets from the NFD vault to a receiver + * @param assets - Array of ASA IDs to send from the vault + * @param receiver - The receiving Algorand address or NFD name + * @param options - Options for the vault operation + * @returns The updated NFD + * @throws If the operation fails + */ + public async sendFromVault( + assets: number[], + receiver: string, + options: SendFromVaultOptions = {}, + ): Promise { + const signer = this.requireSigner() + const nfd = await this.getNfd() + + if (signer.addr.toString() !== nfd.owner) { + throw new Error('Only the owner can send from the vault') + } + + if (!nfd.appID) { + throw new Error('NFD has no application ID') + } + if (assets.length === 0) { + throw new Error('At least one asset must be specified') + } + + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + const primaryAsset = BigInt(assets[0]) + const otherAssets = assets.slice(1).map(BigInt) + + // Calculate fees based on number of assets + const feePerAsset = 1000n + const baseFee = 3000n + const totalFee = baseFee + feePerAsset * BigInt(assets.length) + + try { + await nfdInstanceClient + .newGroup() + .vaultSend({ + args: { + amount: options.amount ?? 0n, + receiver, + note: options.note ?? '', + asset: primaryAsset, + otherAssets, + }, + staticFee: AlgoAmount.MicroAlgos(totalFee), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to send from vault: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this._nfd = null + return this.getNfd() + } } diff --git a/packages/sdk/src/modules/purchasing.ts b/packages/sdk/src/modules/purchasing.ts index 1656731..f33036f 100644 --- a/packages/sdk/src/modules/purchasing.ts +++ b/packages/sdk/src/modules/purchasing.ts @@ -292,4 +292,47 @@ export class PurchasingModule extends BaseModule { return false } } + + /** + * Make an offer to purchase an NFD from its owner + * @param nameOrAppId - The NFD name or application ID to make an offer on + * @param amount - The offer amount in microAlgos + * @param note - Optional note to the owner + * @returns The NFD record + * @throws If the offer fails + */ + public async makeOffer( + nameOrAppId: string | number | bigint, + amount: bigint | number, + note: string = '', + ): Promise { + const signer = this.requireSigner() + + const nfd = await this.client.resolve(nameOrAppId, { view: 'full' }) + if (!nfd.appID) { + throw new Error(`Cannot determine app ID for NFD: ${nfd.name}`) + } + + const nfdInstanceClient = this.getInstanceClient( + BigInt(nfd.appID), + signer.addr, + ) + + try { + await nfdInstanceClient + .newGroup() + .postOffer({ + args: { + offer: BigInt(amount), + note, + }, + staticFee: AlgoAmount.MicroAlgos(3000), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error(`Failed to make offer: ${parseTransactionError(error)}`) + } + + return this.client.resolve(nameOrAppId, { view: 'full' }) + } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 44a1030..7a2c695 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { NfdRecord, NfdSearchV2Response } from './api/types.gen' +import type { + NfdRecord, + NfdSearchV2Response, + VerifyConfirmResponseBody, + VerifyRequestResponseBody, +} from './api/types.gen' /** * Configuration options for resolving an NFD @@ -249,3 +254,102 @@ export interface NfdImageResult { /** Whether this result uses a fallback default image (only for avatars) */ isFallback?: boolean } + +/** + * Configuration options for suggesting NFD names + */ +export interface SuggestOptions { + /** + * The buyer's Algorand address (required for eligibility filtering) + */ + buyer: string + + /** + * Limit the number of results returned + * @default 20 + * @maximum 40 + */ + limit?: number + + /** + * View of data to return + * @default 'brief' + */ + view?: 'brief' | 'full' +} + +/** + * Options for listing an NFD for sale + */ +export interface ListForSaleOptions { + /** + * Reserve the sale for a specific address + */ + reservedFor?: string +} + +/** + * Options for sending assets to a vault + */ +export interface SendToVaultOptions { + /** + * Whether to only opt the vault into the asset(s) without transferring + * @default false + */ + optInOnly?: boolean + + /** + * Amount to send in base units of the asset. Required when sending a single asset. + * Ignored when sending multiple assets (all of each asset is sent). + */ + amount?: bigint + + /** + * Optional note to include in the transaction + */ + note?: string +} + +/** + * Options for sending assets from a vault + */ +export interface SendFromVaultOptions { + /** + * Amount to send in base units of the asset. Required when sending a single asset. + * Ignored when sending multiple assets (all of each asset is sent). + */ + amount?: bigint + + /** + * Optional note to include in the transaction + */ + note?: string + + /** + * Type of receiver + * @default 'account' + */ + receiverType?: 'account' | 'nfdVault' +} + +/** + * Field types that can be verified on an NFD + */ +export type VerifyField = + | 'blueskydid' + | 'twitter' + | 'github' + | 'domain' + | 'email' + | 'avatar' + | 'banner' + +/** + * Result of starting a verification request + */ +export type VerifyRequestResult = VerifyRequestResponseBody + +/** + * Result of confirming a verification + */ +export type VerifyConfirmResult = VerifyConfirmResponseBody diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 2d18254..c5bc35b 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -34,7 +34,12 @@ vi.mock('@algorandfoundation/algokit-utils', () => ({ vi.mock('../src/api-client', () => ({ NfdApiClient: vi.fn().mockImplementation(() => ({ - // Mock API client methods as needed + suggest: vi.fn().mockResolvedValue([{ name: 'suggestion.algo' }]), + verifyRequest: vi.fn().mockResolvedValue({ + challenge: 'test-challenge', + id: 'test-id', + }), + verifyConfirm: vi.fn().mockResolvedValue({ confirmed: true }), })), })) @@ -77,6 +82,12 @@ vi.mock('../src/modules/purchasing', () => ({ state: 'owned' as const, owner: VALID_ADDRESS, }), + makeOffer: vi.fn().mockResolvedValue({ + name: 'test.algo', + appID: 12345, + state: 'forSale' as const, + owner: VALID_ADDRESS, + }), })), })) @@ -234,5 +245,90 @@ describe('NfdClient', () => { 'Signer must be set before buying NFD', ) }) + + it('should delegate makeOffer to purchasing module and reset signer', async () => { + const result = await client.makeOffer('test.algo', 5000000n) + + expect(result).toEqual({ + name: 'test.algo', + appID: 12345, + state: 'forSale', + owner: VALID_ADDRESS, + }) + expect(client.signer).toBeNull() + }) + + it('should reset signer even when makeOffer fails', async () => { + // Get the mock purchasing module and make makeOffer throw + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const purchasing = (client as any)._purchasing + purchasing.makeOffer.mockRejectedValueOnce(new Error('Offer failed')) + + await expect(client.makeOffer('test.algo', 5000000n)).rejects.toThrow( + 'Offer failed', + ) + expect(client.signer).toBeNull() + }) + + it('should throw error if signer not set for makeOffer', async () => { + const clientWithoutSigner = NfdClient.testNet() + + await expect( + clientWithoutSigner.makeOffer('test.algo', 5000000n), + ).rejects.toThrow('Signer must be set before making an offer') + }) + }) + + describe('suggest', () => { + it('should delegate suggest to api client', async () => { + const result = await client.suggest('test', { buyer: VALID_ADDRESS }) + expect(result).toEqual([{ name: 'suggestion.algo' }]) + }) + }) + + describe('Verification methods', () => { + it('should delegate verifyRequest to api and reset signer', async () => { + client.setSigner(VALID_ADDRESS, mockSigner) + + const result = await client.verifyRequest('test.algo', 'twitter') + + expect(result).toEqual({ + challenge: 'test-challenge', + id: 'test-id', + }) + expect(client.signer).toBeNull() + }) + + it('should reset signer even when verifyRequest fails', async () => { + client.setSigner(VALID_ADDRESS, mockSigner) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const api = (client as any)._api + api.verifyRequest.mockRejectedValueOnce(new Error('Verify failed')) + + await expect( + client.verifyRequest('test.algo', 'twitter'), + ).rejects.toThrow('Verify failed') + expect(client.signer).toBeNull() + }) + + it('should throw error if signer not set for verifyRequest', async () => { + const clientWithoutSigner = NfdClient.testNet() + + await expect( + clientWithoutSigner.verifyRequest('test.algo', 'twitter'), + ).rejects.toThrow('Signer must be set before requesting verification') + }) + + it('should delegate verifyConfirm to api without requiring signer', async () => { + const clientWithoutSigner = NfdClient.testNet() + + const result = await clientWithoutSigner.verifyConfirm( + 'test-id', + 'challenge-value', + ) + + expect(result).toEqual({ confirmed: true }) + }) }) }) diff --git a/packages/sdk/tests/modules/manager.test.ts b/packages/sdk/tests/modules/manager.test.ts new file mode 100644 index 0000000..f7de368 --- /dev/null +++ b/packages/sdk/tests/modules/manager.test.ts @@ -0,0 +1,551 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { NfdClient } from '../../src/client' +import { ALGORAND_ZERO_ADDRESS } from '../../src/constants' +import { NfdManager } from '../../src/modules/manager' + +import type { Nfd } from '../../src/types' + +// Valid Algorand addresses for testing +const OWNER_ADDRESS = + 'ZZAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM' +const OTHER_ADDRESS = + 'CCAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM' +const VAULT_ADDRESS = + 'BBAF5ARA4MEC5PVDOP64JM5O5MQST63Q2KOY2FLYFLXXD3PFSNJJBYAFZM' + +const mockOwnedNfd: Nfd = { + name: 'test.algo', + appID: 12345, + state: 'owned', + owner: OWNER_ADDRESS, + nfdAccount: VAULT_ADDRESS, +} + +// Mock types +interface MockSigner { + addr: { toString: () => string } + signer: ReturnType +} + +interface MockInstanceClient { + appAddress: string + newGroup: ReturnType +} + +// Mock send result factory +const mockSend = () => vi.fn().mockResolvedValue({}) + +// Mock Algorand client +const mockAlgorand = { + createTransaction: { + payment: vi.fn().mockResolvedValue({ id: 'mock-payment-txn' }), + assetTransfer: vi.fn().mockResolvedValue({ id: 'mock-asset-txn' }), + }, + setSigner: vi.fn(), +} + +// Build a mock instance client with configurable method chains +function createMockInstanceClient(): MockInstanceClient { + const groupMock = { + getRenewPrice: vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + returns: [5000000n], + }), + }), + renew: vi.fn().mockReturnValue({ + send: mockSend(), + }), + offerForSale: vi.fn().mockReturnValue({ + send: mockSend(), + }), + cancelSale: vi.fn().mockReturnValue({ + send: mockSend(), + }), + segmentLock: vi.fn().mockReturnValue({ + send: mockSend(), + }), + vaultOptInLock: vi.fn().mockReturnValue({ + send: mockSend(), + }), + vaultOptIn: vi.fn().mockReturnThis(), + vaultSend: vi.fn().mockReturnValue({ + send: mockSend(), + }), + addTransaction: vi.fn().mockReturnThis(), + send: mockSend(), + } + return { + appAddress: 'mock-app-address', + newGroup: vi.fn().mockReturnValue(groupMock), + } +} + +let mockInstanceClient: MockInstanceClient + +// Mock the dependencies +vi.mock('algosdk', () => ({ + isValidAddress: vi.fn((addr: string) => addr.length === 58), + Address: { + fromString: vi.fn((addr: string) => { + 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('NfdManager', () => { + let client: NfdClient + let manager: NfdManager + let mockSigner: MockSigner + + beforeEach(() => { + vi.clearAllMocks() + + mockInstanceClient = createMockInstanceClient() + + mockSigner = { + addr: { toString: () => OWNER_ADDRESS }, + signer: vi.fn(), + } + + client = new NfdClient() + client.setSigner(OWNER_ADDRESS, mockSigner.signer) + manager = new NfdManager(client, 'test.algo') + + // Mock client.resolve to return the owned NFD + vi.spyOn(client, 'resolve').mockResolvedValue(mockOwnedNfd) + + // Mock getInstanceClient + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + }) + + describe('getRenewalPrice', () => { + it('should return the renewal price', async () => { + const price = await manager.getRenewalPrice() + + expect(price).toBe(5000000n) + expect(mockInstanceClient.newGroup).toHaveBeenCalled() + expect( + mockInstanceClient.newGroup().getRenewPrice, + ).toHaveBeenCalled() + }) + + it('should throw if NFD has no appID', async () => { + vi.spyOn(client, 'resolve').mockResolvedValueOnce({ + ...mockOwnedNfd, + appID: undefined, + }) + // Reset cached NFD + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + + await expect(manager.getRenewalPrice()).rejects.toThrow( + 'NFD has no application ID', + ) + }) + + it('should throw if no price returned', async () => { + mockInstanceClient.newGroup.mockReturnValueOnce({ + getRenewPrice: vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ returns: [undefined] }), + }), + }) + + await expect(manager.getRenewalPrice()).rejects.toThrow( + 'No price returned', + ) + }) + }) + + describe('renew', () => { + it('should renew the NFD for the specified number of years', async () => { + const result = await manager.renew(2) + + // Should calculate total price: 5000000 * 2 = 10000000 + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ + sender: expect.anything(), + receiver: 'mock-app-address', + amount: expect.objectContaining({ + amountInMicroAlgo: 10000000n, + }), + }) + + expect(mockInstanceClient.newGroup().renew).toHaveBeenCalledWith({ + args: { payment: { id: 'mock-payment-txn' } }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 5000n, + }), + }) + + expect(result).toEqual(mockOwnedNfd) + }) + + it('should default to 1 year', async () => { + await manager.renew() + + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ + sender: expect.anything(), + receiver: 'mock-app-address', + amount: expect.objectContaining({ + amountInMicroAlgo: 5000000n, + }), + }) + }) + + it('should throw if NFD has no appID', async () => { + vi.spyOn(client, 'resolve').mockResolvedValueOnce({ + ...mockOwnedNfd, + appID: undefined, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + + await expect(manager.renew()).rejects.toThrow( + 'NFD has no application ID', + ) + }) + }) + + describe('listForSale', () => { + it('should list the NFD for sale with default reservedFor', async () => { + const result = await manager.listForSale(10000000n) + + expect( + mockInstanceClient.newGroup().offerForSale, + ).toHaveBeenCalledWith({ + args: { + sellAmount: 10000000n, + reservedFor: ALGORAND_ZERO_ADDRESS, + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + + expect(result).toEqual(mockOwnedNfd) + }) + + it('should use provided reservedFor address', async () => { + await manager.listForSale(10000000n, { reservedFor: OTHER_ADDRESS }) + + expect( + mockInstanceClient.newGroup().offerForSale, + ).toHaveBeenCalledWith({ + args: { + sellAmount: 10000000n, + reservedFor: OTHER_ADDRESS, + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should throw if not the owner', async () => { + const otherSigner: MockSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const manager2 = new NfdManager(client, 'test.algo') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect(manager2.listForSale(10000000n)).rejects.toThrow( + 'Only the owner can list this NFD for sale', + ) + }) + }) + + describe('cancelSale', () => { + it('should cancel the sale listing', async () => { + const result = await manager.cancelSale() + + expect( + mockInstanceClient.newGroup().cancelSale, + ).toHaveBeenCalledWith({ + args: {}, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + + expect(result).toEqual(mockOwnedNfd) + }) + + it('should throw if not the owner', async () => { + const otherSigner: MockSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const manager2 = new NfdManager(client, 'test.algo') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect(manager2.cancelSale()).rejects.toThrow( + 'Only the owner can cancel the sale of this NFD', + ) + }) + }) + + describe('lockSegment', () => { + it('should lock segment minting', async () => { + await manager.lockSegment(true, 300) + + expect( + mockInstanceClient.newGroup().segmentLock, + ).toHaveBeenCalledWith({ + args: { + lock: true, + usdPrice: 300n, + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should unlock segment minting', async () => { + await manager.lockSegment(false) + + expect( + mockInstanceClient.newGroup().segmentLock, + ).toHaveBeenCalledWith({ + args: { + lock: false, + usdPrice: 0n, + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should throw if not the owner', async () => { + const otherSigner: MockSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const manager2 = new NfdManager(client, 'test.algo') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect(manager2.lockSegment(true)).rejects.toThrow( + 'Only the owner can lock/unlock segments for this NFD', + ) + }) + }) + + describe('lockVault', () => { + it('should lock vault opt-ins', async () => { + await manager.lockVault(true) + + expect( + mockInstanceClient.newGroup().vaultOptInLock, + ).toHaveBeenCalledWith({ + args: { lock: true }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should unlock vault opt-ins', async () => { + await manager.lockVault(false) + + expect( + mockInstanceClient.newGroup().vaultOptInLock, + ).toHaveBeenCalledWith({ + args: { lock: false }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should throw if not the owner', async () => { + const otherSigner: MockSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const manager2 = new NfdManager(client, 'test.algo') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect(manager2.lockVault(true)).rejects.toThrow( + 'Only the owner can lock/unlock the vault for this NFD', + ) + }) + }) + + describe('sendToVault', () => { + it('should opt the vault into assets (opt-in only)', async () => { + await manager.sendToVault([100, 200], { optInOnly: true }) + + expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalledWith({ + args: { assets: [100n, 200n] }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 5000n, // 3000 + 1000 * 2 + }), + }) + + // Should not create transfer transactions + expect(mockAlgorand.createTransaction.payment).not.toHaveBeenCalled() + expect( + mockAlgorand.createTransaction.assetTransfer, + ).not.toHaveBeenCalled() + }) + + it('should opt in and transfer ALGO (asset 0)', async () => { + await manager.sendToVault([0], { amount: 1000000n }) + + expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalled() + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ + sender: expect.anything(), + receiver: VAULT_ADDRESS, + amount: expect.objectContaining({ + amountInMicroAlgo: 1000000n, + }), + note: undefined, + }) + }) + + it('should opt in and transfer ASA', async () => { + await manager.sendToVault([42], { amount: 500n, note: 'test' }) + + expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalled() + expect( + mockAlgorand.createTransaction.assetTransfer, + ).toHaveBeenCalledWith({ + sender: expect.anything(), + receiver: VAULT_ADDRESS, + assetId: 42n, + amount: 500n, + note: 'test', + }) + }) + + it('should throw if NFD has no vault account', async () => { + vi.spyOn(client, 'resolve').mockResolvedValueOnce({ + ...mockOwnedNfd, + nfdAccount: undefined, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + + await expect( + manager.sendToVault([100], { amount: 100n }), + ).rejects.toThrow('NFD has no vault account') + }) + }) + + describe('sendFromVault', () => { + it('should send assets from the vault', async () => { + await manager.sendFromVault([100], OTHER_ADDRESS, { amount: 500n }) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith({ + args: { + amount: 500n, + receiver: OTHER_ADDRESS, + note: '', + asset: 100n, + otherAssets: [], + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 4000n, // 3000 + 1000 * 1 + }), + }) + }) + + it('should split multiple assets into primary and others', async () => { + await manager.sendFromVault([100, 200, 300], OTHER_ADDRESS) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith({ + args: { + amount: 0n, + receiver: OTHER_ADDRESS, + note: '', + asset: 100n, + otherAssets: [200n, 300n], + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 6000n, // 3000 + 1000 * 3 + }), + }) + }) + + it('should throw if not the owner', async () => { + const otherSigner: MockSigner = { + addr: { toString: () => OTHER_ADDRESS }, + signer: vi.fn(), + } + client.setSigner(OTHER_ADDRESS, otherSigner.signer) + const manager2 = new NfdManager(client, 'test.algo') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager2 as any, 'getInstanceClient').mockReturnValue( + mockInstanceClient, + ) + + await expect( + manager2.sendFromVault([100], OWNER_ADDRESS), + ).rejects.toThrow('Only the owner can send from the vault') + }) + + it('should throw if no assets specified', async () => { + await expect( + manager.sendFromVault([], OTHER_ADDRESS), + ).rejects.toThrow('At least one asset must be specified') + }) + + it('should include note when provided', async () => { + await manager.sendFromVault([100], OTHER_ADDRESS, { + amount: 100n, + note: 'payment', + }) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + note: 'payment', + }), + }), + ) + }) + }) +}) diff --git a/packages/sdk/tests/modules/purchasing.test.ts b/packages/sdk/tests/modules/purchasing.test.ts index 0770a73..a1b87a4 100644 --- a/packages/sdk/tests/modules/purchasing.test.ts +++ b/packages/sdk/tests/modules/purchasing.test.ts @@ -90,6 +90,9 @@ const mockInstanceClient: MockInstanceClient = { purchase: vi.fn().mockReturnValue({ send: vi.fn().mockResolvedValue({}), }), + postOffer: vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue({}), + }), }), } @@ -434,6 +437,71 @@ describe('PurchasingModule', () => { }) }) + describe('makeOffer', () => { + it('should successfully make an offer on an NFD', async () => { + const result = await purchasing.makeOffer('forsale.algo', 5000000n, 'I want this NFD') + + expect(mockInstanceClient.newGroup).toHaveBeenCalled() + expect(mockInstanceClient.newGroup().postOffer).toHaveBeenCalledWith({ + args: { + offer: 5000000n, + note: 'I want this NFD', + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + expect( + mockInstanceClient.newGroup().postOffer().send, + ).toHaveBeenCalledWith({ + populateAppCallResources: true, + }) + + // Should re-resolve the NFD after the offer + expect(client.resolve).toHaveBeenCalledTimes(2) + expect(result).toEqual(mockForSaleNfd) + }) + + it('should accept numeric amount', async () => { + await purchasing.makeOffer('forsale.algo', 5000000) + + expect(mockInstanceClient.newGroup().postOffer).toHaveBeenCalledWith({ + args: { + offer: 5000000n, + note: '', + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + it('should throw error if NFD has no appID', async () => { + const noAppIdNfd: Nfd = { + name: 'noapp.algo', + state: 'forSale', + owner: SELLER_ADDRESS, + } + vi.spyOn(client, 'resolve').mockResolvedValueOnce(noAppIdNfd) + + await expect( + purchasing.makeOffer('noapp.algo', 5000000n), + ).rejects.toThrow('Cannot determine app ID for NFD: noapp.algo') + }) + + it('should wrap transaction errors', async () => { + mockInstanceClient.newGroup.mockReturnValueOnce({ + postOffer: vi.fn().mockReturnValue({ + send: vi.fn().mockRejectedValue(new Error('txn failed')), + }), + }) + + await expect( + purchasing.makeOffer('forsale.algo', 5000000n), + ).rejects.toThrow('Failed to make offer: txn failed') + }) + }) + describe('address validation', () => { it('should throw error for invalid buyer address in getPurchaseQuote', async () => { await expect( From f490cd2820f4710434fb0e0ec83281b9c35283b5 Mon Sep 17 00:00:00 2001 From: Patrick Bennett Date: Wed, 26 Aug 2026 18:18:40 -0400 Subject: [PATCH 2/5] feat(core)!: read NFD boxes in a single algod request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving an NFD listed every box, then fetched each value one at a time in a sequential loop. Every public entry point routes through LookupModule.resolve(), so that cost was paid on nearly every call. getAllBoxes() (src/utils/internal/boxes.ts) now reads names and values together via the algod include=values query parameter. algokit-utils does not expose it at any version (its AppManager.getBoxValues() is still one HTTP request per box), so the read goes through the raw algod client. Measured on MainNet against armyids.voi.algo (14 boxes, view: 'full'): 16 box requests and 1801ms before, 2 requests and 373ms after, with identical output. Both readers share it — LookupModule via a BaseModule wrapper and the slim NfdResolver directly — so buildNfdRecord() no longer takes a getBoxValue callback and is now synchronous. Global state and boxes are also fetched concurrently rather than in sequence. The read paginates, which it never did before. It follows nextToken, pins pages after the first to the first page's round, and throws rather than looping if a node repeats a cursor. It throws as well when a box comes back without a value, since a node that ignores include=values would otherwise yield an NFD silently missing all of its properties. LookupModule.resolveWithBoxes() returns the raw boxes alongside the NFD. NfdManager uses it to drop a duplicate read of v.caAlgo.0.as in linkAddress(), which getNfd() had already fetched, and caches the NFD and its boxes behind one invalidate() so the two cannot diverge. Also declares typescript-eslint, which both eslint configs import but nothing depended on. It resolved only under pnpm 9's hoisting, so lint fails for every package under the pinned pnpm 10. Tests: adds tests/utils/boxes.test.ts for the bulk read, pagination and cursor guard, and extends nfd-record tests with split-field ordering and zero-slot caAlgo cases. Adds linkAddress coverage to the manager tests, which it lacked entirely. BREAKING CHANGE: algosdk must now be v3.6.0 or later. The SDK reads NFD properties with the include=values box query parameter, added in algosdk v3.6.0; on an older algosdk, resolving an NFD fails at runtime rather than at install time. It also needs an algod node new enough to honour include=values, which the public MainNet and TestNet nodes already are. --- CLAUDE.md | 16 +- README.md | 7 +- examples/api-search/package.json | 2 +- examples/claim-nfd/package.json | 2 +- examples/link-address/package.json | 2 +- examples/mint/package.json | 2 +- examples/nfd-metadata/package.json | 2 +- examples/resolve/package.json | 2 +- examples/reverse-lookup/package.json | 2 +- examples/set-metadata/package.json | 2 +- examples/set-primary-nfd/package.json | 2 +- package.json | 3 +- packages/sdk/README.md | 7 +- packages/sdk/package.json | 4 +- packages/sdk/src/lookup-entry.ts | 10 +- packages/sdk/src/modules/base.ts | 21 ++ packages/sdk/src/modules/lookup.ts | 46 +++- packages/sdk/src/modules/manager.ts | 88 ++++--- packages/sdk/src/utils/internal/boxes.ts | 85 +++++++ packages/sdk/src/utils/internal/bytes.ts | 24 +- packages/sdk/src/utils/internal/nfd-record.ts | 58 ++--- packages/sdk/tests/lookup-entry.test.ts | 23 +- packages/sdk/tests/modules/lookup.test.ts | 43 +++- packages/sdk/tests/modules/manager.test.ts | 228 +++++++++++++----- packages/sdk/tests/utils/boxes.test.ts | 159 ++++++++++++ packages/sdk/tests/utils/nfd-record.test.ts | 105 +++++--- pnpm-lock.yaml | 122 ++++++---- 27 files changed, 803 insertions(+), 264 deletions(-) create mode 100644 packages/sdk/src/utils/internal/boxes.ts create mode 100644 packages/sdk/tests/utils/boxes.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c32c9b0..7a87bbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ NFDomains SDK (`@txnlab/nfd-sdk`) — a TypeScript SDK for interacting with Non- - **`packages/sdk/`** — the SDK package (published as `@txnlab/nfd-sdk`) - **`examples/`** — React/Vite example apps demonstrating SDK features -- Package manager: **pnpm v9+** with workspaces +- Package manager: **pnpm v10+** with workspaces (pinned by the root `packageManager` field, which CI reads too) - Node version: **22.14.0** (see `.nvmrc`) ## Commands @@ -30,6 +30,7 @@ pnpm build:examples # Build all example apps ``` Run a single test file: + ```bash pnpm --filter @txnlab/nfd-sdk exec vitest run tests/utils/nfd.test.ts ``` @@ -65,6 +66,19 @@ All modules extend `BaseModule` (`modules/base.ts`), which provides access to th - **Network presets**: `NfdClient.mainNet()` / `NfdClient.testNet()` with correct registry app IDs and API URLs. - **Dual output**: Build produces both ESM and CJS bundles. Contract clients are split into a separate `nfd-contracts` chunk. +### Reading boxes + +An NFD's `userDefined` and `verified` properties live in application boxes, not global state. `getAllBoxes()` (`src/utils/internal/boxes.ts`) reads them **through the raw algod client** (`algorand.client.algod`) using `.include('values')`, so names and values arrive together — one request per page instead of one per box. Both readers share it: `LookupModule` via the `BaseModule.getAllBoxes()` wrapper, and the slim `NfdResolver` (`src/lookup-entry.ts`) directly. `buildNfdRecord()` then takes the boxes already carrying their values, which is why it is synchronous. + +**Do not "simplify" this to `appClient.getBoxNames()` / `getBoxValues()`, and do not reintroduce a `getBoxValue` callback into `buildNfdRecord()`.** algokit-utils has no bulk box API at any version (checked through 9.2.0): its `AppManager.getBoxValues()` is `Promise.all(boxNames.map(getBoxValue))`, i.e. still one HTTP request per box. Going back to it silently restores an N+1 — a property-rich NFD costs 15 round-trips instead of 1. + +Consequences to keep in mind: + +- **algosdk >= 3.6.0 is required** — `.include()` does not exist before it. This is why `peerDependencies.algosdk` is `^3.6.0`; loosening it breaks consumers at runtime, not at install. +- `getAllBoxes()` follows `nextToken` and pins later pages to the first page's `round`. It throws if the cursor repeats (a node that never advances would loop forever) or if a box comes back without a value (a node ignoring `include=values`, which would otherwise yield an NFD silently missing all properties). +- `resolve()`'s `view` option selects which boxes are **parsed**, not which are fetched — all of them arrive in the one request regardless. +- Callers needing a raw box value should use `LookupModule.resolveWithBoxes()` and take it from the returned boxes rather than issuing a second read; `NfdManager` does this for `v.caAlgo.0.as`. + ### API Client `NfdApiClient` (`src/api-client.ts`) wraps the generated OpenAPI client for NFD HTTP API calls (resolve, search, reverse lookup). Configured per-network with MainNet/TestNet base URLs defined in `src/constants.ts`. diff --git a/README.md b/README.md index 2449979..9fe1b3d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Once we reach v1.0.0 with all planned features, breaking changes will only be in ## Installation -The SDK requires `algosdk` as a peer dependency. Install both packages: +The SDK requires **`algosdk` v3.6.0 or later** as a peer dependency. Install both packages: ```bash # npm @@ -41,6 +41,11 @@ yarn add @txnlab/nfd-sdk algosdk pnpm add @txnlab/nfd-sdk algosdk ``` +> [!IMPORTANT] +> v3.6.0 is the minimum because the SDK reads an NFD's properties with the `include=values` box query parameter, added in algosdk v3.6.0. It reads every box in one request instead of one request per box. +> +> On an older algosdk this fails at runtime, not at install time. It also needs an algod node new enough to honour `include=values` — the public MainNet and TestNet nodes already do. + ## Quick Start ```typescript diff --git a/examples/api-search/package.json b/examples/api-search/package.json index 8a38bcf..b7c0ef9 100644 --- a/examples/api-search/package.json +++ b/examples/api-search/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@txnlab/nfd-sdk": "^1.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/examples/claim-nfd/package.json b/examples/claim-nfd/package.json index 21c3fec..4b4d3c9 100644 --- a/examples/claim-nfd/package.json +++ b/examples/claim-nfd/package.json @@ -12,7 +12,7 @@ "@algorandfoundation/algokit-utils": "^8.2.2", "@txnlab/nfd-sdk": "^1.0.0", "@txnlab/use-wallet-react": "^4.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "lute-connect": "^1.4.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/examples/link-address/package.json b/examples/link-address/package.json index 8081f62..a0e6851 100644 --- a/examples/link-address/package.json +++ b/examples/link-address/package.json @@ -12,7 +12,7 @@ "@algorandfoundation/algokit-utils": "^8.2.2", "@txnlab/nfd-sdk": "^1.0.0", "@txnlab/use-wallet-react": "^4.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "lute-connect": "^1.4.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/examples/mint/package.json b/examples/mint/package.json index c41d488..1338b7f 100644 --- a/examples/mint/package.json +++ b/examples/mint/package.json @@ -12,7 +12,7 @@ "@algorandfoundation/algokit-utils": "^8.2.2", "@txnlab/nfd-sdk": "^1.0.0", "@txnlab/use-wallet-react": "^4.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "lute-connect": "^1.4.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/examples/nfd-metadata/package.json b/examples/nfd-metadata/package.json index 37305b8..d1f0f0a 100644 --- a/examples/nfd-metadata/package.json +++ b/examples/nfd-metadata/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@txnlab/nfd-sdk": "^1.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/examples/resolve/package.json b/examples/resolve/package.json index bb36a9d..1ea29cd 100644 --- a/examples/resolve/package.json +++ b/examples/resolve/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@txnlab/nfd-sdk": "^1.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/examples/reverse-lookup/package.json b/examples/reverse-lookup/package.json index ccfe9c2..843cb0c 100644 --- a/examples/reverse-lookup/package.json +++ b/examples/reverse-lookup/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@txnlab/nfd-sdk": "^1.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/examples/set-metadata/package.json b/examples/set-metadata/package.json index 18c022f..526276e 100644 --- a/examples/set-metadata/package.json +++ b/examples/set-metadata/package.json @@ -12,7 +12,7 @@ "@algorandfoundation/algokit-utils": "^8.2.2", "@txnlab/nfd-sdk": "^1.0.0", "@txnlab/use-wallet-react": "^4.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "lute-connect": "^1.4.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/examples/set-primary-nfd/package.json b/examples/set-primary-nfd/package.json index e593c5e..2b184be 100644 --- a/examples/set-primary-nfd/package.json +++ b/examples/set-primary-nfd/package.json @@ -12,7 +12,7 @@ "@algorandfoundation/algokit-utils": "^8.2.2", "@txnlab/nfd-sdk": "^1.0.0", "@txnlab/use-wallet-react": "^4.0.0", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "lute-connect": "^1.4.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/package.json b/package.json index 9be678c..66b0c84 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "globals": "^14.0.0", "prettier": "^3.5.1", "semantic-release": "^25.0.3", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "typescript-eslint": "^8.24.1" }, "pnpm": { "overrides": { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 811a467..5686b72 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -4,7 +4,7 @@ SDK for interacting with NFDomains (NFD) API and Algorand blockchain. This packa ## Installation -The SDK requires `algosdk` as a peer dependency. Install both packages: +The SDK requires **`algosdk` v3.6.0 or later** as a peer dependency. Install both packages: ```bash # npm @@ -17,6 +17,11 @@ yarn add @txnlab/nfd-sdk algosdk pnpm add @txnlab/nfd-sdk algosdk ``` +> [!IMPORTANT] +> v3.6.0 is the minimum because the SDK reads an NFD's properties with the `include=values` box query parameter, added in algosdk v3.6.0. It reads every box in one request instead of one request per box. +> +> On an older algosdk this fails at runtime, not at install time. It also needs an algod node new enough to honour `include=values` — the public MainNet and TestNet nodes already do. + ## Quick Start ```typescript diff --git a/packages/sdk/package.json b/packages/sdk/package.json index abfcd76..1dd9691 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -81,7 +81,7 @@ "@hey-api/client-fetch": "^0.8.1" }, "peerDependencies": { - "algosdk": "^3.0.0" + "algosdk": "^3.6.0" }, "devDependencies": { "@algorandfoundation/algokit-client-generator": "^4.0.8", @@ -91,7 +91,7 @@ "@types/node-fetch": "^2.6.12", "@vitest/coverage-v8": "^3.0.7", "@vitest/ui": "^3.0.7", - "algosdk": "^3.5.2", + "algosdk": "^3.7.0", "dotenv": "^16.4.7", "node-fetch": "^3.3.2", "publint": "^0.3.6", diff --git a/packages/sdk/src/lookup-entry.ts b/packages/sdk/src/lookup-entry.ts index a13fd85..7d47d19 100644 --- a/packages/sdk/src/lookup-entry.ts +++ b/packages/sdk/src/lookup-entry.ts @@ -2,6 +2,7 @@ import { AlgorandClient } from '@algorandfoundation/algokit-utils' import { Address, decodeUint64 } from 'algosdk' import { DefaultSender, NfdRegistryId } from './constants' +import { getAllBoxes } from './utils/internal/boxes' import { isZeroBytes } from './utils/internal/bytes' import { buildNfdRecord, type NfdView } from './utils/internal/nfd-record' import { @@ -163,15 +164,18 @@ export class NfdResolver { const nfdAppId = await this.parseAppId(nameOrAppId) const instance = this.appClientFor(nfdAppId) - const globalState = await instance.getGlobalState() - const boxes = await instance.getBoxNames() + // Names and values arrive together, so this is one request per page rather + // than one per box + const [globalState, boxes] = await Promise.all([ + instance.getGlobalState(), + getAllBoxes(this.algorand.client.algod, nfdAppId), + ]) return buildNfdRecord({ appId: nfdAppId, appAddress: instance.appAddress.toString(), globalState, boxes, - getBoxValue: (nameRaw) => instance.getBoxValue(nameRaw), view: options.view, }) } diff --git a/packages/sdk/src/modules/base.ts b/packages/sdk/src/modules/base.ts index fa2c2be..3c05490 100644 --- a/packages/sdk/src/modules/base.ts +++ b/packages/sdk/src/modules/base.ts @@ -5,6 +5,7 @@ import { Address } from 'algosdk' import { DefaultSender, NfdRegistryId } from '../constants' import { NfdInstanceClient } from '../contracts/NFDInstanceClient' import { NfdRegistryClient } from '../contracts/NFDRegistryClient' +import { getAllBoxes } from '../utils/internal/boxes' import { decodeAppIdFromNameBox, getNameBoxName, @@ -12,6 +13,9 @@ import { import type { NfdClient } from '../client' import type { Constraints } from '../contracts/NFDRegistryClient' +import type { AppBox } from '../utils/internal/boxes' + +export type { AppBox } from '../utils/internal/boxes' /** * Base module class that all other modules will extend @@ -94,6 +98,23 @@ export abstract class BaseModule { return getNameBoxName(nfdName) } + /** + * Get every box for an application, with values included + * + * Uses the `include=values` query parameter so that names and values arrive + * together, which takes one request per page rather than one request per box. + * Pages after the first are pinned to the round the first page was read at, so + * a multi-page read is consistent. + * + * @param appId - The application ID to read boxes from + * @returns Every box for the application + * @throws If the node returns boxes without values, or does not advance the + * pagination cursor + */ + protected getAllBoxes(appId: bigint): Promise { + return getAllBoxes(this.algorand.client.algod, appId) + } + /** * Get an NFD's application ID from its name * @param name - The NFD name diff --git a/packages/sdk/src/modules/lookup.ts b/packages/sdk/src/modules/lookup.ts index 76f60d1..d90d24b 100644 --- a/packages/sdk/src/modules/lookup.ts +++ b/packages/sdk/src/modules/lookup.ts @@ -1,9 +1,11 @@ +import { getAllBoxes } from '../utils/internal/boxes' import { buildNfdRecord } from '../utils/internal/nfd-record' import { isValidName } from '../utils/nfd' import { BaseModule } from './base' import type { Nfd } from '../types' +import type { AppBox } from '../utils/internal/boxes' /** * Options for resolving an NFD @@ -18,6 +20,16 @@ export interface ResolveOptions { view?: 'tiny' | 'brief' | 'full' } +/** + * An NFD resolved from chain, together with the boxes it was built from + */ +export interface ResolveResult { + /** The resolved NFD record */ + nfd: Nfd + /** Every box on the NFD instance app, regardless of the view used */ + boxes: AppBox[] +} + /** * Module for NFD lookup and resolution operations */ @@ -68,23 +80,47 @@ export class LookupModule extends BaseModule { nameOrAppId: string | number | bigint, options: ResolveOptions = {}, ): Promise { + const { nfd } = await this.resolveWithBoxes(nameOrAppId, options) + return nfd + } + + /** + * Resolve an NFD and also return the boxes it was built from + * + * Callers that need a raw box value (rather than the parsed property) can + * take it from the returned boxes instead of issuing a second read. + * + * @param nameOrAppId - The NFD name or application ID to resolve + * @param options - Optional parameters + * @returns The NFD record and every box on its instance app + * @throws If the NFD name is invalid or not found + */ + public async resolveWithBoxes( + nameOrAppId: string | number | bigint, + options: ResolveOptions = {}, + ): Promise { // Get the NFD app ID const nfdAppId = await this.parseAppId(nameOrAppId) // Get the NFD instance client const instanceClient = this.getInstanceClient(nfdAppId) - // Get the global state and box names in order to read all properties - const globalState = await instanceClient.appClient.getGlobalState() - const boxes = await instanceClient.appClient.getBoxNames() + // Get the global state and every box (names and values together) in order + // to read all properties. The view only decides which boxes are parsed, + // not how many requests are made. + const [globalState, boxes] = await Promise.all([ + instanceClient.appClient.getGlobalState(), + getAllBoxes(this.algorand.client.algod, nfdAppId), + ]) - return buildNfdRecord({ + const nfd = buildNfdRecord({ appId: nfdAppId, appAddress: instanceClient.appAddress.toString(), globalState, boxes, - getBoxValue: (nameRaw) => instanceClient.appClient.getBoxValue(nameRaw), view: options.view, }) + + return { nfd, boxes } } } diff --git a/packages/sdk/src/modules/manager.ts b/packages/sdk/src/modules/manager.ts index 1c3cfa9..5143d68 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -6,7 +6,9 @@ import { parseTransactionError } from '../utils/error-parser' import { strToUint8Array, concatUint8Arrays } from '../utils/internal/bytes' import { BaseModule } from './base' +import { LookupModule } from './lookup' +import type { AppBox } from './base' import type { NfdClient } from '../client' import type { Nfd, @@ -20,6 +22,7 @@ import type { */ export class NfdManager extends BaseModule { private _nfd: Nfd | null = null + private _boxes: AppBox[] | null = null private readonly _nameOrAppId: string | number | bigint constructor(client: NfdClient, nameOrAppId: string | number | bigint) { @@ -34,11 +37,37 @@ export class NfdManager extends BaseModule { */ private async getNfd(): Promise { if (!this._nfd) { - this._nfd = await this.client.resolve(this._nameOrAppId, { view: 'full' }) + // Keep the boxes from the same read so operations that need a raw box + // value do not have to fetch it again + const { nfd, boxes } = await new LookupModule( + this.client, + ).resolveWithBoxes(this._nameOrAppId, { view: 'full' }) + this._nfd = nfd + this._boxes = boxes } return this._nfd } + /** + * Get the raw value of a box read during the last `getNfd()` + * @param name - The box name + * @returns The box value, or an empty array if the NFD has no such box + */ + private getResolvedBoxValue(name: string): Uint8Array { + return ( + this._boxes?.find((box) => box.name === name)?.value ?? new Uint8Array() + ) + } + + /** + * Drop the cached NFD so the next `getNfd()` re-reads it from chain. Clears + * the cached boxes too, so they can never pair with a newer NFD. + */ + private invalidate(): void { + this._nfd = null + this._boxes = null + } + /** * Split fields and values if any values exceed the byte limit * @param fieldsAndValues - Array of alternating field names and values @@ -112,13 +141,10 @@ export class NfdManager extends BaseModule { addressToLink.publicKey, ] - // Get current v.caAlgo.0.as box value/size - let curCaAlgo: Uint8Array - try { - curCaAlgo = await nfdInstanceClient.appClient.getBoxValue('v.caAlgo.0.as') - } catch { - curCaAlgo = new Uint8Array() - } + // Current v.caAlgo.0.as box value/size, already read by getNfd() above. + // The raw bytes are needed rather than nfd.caAlgo because empty (zero + // filled) address slots count toward the size the update is costed against. + const curCaAlgo = this.getResolvedBoxValue('v.caAlgo.0.as') // Calculate the eventual fields after the update const eventualFields: Uint8Array[] = [ @@ -193,7 +219,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -246,7 +272,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -345,7 +371,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -397,7 +423,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -444,7 +470,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -521,7 +547,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -569,7 +595,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -601,13 +627,11 @@ export class NfdManager extends BaseModule { }) .send({ populateAppCallResources: true }) } catch (error) { - throw new Error( - `Failed to cancel sale: ${parseTransactionError(error)}`, - ) + throw new Error(`Failed to cancel sale: ${parseTransactionError(error)}`) } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -618,10 +642,7 @@ export class NfdManager extends BaseModule { * @returns The updated NFD * @throws If the operation fails */ - public async lockSegment( - lock: boolean, - usdPrice: number = 0, - ): Promise { + public async lockSegment(lock: boolean, usdPrice: number = 0): Promise { const signer = this.requireSigner() const nfd = await this.getNfd() @@ -653,7 +674,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -694,7 +715,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -742,13 +763,12 @@ export class NfdManager extends BaseModule { for (const assetId of assets) { if (assetId === 0) { // ALGO transfer - const paymentTxn = - await this.algorand.createTransaction.payment({ - sender: signer.addr, - receiver: vaultAddress, - amount: AlgoAmount.MicroAlgos(options.amount ?? 0n), - note: options.note, - }) + const paymentTxn = await this.algorand.createTransaction.payment({ + sender: signer.addr, + receiver: vaultAddress, + amount: AlgoAmount.MicroAlgos(options.amount ?? 0n), + note: options.note, + }) group.addTransaction(paymentTxn) } else { // ASA transfer @@ -773,7 +793,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -836,7 +856,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } } diff --git a/packages/sdk/src/utils/internal/boxes.ts b/packages/sdk/src/utils/internal/boxes.ts new file mode 100644 index 0000000..1d432f4 --- /dev/null +++ b/packages/sdk/src/utils/internal/boxes.ts @@ -0,0 +1,85 @@ +import type { Algodv2 } from 'algosdk' + +/** + * An application box, with its name decoded and its value included + */ +export interface AppBox { + /** The box name, decoded as UTF-8 */ + name: string + /** The box value */ + value: Uint8Array +} + +/** + * Get every box for an application, with values included + * + * Uses the `include=values` query parameter so that names and values arrive + * together, which takes one request per page rather than one request per box. + * Pages after the first are pinned to the round the first page was read at, so + * a multi-page read is consistent. + * + * Requires algosdk >= 3.6.0 and an algod node new enough to honour + * `include=values`. + * + * @param algod - The algod client to read through + * @param appId - The application ID to read boxes from + * @returns Every box for the application + * @throws If the node returns boxes without values, or does not advance the + * pagination cursor + */ +export async function getAllBoxes( + algod: Algodv2, + appId: bigint, +): Promise { + const decoder = new TextDecoder('utf-8') + const boxes: AppBox[] = [] + + // The algod endpoint is caller-supplied, so the cursor is not trusted to + // advance on its own; a repeated token would otherwise loop forever + const seenTokens = new Set() + + let nextToken: string | undefined + let round: number | undefined + + do { + const request = algod.getApplicationBoxes(appId).include('values') + + if (nextToken) { + request.next(nextToken) + } + if (round !== undefined) { + request.round(round) + } + + const response = await request.do() + round ??= response.round + + for (const box of response.boxes) { + if (box.value === undefined) { + throw new Error( + `Box "${decoder.decode(box.name)}" of app ${appId} was returned without a value. ` + + 'The algod node does not support the `include=values` query parameter; ' + + 'a newer node is required.', + ) + } + + boxes.push({ + name: decoder.decode(box.name), + value: box.value, + }) + } + + nextToken = response.nextToken + + if (nextToken) { + if (seenTokens.has(nextToken)) { + throw new Error( + `Box pagination for app ${appId} did not advance: the algod node repeated a page cursor.`, + ) + } + seenTokens.add(nextToken) + } + } while (nextToken) + + return boxes +} diff --git a/packages/sdk/src/utils/internal/bytes.ts b/packages/sdk/src/utils/internal/bytes.ts index d202dc5..8c7df4e 100644 --- a/packages/sdk/src/utils/internal/bytes.ts +++ b/packages/sdk/src/utils/internal/bytes.ts @@ -8,22 +8,20 @@ export function strToUint8Array(str: string): Uint8Array { } /** - * Concatenate two Uint8Arrays - * @param array1 - The first array - * @param array2 - The second array + * Concatenate any number of Uint8Arrays + * @param arrays - The arrays to concatenate, in order * @returns The concatenated array */ -export function concatUint8Arrays( - array1: Uint8Array, - array2: Uint8Array, -): Uint8Array { - const concatenatedArray = new Uint8Array(array1.length + array2.length) +export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((total, array) => total + array.length, 0) + const concatenatedArray = new Uint8Array(totalLength) - // Set the first array values - concatenatedArray.set(array1, 0) - - // Set the second array values starting from the end of the first array - concatenatedArray.set(array2, array1.length) + // Set each array's values starting from the end of the previous one + let offset = 0 + for (const array of arrays) { + concatenatedArray.set(array, offset) + offset += array.length + } return concatenatedArray } diff --git a/packages/sdk/src/utils/internal/nfd-record.ts b/packages/sdk/src/utils/internal/nfd-record.ts index 3cf4085..0922821 100644 --- a/packages/sdk/src/utils/internal/nfd-record.ts +++ b/packages/sdk/src/utils/internal/nfd-record.ts @@ -1,14 +1,12 @@ import { Address } from 'algosdk' -import { isZeroBytes } from './bytes' +import { concatUint8Arrays, isZeroBytes } from './bytes' import { determineNfdState, generateMetaTags } from './nfd' import { parseAddress, parseString, parseUint64 } from './state' +import type { AppBox } from './boxes' import type { Nfd } from '../../types' -import type { - AppState, - BoxName, -} from '@algorandfoundation/algokit-utils/types/app' +import type { AppState } from '@algorandfoundation/algokit-utils/types/app' /** The view types supported when reading an NFD's box properties */ export type NfdView = 'tiny' | 'brief' | 'full' @@ -23,11 +21,9 @@ export interface BuildNfdRecordParams { appAddress: string /** The instance's global state */ globalState: AppState - /** The instance's box names */ - boxes: BoxName[] - /** Reads a box value by its raw name */ - getBoxValue: (nameRaw: Uint8Array) => Promise - /** The view type controlling which boxes are read */ + /** The instance's boxes, names and values together */ + boxes: AppBox[] + /** The view type controlling which boxes are parsed */ view?: NfdView } @@ -37,14 +33,13 @@ export interface BuildNfdRecordParams { * `NfdClient` and the slim `NfdResolver` lookup entry, so the two produce * identical records. */ -export async function buildNfdRecord({ +export function buildNfdRecord({ appId, appAddress, globalState, boxes, - getBoxValue, view = 'brief', -}: BuildNfdRecordParams): Promise { +}: BuildNfdRecordParams): Nfd { // Filter boxes based on view type const filteredBoxes = boxes.filter((box) => { const boxName = box.name @@ -77,8 +72,8 @@ export async function buildNfdRecord({ const caAlgo: string[] = [] const unverifiedCaAlgo: string[] = [] - // Group box names by their base field name to handle split fields - const boxGroups: Record = {} + // Group box values by their base field name to handle split fields + const boxGroups: Record> = {} for (const box of filteredBoxes) { const boxName = box.name @@ -94,38 +89,21 @@ export async function buildNfdRecord({ if (!boxGroups[baseName]) { boxGroups[baseName] = [] } - // Store the box name at the correct index - boxGroups[baseName][index] = boxName + // Store the box value at the correct index + boxGroups[baseName][index] = box.value } else { // Regular field (not split), add it as a single-item array - boxGroups[boxName] = [boxName] + boxGroups[boxName] = [box.value] } } } // Process each group of boxes - for (const [baseFieldName, boxNames] of Object.entries(boxGroups)) { - // Sort the box names to ensure correct order (important for split fields) - boxNames.sort() - - let value = new Uint8Array(0) - - // Fetch and combine values from all boxes in this group - for (const boxName of boxNames) { - if (!boxName) continue // Skip undefined entries - - const box = filteredBoxes.find((b) => b.name === boxName) - if (!box) continue - - const boxValue = await getBoxValue(box.nameRaw) - if (!boxValue) continue - - // Concatenate the value - const newCombined = new Uint8Array(value.length + boxValue.length) - newCombined.set(value) - newCombined.set(boxValue, value.length) - value = newCombined - } + for (const [baseFieldName, chunks] of Object.entries(boxGroups)) { + // Combine the chunks in index order, skipping any gaps + const value = concatUint8Arrays( + ...chunks.filter((chunk): chunk is Uint8Array => chunk !== undefined), + ) if (value.length === 0) continue diff --git a/packages/sdk/tests/lookup-entry.test.ts b/packages/sdk/tests/lookup-entry.test.ts index 175184d..46252db 100644 --- a/packages/sdk/tests/lookup-entry.test.ts +++ b/packages/sdk/tests/lookup-entry.test.ts @@ -62,11 +62,28 @@ function makeInstanceStub() { return { appAddress: getApplicationAddress(NFD_APP_ID), getGlobalState: vi.fn().mockResolvedValue(makeGlobalState()), - getBoxNames: vi.fn().mockResolvedValue([]), - getBoxValue: vi.fn(), } } +/** + * Fake algod. Boxes are read in one `include=values` request, so the fake + * returns names and values together. + */ +function makeAlgodStub( + boxes: Array<{ name: Uint8Array; value: Uint8Array }> = [], +) { + const getApplicationBoxes = vi.fn(() => { + const request = { + include: vi.fn(() => request), + next: vi.fn(() => request), + round: vi.fn(() => request), + do: vi.fn().mockResolvedValue({ boxes, round: 1000 }), + } + return request + }) + return { getApplicationBoxes } +} + interface MockSetup { resolver: NfdResolver registryStub: { getBoxValue: ReturnType } @@ -82,7 +99,7 @@ function setup(registryId: number | bigint = NfdRegistryId.MAINNET): MockSetup { ) const mockAlgorand = { - client: { getAppClientById }, + client: { getAppClientById, algod: makeAlgodStub() }, } as unknown as AlgorandClient const resolver = new NfdResolver({ algorand: mockAlgorand, registryId }) diff --git a/packages/sdk/tests/modules/lookup.test.ts b/packages/sdk/tests/modules/lookup.test.ts index 0a1babb..506cf35 100644 --- a/packages/sdk/tests/modules/lookup.test.ts +++ b/packages/sdk/tests/modules/lookup.test.ts @@ -59,12 +59,27 @@ function makeInstanceFake() { appAddress: getApplicationAddress(NFD_APP_ID), appClient: { getGlobalState: vi.fn().mockResolvedValue(makeGlobalState()), - getBoxNames: vi.fn().mockResolvedValue([]), - getBoxValue: vi.fn(), }, } } +/** + * Fake algod. Boxes are read in one `include=values` request, so the fake + * returns names and values together. + */ +function makeAlgodFake(boxes: Array<{ name: Uint8Array; value: Uint8Array }>) { + const getApplicationBoxes = vi.fn(() => { + const request = { + include: vi.fn(() => request), + next: vi.fn(() => request), + round: vi.fn(() => request), + do: vi.fn().mockResolvedValue({ boxes, round: 1000 }), + } + return request + }) + return { getApplicationBoxes } +} + /** Fake shaped like the typed NfdRegistryClient used by getRegistryClient */ function makeRegistryFake() { return { @@ -78,12 +93,16 @@ interface MockSetup { lookup: LookupModule registryFake: ReturnType instanceFake: ReturnType + algodFake: ReturnType getTypedAppClientById: ReturnType } -function setup(): MockSetup { +function setup( + boxes: Array<{ name: Uint8Array; value: Uint8Array }> = [], +): MockSetup { const registryFake = makeRegistryFake() const instanceFake = makeInstanceFake() + const algodFake = makeAlgodFake(boxes) // Dispatch by the typed-client class arg const getTypedAppClientById = vi.fn((ClientClass: unknown) => { @@ -93,13 +112,19 @@ function setup(): MockSetup { }) const mockClient = { - algorand: { client: { getTypedAppClientById } }, + algorand: { client: { getTypedAppClientById, algod: algodFake } }, registryId: BigInt(NfdRegistryId.MAINNET), } // eslint-disable-next-line @typescript-eslint/no-explicit-any const lookup = new LookupModule(mockClient as any) - return { lookup, registryFake, instanceFake, getTypedAppClientById } + return { + lookup, + registryFake, + instanceFake, + algodFake, + getTypedAppClientById, + } } /** Registry name box: ASA ID (bytes 0-7) + app ID (bytes 8-15) */ @@ -115,7 +140,7 @@ describe('LookupModule', () => { describe('resolve by name', () => { it('resolves a name to its app ID via the registry then builds the record', async () => { - const { lookup, registryFake, instanceFake } = setup() + const { lookup, registryFake, instanceFake, algodFake } = setup() registryFake.appClient.getBoxValue.mockResolvedValue(nameBox) const nfd = await lookup.resolve(NFD_NAME) @@ -128,9 +153,11 @@ describe('LookupModule', () => { expect(nfd.asaID).toBe(Number(ASA_ID)) expect(nfd.owner).toBe(OWNER) expect(nfd.nfdAccount).toBe(APP_ADDRESS) - // Instance state/boxes were read for the resolved app ID + // Instance state/boxes were read for the resolved app ID, the boxes in + // a single request expect(instanceFake.appClient.getGlobalState).toHaveBeenCalledTimes(1) - expect(instanceFake.appClient.getBoxNames).toHaveBeenCalledTimes(1) + expect(algodFake.getApplicationBoxes).toHaveBeenCalledTimes(1) + expect(algodFake.getApplicationBoxes).toHaveBeenCalledWith(NFD_APP_ID) }) }) diff --git a/packages/sdk/tests/modules/manager.test.ts b/packages/sdk/tests/modules/manager.test.ts index f7de368..1be92ed 100644 --- a/packages/sdk/tests/modules/manager.test.ts +++ b/packages/sdk/tests/modules/manager.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NfdClient } from '../../src/client' import { ALGORAND_ZERO_ADDRESS } from '../../src/constants' +import { LookupModule } from '../../src/modules/lookup' import { NfdManager } from '../../src/modules/manager' import type { Nfd } from '../../src/types' @@ -30,7 +31,9 @@ interface MockSigner { interface MockInstanceClient { appAddress: string + appId: bigint newGroup: ReturnType + appClient: { getBoxValue: ReturnType } } // Mock send result factory @@ -72,16 +75,41 @@ function createMockInstanceClient(): MockInstanceClient { vaultSend: vi.fn().mockReturnValue({ send: mockSend(), }), + getFieldUpdateCost: vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ returns: [1000n] }), + }), + updateFields: vi.fn().mockReturnThis(), addTransaction: vi.fn().mockReturnThis(), send: mockSend(), } return { appAddress: 'mock-app-address', + appId: 12345n, newGroup: vi.fn().mockReturnValue(groupMock), + // Present so a stray per-box read would be visible rather than throwing + appClient: { getBoxValue: vi.fn() }, + } +} + +// Build a mock registry client for the address-linking flow +function createMockRegistryClient() { + return { + appAddress: 'mock-registry-address', + newGroup: vi.fn().mockReturnValue({ + costToAddToAddress: vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ returns: [0n] }), + }), + }), + createTransaction: { + linkNfdAddress: vi + .fn() + .mockResolvedValue({ transactions: [{ id: 'mock-link-txn' }] }), + }, } } let mockInstanceClient: MockInstanceClient +let mockRegistryClient: ReturnType // Mock the dependencies vi.mock('algosdk', () => ({ @@ -124,6 +152,7 @@ describe('NfdManager', () => { vi.clearAllMocks() mockInstanceClient = createMockInstanceClient() + mockRegistryClient = createMockRegistryClient() mockSigner = { addr: { toString: () => OWNER_ADDRESS }, @@ -134,8 +163,11 @@ describe('NfdManager', () => { client.setSigner(OWNER_ADDRESS, mockSigner.signer) manager = new NfdManager(client, 'test.algo') - // Mock client.resolve to return the owned NFD - vi.spyOn(client, 'resolve').mockResolvedValue(mockOwnedNfd) + // Mock the resolve the manager uses to return the owned NFD and its boxes + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockOwnedNfd, + boxes: [], + }) // Mock getInstanceClient // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -150,15 +182,16 @@ describe('NfdManager', () => { expect(price).toBe(5000000n) expect(mockInstanceClient.newGroup).toHaveBeenCalled() - expect( - mockInstanceClient.newGroup().getRenewPrice, - ).toHaveBeenCalled() + expect(mockInstanceClient.newGroup().getRenewPrice).toHaveBeenCalled() }) it('should throw if NFD has no appID', async () => { - vi.spyOn(client, 'resolve').mockResolvedValueOnce({ - ...mockOwnedNfd, - appID: undefined, + vi.spyOn( + LookupModule.prototype, + 'resolveWithBoxes', + ).mockResolvedValueOnce({ + nfd: { ...mockOwnedNfd, appID: undefined }, + boxes: [], }) // Reset cached NFD // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -218,16 +251,17 @@ describe('NfdManager', () => { }) it('should throw if NFD has no appID', async () => { - vi.spyOn(client, 'resolve').mockResolvedValueOnce({ - ...mockOwnedNfd, - appID: undefined, + vi.spyOn( + LookupModule.prototype, + 'resolveWithBoxes', + ).mockResolvedValueOnce({ + nfd: { ...mockOwnedNfd, appID: undefined }, + boxes: [], }) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(manager as any)._nfd = null - await expect(manager.renew()).rejects.toThrow( - 'NFD has no application ID', - ) + await expect(manager.renew()).rejects.toThrow('NFD has no application ID') }) }) @@ -235,9 +269,7 @@ describe('NfdManager', () => { it('should list the NFD for sale with default reservedFor', async () => { const result = await manager.listForSale(10000000n) - expect( - mockInstanceClient.newGroup().offerForSale, - ).toHaveBeenCalledWith({ + expect(mockInstanceClient.newGroup().offerForSale).toHaveBeenCalledWith({ args: { sellAmount: 10000000n, reservedFor: ALGORAND_ZERO_ADDRESS, @@ -253,9 +285,7 @@ describe('NfdManager', () => { it('should use provided reservedFor address', async () => { await manager.listForSale(10000000n, { reservedFor: OTHER_ADDRESS }) - expect( - mockInstanceClient.newGroup().offerForSale, - ).toHaveBeenCalledWith({ + expect(mockInstanceClient.newGroup().offerForSale).toHaveBeenCalledWith({ args: { sellAmount: 10000000n, reservedFor: OTHER_ADDRESS, @@ -288,9 +318,7 @@ describe('NfdManager', () => { it('should cancel the sale listing', async () => { const result = await manager.cancelSale() - expect( - mockInstanceClient.newGroup().cancelSale, - ).toHaveBeenCalledWith({ + expect(mockInstanceClient.newGroup().cancelSale).toHaveBeenCalledWith({ args: {}, staticFee: expect.objectContaining({ amountInMicroAlgo: 3000n, @@ -322,9 +350,7 @@ describe('NfdManager', () => { it('should lock segment minting', async () => { await manager.lockSegment(true, 300) - expect( - mockInstanceClient.newGroup().segmentLock, - ).toHaveBeenCalledWith({ + expect(mockInstanceClient.newGroup().segmentLock).toHaveBeenCalledWith({ args: { lock: true, usdPrice: 300n, @@ -338,9 +364,7 @@ describe('NfdManager', () => { it('should unlock segment minting', async () => { await manager.lockSegment(false) - expect( - mockInstanceClient.newGroup().segmentLock, - ).toHaveBeenCalledWith({ + expect(mockInstanceClient.newGroup().segmentLock).toHaveBeenCalledWith({ args: { lock: false, usdPrice: 0n, @@ -373,27 +397,27 @@ describe('NfdManager', () => { it('should lock vault opt-ins', async () => { await manager.lockVault(true) - expect( - mockInstanceClient.newGroup().vaultOptInLock, - ).toHaveBeenCalledWith({ - args: { lock: true }, - staticFee: expect.objectContaining({ - amountInMicroAlgo: 3000n, - }), - }) + expect(mockInstanceClient.newGroup().vaultOptInLock).toHaveBeenCalledWith( + { + args: { lock: true }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }, + ) }) it('should unlock vault opt-ins', async () => { await manager.lockVault(false) - expect( - mockInstanceClient.newGroup().vaultOptInLock, - ).toHaveBeenCalledWith({ - args: { lock: false }, - staticFee: expect.objectContaining({ - amountInMicroAlgo: 3000n, - }), - }) + expect(mockInstanceClient.newGroup().vaultOptInLock).toHaveBeenCalledWith( + { + args: { lock: false }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }, + ) }) it('should throw if not the owner', async () => { @@ -450,21 +474,24 @@ describe('NfdManager', () => { await manager.sendToVault([42], { amount: 500n, note: 'test' }) expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalled() - expect( - mockAlgorand.createTransaction.assetTransfer, - ).toHaveBeenCalledWith({ - sender: expect.anything(), - receiver: VAULT_ADDRESS, - assetId: 42n, - amount: 500n, - note: 'test', - }) + expect(mockAlgorand.createTransaction.assetTransfer).toHaveBeenCalledWith( + { + sender: expect.anything(), + receiver: VAULT_ADDRESS, + assetId: 42n, + amount: 500n, + note: 'test', + }, + ) }) it('should throw if NFD has no vault account', async () => { - vi.spyOn(client, 'resolve').mockResolvedValueOnce({ - ...mockOwnedNfd, - nfdAccount: undefined, + vi.spyOn( + LookupModule.prototype, + 'resolveWithBoxes', + ).mockResolvedValueOnce({ + nfd: { ...mockOwnedNfd, nfdAccount: undefined }, + boxes: [], }) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(manager as any)._nfd = null @@ -528,9 +555,9 @@ describe('NfdManager', () => { }) it('should throw if no assets specified', async () => { - await expect( - manager.sendFromVault([], OTHER_ADDRESS), - ).rejects.toThrow('At least one asset must be specified') + await expect(manager.sendFromVault([], OTHER_ADDRESS)).rejects.toThrow( + 'At least one asset must be specified', + ) }) it('should include note when provided', async () => { @@ -548,4 +575,85 @@ describe('NfdManager', () => { ) }) }) + + describe('linkAddress', () => { + /** Seed the manager's cache with a v.caAlgo.0.as box of the given value */ + function seedCaAlgoBox(value: Uint8Array) { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockOwnedNfd, + boxes: [{ name: 'v.caAlgo.0.as', value }], + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'getRegistryClient').mockReturnValue( + mockRegistryClient, + ) + } + + /** The eventual field values passed to the update-cost simulate */ + function eventualFields(): Uint8Array[] { + return mockInstanceClient.newGroup().getFieldUpdateCost.mock.calls[0][0] + .args.fieldAndVals + } + + it('sizes the update from the already-resolved caAlgo box', async () => { + // One populated 32-byte slot followed by an empty (zero filled) one + const curCaAlgo = new Uint8Array(64) + curCaAlgo.fill(7, 0, 32) + seedCaAlgoBox(curCaAlgo) + + await manager.linkAddress(OTHER_ADDRESS) + + // The box came from the resolve, so it is not read a second time + expect(mockInstanceClient.appClient.getBoxValue).not.toHaveBeenCalled() + + // Existing 64 bytes plus the newly linked 32-byte public key + expect(eventualFields()[3]).toHaveLength(96) + }) + + it('keeps empty address slots, which count toward the update cost', async () => { + const curCaAlgo = new Uint8Array(64) + curCaAlgo.fill(7, 0, 32) + seedCaAlgoBox(curCaAlgo) + + await manager.linkAddress(OTHER_ADDRESS) + + // Bytes 32-63 are the zero filled slot and must survive intact + const combined = eventualFields()[3] + expect(Array.from(combined.slice(32, 64))).toEqual(Array(32).fill(0)) + expect(Array.from(combined.slice(0, 32))).toEqual(Array(32).fill(7)) + }) + + it('handles an NFD with no caAlgo box', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockOwnedNfd, + boxes: [], + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'getRegistryClient').mockReturnValue( + mockRegistryClient, + ) + + await manager.linkAddress(OTHER_ADDRESS) + + // Just the newly linked public key + expect(eventualFields()[3]).toHaveLength(32) + }) + + it('rejects a caller that is not the owner', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, owner: OTHER_ADDRESS }, + boxes: [], + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(manager as any)._nfd = null + + await expect(manager.linkAddress(OTHER_ADDRESS)).rejects.toThrow( + 'Only the owner can link addresses to this NFD', + ) + }) + }) }) diff --git a/packages/sdk/tests/utils/boxes.test.ts b/packages/sdk/tests/utils/boxes.test.ts new file mode 100644 index 0000000..c1300a4 --- /dev/null +++ b/packages/sdk/tests/utils/boxes.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest' + +import { getAllBoxes } from '../../src/utils/internal/boxes' + +import type { Algodv2 } from 'algosdk' + +const APP_ID = 1207664422n +const encoder = new TextEncoder() + +/** A box as algod returns it from `getApplicationBoxes` */ +interface RawBox { + name: Uint8Array + value?: Uint8Array +} + +/** One page of a `getApplicationBoxes` response */ +interface BoxPage { + boxes: RawBox[] + nextToken?: string + round?: number +} + +/** Record of the query params applied to one `getApplicationBoxes` request */ +interface BoxRequestCall { + include: string[] + next?: string + round?: number +} + +function box(name: string, value: string | Uint8Array): RawBox { + return { + name: encoder.encode(name), + value: typeof value === 'string' ? encoder.encode(value) : value, + } +} + +/** + * Build a mock algod serving the given pages in order, recording the query + * params applied to each request + */ +function createMockAlgod(pages: BoxPage[]) { + const calls: BoxRequestCall[] = [] + let pageIndex = 0 + + const getApplicationBoxes = vi.fn(() => { + const call: BoxRequestCall = { include: [] } + calls.push(call) + + const page = pages[pageIndex++] ?? { boxes: [] } + + const request = { + include: vi.fn((...values: string[]) => { + call.include.push(...values) + return request + }), + next: vi.fn((token: string) => { + call.next = token + return request + }), + round: vi.fn((round: number) => { + call.round = Number(round) + return request + }), + do: vi.fn(async () => page), + } + + return request + }) + + return { algod: { getApplicationBoxes } as unknown as Algodv2, calls } +} + +describe('getAllBoxes', () => { + it('reads names and values in a single request', async () => { + const { algod, calls } = createMockAlgod([ + { + boxes: [box('u.url', 'https://example.com'), box('u.bio', 'hello')], + round: 64449651, + }, + ]) + + const boxes = await getAllBoxes(algod, APP_ID) + + expect(boxes).toEqual([ + { name: 'u.url', value: encoder.encode('https://example.com') }, + { name: 'u.bio', value: encoder.encode('hello') }, + ]) + // One request, not one per box + expect(algod.getApplicationBoxes).toHaveBeenCalledTimes(1) + expect(algod.getApplicationBoxes).toHaveBeenCalledWith(APP_ID) + expect(calls[0].include).toEqual(['values']) + }) + + it('returns an empty list for an app with no boxes', async () => { + const { algod } = createMockAlgod([{ boxes: [] }]) + + await expect(getAllBoxes(algod, APP_ID)).resolves.toEqual([]) + }) + + it('throws when the node returns a box without a value', async () => { + const { algod } = createMockAlgod([ + { boxes: [{ name: encoder.encode('u.url') }] }, + ]) + + await expect(getAllBoxes(algod, APP_ID)).rejects.toThrow(/without a value/) + }) + + describe('pagination', () => { + it('follows the next token and merges every page', async () => { + const { algod } = createMockAlgod([ + { + boxes: [box('u.url', 'https://example.com')], + nextToken: 'b64:dS51cmw=', + round: 64449651, + }, + { boxes: [box('u.bio', 'hello')] }, + ]) + + const boxes = await getAllBoxes(algod, APP_ID) + + expect(boxes.map((b) => b.name)).toEqual(['u.url', 'u.bio']) + expect(algod.getApplicationBoxes).toHaveBeenCalledTimes(2) + }) + + it('pins pages after the first to the round of the first page', async () => { + const { algod, calls } = createMockAlgod([ + { + boxes: [box('u.url', 'https://example.com')], + nextToken: 'b64:dS51cmw=', + round: 64449651, + }, + { boxes: [box('u.bio', 'hello')], round: 64449999 }, + ]) + + await getAllBoxes(algod, APP_ID) + + // The first request is unpinned, later ones carry the first round + expect(calls[0].round).toBeUndefined() + expect(calls[0].next).toBeUndefined() + expect(calls[1].round).toBe(64449651) + expect(calls[1].next).toBe('b64:dS51cmw=') + expect(calls[1].include).toEqual(['values']) + }) + + it('throws instead of looping when the cursor does not advance', async () => { + // A node that keeps handing back the same token would otherwise spin + // forever, accumulating boxes until the process runs out of memory + const stuck = { boxes: [box('u.url', 'x')], nextToken: 'same-token' } + const { algod } = createMockAlgod([stuck, stuck, stuck, stuck]) + + await expect(getAllBoxes(algod, APP_ID)).rejects.toThrow( + /did not advance/, + ) + + // Bailed on the repeat rather than exhausting every page + expect(algod.getApplicationBoxes).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/packages/sdk/tests/utils/nfd-record.test.ts b/packages/sdk/tests/utils/nfd-record.test.ts index 66279f4..816c60a 100644 --- a/packages/sdk/tests/utils/nfd-record.test.ts +++ b/packages/sdk/tests/utils/nfd-record.test.ts @@ -3,7 +3,8 @@ import { describe, it, expect } from 'vitest' import { buildNfdRecord } from '../../src/utils/internal/nfd-record' -import type { AppState, BoxName } from '@algorandfoundation/algokit-utils/types/app' +import type { AppBox } from '../../src/utils/internal/boxes' +import type { AppState } from '@algorandfoundation/algokit-utils/types/app' const APP_ID = 763844423n const APP_ADDRESS = getApplicationAddress(APP_ID).toString() @@ -13,10 +14,17 @@ const ADDR_B = 'RSV2YCHXA7MWGFTX3WYI7TVGAS5W5XH5M7ZQVXPPRQ7DNTNW36OW2TRR6I' const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s) function bytesEntry(valueRaw: Uint8Array, value = ''): AppState[string] { - return { value, valueRaw, valueBase64: '', keyRaw: new Uint8Array(), keyBase64: '' } + return { + value, + valueRaw, + valueBase64: '', + keyRaw: new Uint8Array(), + keyBase64: '', + } } const stringEntry = (s: string): AppState[string] => bytesEntry(utf8(s), s) -const uintEntry = (n: number | bigint): AppState[string] => bytesEntry(encodeUint64(n)) +const uintEntry = (n: number | bigint): AppState[string] => + bytesEntry(encodeUint64(n)) const addressEntry = (a: string): AppState[string] => bytesEntry(Address.fromString(a).publicKey) @@ -52,29 +60,14 @@ function makeState(overrides: StateOverrides = {}): AppState { return state } -const box = (name: string): BoxName => ({ - name, - nameRaw: utf8(name), - nameBase64: '', -}) - -function boxReader(values: Record) { - return (nameRaw: Uint8Array): Promise => - Promise.resolve(values[new TextDecoder().decode(nameRaw)] ?? new Uint8Array(0)) +/** Boxes arrive from algod with names and values together */ +function boxesFrom(values: Record): AppBox[] { + return Object.entries(values).map(([name, value]) => ({ name, value })) } -const noBoxes = boxReader({}) - describe('buildNfdRecord', () => { describe('box parsing (full view)', () => { it('parses verified caAlgo, user-defined, unverified and split fields', async () => { - const boxes = [ - box('v.caAlgo.0.as'), - box('u.url'), - box('u.caalgo'), - box('u.bio_00'), - box('u.bio_01'), - ] const values: Record = { // One real 32-byte public key + one zero key (which must be skipped) 'v.caAlgo.0.as': new Uint8Array([ @@ -91,8 +84,7 @@ describe('buildNfdRecord', () => { appId: APP_ID, appAddress: APP_ADDRESS, globalState: makeState(), - boxes, - getBoxValue: boxReader(values), + boxes: boxesFrom(values), view: 'full', }) @@ -106,9 +98,62 @@ describe('buildNfdRecord', () => { }) }) + describe('split fields', () => { + it('reassembles chunks in index order regardless of arrival order', () => { + const nfd = buildNfdRecord({ + appId: APP_ID, + appAddress: APP_ADDRESS, + globalState: makeState(), + boxes: [ + { name: 'u.bio_01', value: utf8(' world') }, + { name: 'u.bio_00', value: utf8('hello') }, + { name: 'u.bio_02', value: utf8('!') }, + ], + view: 'full', + }) + + expect(nfd.properties?.userDefined?.bio).toBe('hello world!') + }) + + it('skips gaps left by a missing chunk', () => { + const nfd = buildNfdRecord({ + appId: APP_ID, + appAddress: APP_ADDRESS, + globalState: makeState(), + boxes: [ + { name: 'u.bio_00', value: utf8('hello') }, + // no _01 + { name: 'u.bio_02', value: utf8('!') }, + ], + view: 'full', + }) + + expect(nfd.properties?.userDefined?.bio).toBe('hello!') + }) + }) + + describe('verified caAlgo parsing', () => { + it('skips zero-filled address slots', () => { + const value = new Uint8Array(96) + value.set(Address.fromString(OWNER).publicKey, 0) + // bytes 32-63 stay zero — an empty slot + value.set(Address.fromString(ADDR_B).publicKey, 64) + + const nfd = buildNfdRecord({ + appId: APP_ID, + appAddress: APP_ADDRESS, + globalState: makeState(), + boxes: [{ name: 'v.caAlgo.0.as', value }], + view: 'full', + }) + + expect(nfd.caAlgo).toEqual([OWNER, ADDR_B]) + expect(nfd.properties?.verified?.caAlgo).toBe(`${OWNER},${ADDR_B}`) + }) + }) + describe('view filtering', () => { it('tiny view includes caAlgo/url but excludes other user fields', async () => { - const boxes = [box('v.caAlgo.0.as'), box('u.url'), box('u.bio')] const values: Record = { 'v.caAlgo.0.as': Address.fromString(OWNER).publicKey, 'u.url': utf8('https://example.com'), @@ -119,8 +164,7 @@ describe('buildNfdRecord', () => { appId: APP_ID, appAddress: APP_ADDRESS, globalState: makeState(), - boxes, - getBoxValue: boxReader(values), + boxes: boxesFrom(values), view: 'tiny', }) @@ -135,11 +179,7 @@ describe('buildNfdRecord', () => { ['owned', { owner: OWNER }, 'owned'], ['forSale', { owner: OWNER, sellamt: 1_000_000 }, 'forSale'], ['minting', { owner: OWNER, minting: '1' }, 'minting'], - [ - 'reserved', - { owner: APP_ADDRESS, reservedOwner: OWNER }, - 'reserved', - ], + ['reserved', { owner: APP_ADDRESS, reservedOwner: OWNER }, 'reserved'], ['available', { owner: APP_ADDRESS }, 'available'], ['expired', { owner: OWNER, expirationTime: 1_000_000 }, 'expired'], ] @@ -150,7 +190,6 @@ describe('buildNfdRecord', () => { appAddress: APP_ADDRESS, globalState: makeState(overrides), boxes: [], - getBoxValue: noBoxes, }) expect(nfd.state).toBe(expected) }) @@ -161,7 +200,6 @@ describe('buildNfdRecord', () => { appAddress: APP_ADDRESS, globalState: makeState({ owner: OWNER, expirationTime: 1_000_000 }), boxes: [], - getBoxValue: noBoxes, }) expect(nfd.expired).toBe(true) expect(nfd.depositAccount).toBeUndefined() @@ -175,7 +213,6 @@ describe('buildNfdRecord', () => { appAddress: APP_ADDRESS, globalState: makeState({ owner: OWNER }), boxes: [], - getBoxValue: noBoxes, }) expect(nfd.name).toBe('example.algo') expect(nfd.appID).toBe(Number(APP_ID)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e312d18..1b91fed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: typescript: specifier: ^5.5.3 version: 5.7.3 + typescript-eslint: + specifier: ^8.24.1 + version: 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) examples/api-search: dependencies: @@ -84,8 +87,8 @@ importers: specifier: workspace:* version: link:../../packages/sdk algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -113,16 +116,16 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@txnlab/nfd-sdk': specifier: workspace:* version: link:../../packages/sdk '@txnlab/use-wallet-react': specifier: ^4.0.0 - version: 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 lute-connect: specifier: ^1.4.1 version: 1.4.1 @@ -153,16 +156,16 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@txnlab/nfd-sdk': specifier: workspace:* version: link:../../packages/sdk '@txnlab/use-wallet-react': specifier: ^4.0.0 - version: 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 lute-connect: specifier: ^1.4.1 version: 1.4.1 @@ -209,16 +212,16 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@txnlab/nfd-sdk': specifier: workspace:* version: link:../../packages/sdk '@txnlab/use-wallet-react': specifier: ^4.0.0 - version: 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 lute-connect: specifier: ^1.4.1 version: 1.4.1 @@ -251,8 +254,8 @@ importers: specifier: workspace:* version: link:../../packages/sdk algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -282,8 +285,8 @@ importers: specifier: workspace:* version: link:../../packages/sdk algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -313,8 +316,8 @@ importers: specifier: workspace:* version: link:../../packages/sdk algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -342,16 +345,16 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@txnlab/nfd-sdk': specifier: workspace:* version: link:../../packages/sdk '@txnlab/use-wallet-react': specifier: ^4.0.0 - version: 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 lute-connect: specifier: ^1.4.1 version: 1.4.1 @@ -382,16 +385,16 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@txnlab/nfd-sdk': specifier: workspace:* version: link:../../packages/sdk '@txnlab/use-wallet-react': specifier: ^4.0.0 - version: 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 lute-connect: specifier: ^1.4.1 version: 1.4.1 @@ -422,14 +425,14 @@ importers: dependencies: '@algorandfoundation/algokit-utils': specifier: ^8.2.2 - version: 8.2.2(algosdk@3.5.2) + version: 8.2.2(algosdk@3.7.0) '@hey-api/client-fetch': specifier: ^0.8.1 version: 0.8.1 devDependencies: '@algorandfoundation/algokit-client-generator': specifier: ^4.0.8 - version: 4.0.8(@algorandfoundation/algokit-utils@8.2.2(algosdk@3.5.2))(algosdk@3.5.2) + version: 4.0.8(@algorandfoundation/algokit-utils@8.2.2(algosdk@3.7.0))(algosdk@3.7.0) '@hey-api/openapi-ts': specifier: ^0.64.5 version: 0.64.5(magicast@0.3.5)(typescript@5.9.3) @@ -449,8 +452,8 @@ importers: specifier: ^3.0.7 version: 3.0.7(vitest@3.0.7) algosdk: - specifier: ^3.5.2 - version: 3.5.2 + specifier: ^3.7.0 + version: 3.7.0 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -1706,6 +1709,10 @@ packages: resolution: {integrity: sha512-frhGtZl1JvfrLRKmMvUm880wj4OiWsWo2FhbreNWh7pdFsKuWPj60fV682wt/CYefLI70iwHavPOwGBkTVt0VA==} engines: {node: '>=18.0.0'} + algosdk@3.7.0: + resolution: {integrity: sha512-6mr5w+A+A87/rAd4Y5hSmKSfvLkOKeik81w8Tte9ZxSqnwqUuWUioQsJail3ZyCbJtEctJXJTxe7Sx50/lQi3g==} + engines: {node: '>=18.0.0'} + alien-signals@0.4.14: resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} @@ -2457,6 +2464,7 @@ packages: eslint@9.39.0: resolution: {integrity: sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -4902,18 +4910,18 @@ snapshots: '@actions/io@3.0.2': {} - '@algorandfoundation/algokit-client-generator@4.0.8(@algorandfoundation/algokit-utils@8.2.2(algosdk@3.5.2))(algosdk@3.5.2)': + '@algorandfoundation/algokit-client-generator@4.0.8(@algorandfoundation/algokit-utils@8.2.2(algosdk@3.7.0))(algosdk@3.7.0)': dependencies: - '@algorandfoundation/algokit-utils': 8.2.2(algosdk@3.5.2) - algosdk: 3.5.2 + '@algorandfoundation/algokit-utils': 8.2.2(algosdk@3.7.0) + algosdk: 3.7.0 chalk: 4.1.2 change-case: 5.4.4 commander: 11.1.0 jsonschema: 1.5.0 - '@algorandfoundation/algokit-utils@8.2.2(algosdk@3.5.2)': + '@algorandfoundation/algokit-utils@8.2.2(algosdk@3.7.0)': dependencies: - algosdk: 3.5.2 + algosdk: 3.7.0 buffer: 6.0.3 '@ampproject/remapping@2.3.0': @@ -5198,6 +5206,11 @@ snapshots: eslint: 9.39.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.21.0(jiti@2.4.2))': + dependencies: + eslint: 9.21.0(jiti@2.4.2) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.0(jiti@2.4.2))': dependencies: eslint: 9.39.0(jiti@2.4.2) @@ -5860,11 +5873,11 @@ snapshots: '@tanstack/store@0.7.0': {} - '@txnlab/use-wallet-react@4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@txnlab/use-wallet-react@4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/react-store': 0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@txnlab/use-wallet': 4.0.0(algosdk@3.5.2)(lute-connect@1.4.1) - algosdk: 3.5.2 + '@txnlab/use-wallet': 4.0.0(algosdk@3.7.0)(lute-connect@1.4.1) + algosdk: 3.7.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -5872,10 +5885,10 @@ snapshots: transitivePeerDependencies: - '@agoralabs-sh/avm-web-provider' - '@txnlab/use-wallet@4.0.0(algosdk@3.5.2)(lute-connect@1.4.1)': + '@txnlab/use-wallet@4.0.0(algosdk@3.7.0)(lute-connect@1.4.1)': dependencies: '@tanstack/store': 0.7.0 - algosdk: 3.5.2 + algosdk: 3.7.0 optionalDependencies: lute-connect: 1.4.1 @@ -5959,10 +5972,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/parser': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/scope-manager': 8.25.0 '@typescript-eslint/type-utils': 8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) @@ -6059,7 +6072,7 @@ snapshots: '@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.21.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.25.0 '@typescript-eslint/types': 8.25.0 '@typescript-eslint/typescript-estree': 8.25.0(typescript@5.7.3) @@ -6070,7 +6083,7 @@ snapshots: '@typescript-eslint/utils@8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.39.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.25.0 '@typescript-eslint/types': 8.25.0 '@typescript-eslint/typescript-estree': 8.25.0(typescript@5.9.3) @@ -6306,6 +6319,17 @@ snapshots: tweetnacl: 1.0.3 vlq: 2.0.4 + algosdk@3.7.0: + dependencies: + algorand-msgpack: 1.1.0 + hi-base32: 0.5.1 + js-sha256: 0.9.0 + js-sha3: 0.8.0 + js-sha512: 0.8.0 + json-bigint: 1.0.0 + tweetnacl: 1.0.3 + vlq: 2.0.4 + alien-signals@0.4.14: {} ansi-escapes@7.3.0: @@ -7126,14 +7150,14 @@ snapshots: eslint-plugin-es-x@7.8.0(eslint@9.21.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.21.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 eslint: 9.21.0(jiti@2.4.2) eslint-compat-utils: 0.5.1(eslint@9.21.0(jiti@2.4.2)) eslint-plugin-es-x@7.8.0(eslint@9.39.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.39.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 eslint: 9.39.0(jiti@2.4.2) eslint-compat-utils: 0.5.1(eslint@9.39.0(jiti@2.4.2)) @@ -9503,7 +9527,7 @@ snapshots: typescript-eslint@8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3))(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/parser': 8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) '@typescript-eslint/utils': 8.25.0(eslint@9.39.0(jiti@2.4.2))(typescript@5.9.3) eslint: 9.39.0(jiti@2.4.2) From 043e4c2062e95012d3a4764df5291f1282699177 Mon Sep 17 00:00:00 2001 From: Patrick Bennett Date: Thu, 27 Aug 2026 16:21:38 -0400 Subject: [PATCH 3/5] fix(core): honor the instance contract's rules for vault and sale calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault and sale methods were built against the ARC-56 JSON, which carries arg types but not a single assert. Reading the TealScript source turned up several rules the SDK was ignoring — one of which made sendToVault unusable. - sendToVault built its group as [vaultOptIn] first with no MBR payment, so every opt-in failed `assert(groupIndex > 0, 'must have prior txn paying MBR')`. The group is now [MBR payment] -> [vaultOptIn] -> [optional transfer], funding 0.1 ALGO per asset (VAULT_OPT_IN_MBR). - vaultSend rejects otherAssets whenever amount is non-zero, and requires amount > 0 with no other assets for ALGO. sendFromVault documented the opposite; it now validates both, and refuses to send the NFD's own ASA to anyone but the owner. - Most owner-driven writes sit behind assertOwnerCalledNotForSaleOrExpired(). sendToVault, sendFromVault, lockSegment and lockVault now check that up front, as do listForSale (expired, minting, and the zero-box requirement) and cancelSale (must be for sale). - lockSegment validates an unlock price against the registry's segmentPlatformCostInUsd, so the default of 0 no longer guarantees failure. - renew reads maxYearsAllowed from the registry rather than a hardcoded 20. Two unrelated correctness fixes in the same surface: - parseAppId used parseInt, so the all-digit NFD name '123.algo' resolved to app 123. It now requires the whole string to be numeric. - resolveVaultReceiver validates a non-name receiver as an address instead of letting ABI encoding fail inside the send, where it read as a transaction failure. CLAUDE.md tabulates the asserts each SDK method mirrors and points at the contract repo via $NFD_CONTRACTS, so the next reader does not have to re-derive them from a JSON that carries none of it. It also states what the suite cannot prove: the composer is mocked, so a green run says nothing about whether a node would accept the group — sendToVault was malformed while its tests passed. Also fixes the two ways `pnpm format` disagreed with `pnpm format:check` about scope. It was `pnpm -r format`, which skipped root-level files and resolved a different .prettierignore per package, rewriting the generated contract clients that the root run correctly excludes. Both are now `prettier .` from the root, and the package ignore lists the generated paths for direct runs. --- .github/workflows/ci.yml | 3 + CLAUDE.md | 73 ++- README.md | 140 ++++++ package.json | 5 +- packages/sdk/.prettierignore | 9 + packages/sdk/README.md | 140 ++++++ packages/sdk/src/client.ts | 1 + packages/sdk/src/constants.ts | 24 + packages/sdk/src/index.ts | 3 + packages/sdk/src/modules/base.ts | 42 ++ packages/sdk/src/modules/lookup.ts | 36 -- packages/sdk/src/modules/manager.ts | 429 +++++++++++++--- packages/sdk/src/modules/purchasing.ts | 22 +- packages/sdk/src/types.ts | 17 +- packages/sdk/src/utils/internal/numbers.ts | 35 ++ packages/sdk/tests/modules/lookup.test.ts | 11 + packages/sdk/tests/modules/manager.test.ts | 458 +++++++++++++++++- packages/sdk/tests/modules/purchasing.test.ts | 65 ++- packages/sdk/tests/utils/numbers.test.ts | 45 ++ 19 files changed, 1406 insertions(+), 152 deletions(-) create mode 100644 packages/sdk/src/utils/internal/numbers.ts create mode 100644 packages/sdk/tests/utils/numbers.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4187eb2..07ae1e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: - name: Lint run: pnpm lint + - name: Format Check + run: pnpm format:check + - name: Type Check run: pnpm typecheck diff --git a/CLAUDE.md b/CLAUDE.md index 7a87bbe..8df8d67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ pnpm test:watch # Run tests in watch mode pnpm test:coverage # Run tests with v8 coverage pnpm lint # ESLint across all packages pnpm format # Prettier --write across all packages +pnpm format:check # Prettier --check from the root (what CI runs) pnpm typecheck # TypeScript type checking (SDK only) pnpm build:examples # Build all example apps ``` @@ -56,7 +57,7 @@ Regenerate all: `pnpm --filter @txnlab/nfd-sdk generate` - **MetadataModule** (`modules/metadata.ts`) — avatar/banner image retrieval with IPFS conversion - **MintingModule** (`modules/minting.ts`) — mint new NFDs with price quoting - **PurchasingModule** (`modules/purchasing.ts`) — claim reserved NFDs, buy from marketplace -- **NfdManager** (`modules/manager.ts`) — link addresses, set metadata, set primary NFD +- **NfdManager** (`modules/manager.ts`) — link addresses, set metadata, set primary NFD, renew, list/cancel a sale, lock segments and the vault, send to and from the vault All modules extend `BaseModule` (`modules/base.ts`), which provides access to the Algorand client, registry contract client, instance contract client, and signer management. @@ -85,7 +86,67 @@ Consequences to keep in mind: ### Constants -`src/constants.ts` contains registry app IDs (`NfdRegistryId` enum: MAINNET=760937186, TESTNET=84366825) and API base URLs. +`src/constants.ts` contains registry app IDs (`NfdRegistryId` enum: MAINNET=760937186, TESTNET=84366825), API base URLs, the Algorand zero address, the static fees contract calls are sent with (`APP_CALL_STATIC_FEE`, `RENEW_STATIC_FEE`, `VAULT_FEE_PER_ASSET`), and the vault's per-asset minimum balance (`VAULT_OPT_IN_MBR`). Put new fee figures here rather than inline — the same `3000n` appears in half a dozen methods and drifted apart once already. + +Protocol limits are **not** constants: `maxYearsAllowed` and `segmentPlatformCostInUsd` come from `BaseModule.getConstraints()` at call time. A `MAX_RENEWAL_YEARS = 20` constant used to sit here and was wrong — the registry owns that number. + +### The contract source + +**Read the TealScript before writing or reviewing a method that wraps a contract call.** The ARC-56 JSON gives arg types and not a single `assert`, and every rule in the table below was shipped wrong because the JSON looked sufficient. + +The contracts are a separate repo (`TxnLab/nfd-contracts`), not a dependency of this one. Set `$NFD_CONTRACTS` to your checkout — the two files that matter are: + +``` +$NFD_CONTRACTS/contracts/v3/contracts/ + NFDInstance.algo.ts # per-NFD instance: vault, sale, locks, renew, fields + NFDRegistry.algo.ts # registry: mint, constraints, name/address boxes +``` + +If it is unset, find an existing checkout by the file rather than assuming a layout: + +```bash +# macOS +NFD_CONTRACTS=$(mdfind 'kMDItemFSName == "NFDInstance.algo.ts"' | head -1) +# portable +NFD_CONTRACTS=$(find ~ -maxdepth 7 -type f -name NFDInstance.algo.ts \ + -not -path '*/node_modules/*' 2>/dev/null | head -1) +``` + +If neither turns anything up, the repo is not cloned — say so rather than guessing from the ABI. + +Useful grep targets in `NFDInstance.algo.ts`: the private helpers at the bottom of the file — `mustBeCalledByOwner()`, `notForSaleOrExpired()`, `assertOwnerCalledNotForSaleOrExpired()`, `isForSale()`, `isExpired()` — tell you a method's preconditions in one line, since almost every public method opens with a call to one of them. + +### Contract preconditions the SDK mirrors + +| Contract method | Asserts | Mirrored in | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `vaultOptIn` | Not first in group; the transaction immediately before pays the vault `100_000 × assets.length`; `notForSaleOrExpired()`; owner only when the vault is locked | `sendToVault` builds `[MBR payment] → [vaultOptIn] → [optional transfer]` | +| `vaultSend` | `assertOwnerCalledNotForSaleOrExpired()`; receiver ≠ zero address; `otherAssets` empty whenever `amount !== 0`; for ALGO (`asset === 0`) `amount > 0` and no other assets; the NFD's own ASA is clawed to the owner only | `sendFromVault` | +| `offerForSale` | Owner; not expired; not minting; **`totalBoxes === 0`** | `listForSale` (box count comes free from `getNfd()`) | +| `cancelSale` | Owner; not expired; not minting; `isForSale()` | `cancelSale` | +| `segmentLock` | `assertOwnerCalledNotForSaleOrExpired()`; on unlock, `usdPrice >= segmentPlatformCostInUsd` (cents) | `lockSegment` | +| `vaultOptInLock` | `assertOwnerCalledNotForSaleOrExpired()` | `lockVault` | +| `renew` | Payment ≥ one year's price; expiration capped by the registry's `maxYearsAllowed`; metadata must be cleared if someone other than the owner claims an expired NFD | `renew` | +| `postOffer` | Nothing — it only logs an ARC-28 event | `makeOffer` needs no precondition checks | + +The recurring shape: **listing an NFD for sale or letting it expire blocks nearly every owner-driven write** until it is cancelled or renewed. `NfdManager.assertNotForSaleOrExpired` and `assertNotMinting` exist to turn those into errors that name the cure. + +Two more that bite when adding a method: + +- **Check the ABI arg types against `src/contracts/minimal/*.arc56.json` before writing the JSDoc.** `vaultSend`'s `receiver` is an ABI `address`, not a string, so "accepts an NFD name" was a promise the code could not keep — a name has to be resolved to an address first (`NfdManager.resolveVaultReceiver`). A doc comment describing the NFD _API_'s behavior is not evidence of what the _contract_ accepts. +- **Coerce caller-supplied amounts with `toAmount()` (`src/utils/internal/numbers.ts`), never bare `BigInt()`.** `BigInt(1.5)` throws a `RangeError` that names neither the parameter nor the method; `toAmount(price, 'Sale price')` says which argument was wrong, and rejects negatives and unsafe integers as well. + +Validate arguments and throw _before_ the `try` that wraps the send, so a bad argument is not reported as `Failed to …: ` as though the transaction had failed. Prefer checking a precondition the resolved `Nfd` already answers over letting an opaque `assert` failure come back from chain — `getNfd()` has the state and the boxes cached. + +### What the tests can and cannot prove + +`tests/modules/*.test.ts` mock the typed client and the composer wholesale. They verify **the SDK's own logic** — which guard fires, which args and fees a call is given, what order transactions are added in — and nothing about whether a node would accept the result. The `sendToVault` group was malformed for the contract's `groupIndex`/MBR asserts while its tests were green. + +So: + +- Assert on group **order** (`invocationCallOrder` on the group mock) wherever the contract cares about position. `TransactionComposer.build()` iterates `this.txns` in push order, so add-order is group order. +- A green suite is not evidence a call works on chain. Confirming that needs a `simulate()` against TestNet or a LocalNet run, which nothing in CI does today. +- When a change is driven by a contract `assert`, quote the assert in the test comment. The next reader cannot re-derive it from the ABI. ## Code Style @@ -101,9 +162,11 @@ Angular commit format: `type(scope): subject` Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore` Scopes: `core`, `api`, `contracts`, `metadata`, `purchasing`, etc. -`feat` → minor version bump, `fix` → patch, `BREAKING CHANGE` in footer → major (manual). +`feat` → minor version bump, `fix` → patch, `BREAKING CHANGE` in footer → major. + +Versions are computed by semantic-release from the commits since the last tag, so **do not hand-edit the `version` field** in `packages/sdk/package.json` — `@semantic-release/npm` overwrites it and `@semantic-release/git` commits the result. ## CI -PR checks (`pr.yml`): lint → typecheck → test → build → build:examples. All must pass. -Publishing (`ci.yml`): automatic on push to main/alpha/beta/next branches via TanStack publish config. +PR checks (`ci.yml`): lint → format:check → typecheck → test → build → build:examples. All must pass. The same sequence is the root `ci` script, which `release` runs first. +Publishing (`release.yml`): on push to a release branch, semantic-release publishes to npm via OIDC trusted publishing (no `NPM_TOKEN`), cuts the GitHub release, and commits `chore(release): x.y.z [skip ci]`. diff --git a/README.md b/README.md index 9fe1b3d..0b4b9a1 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,146 @@ const updatedNfd2 = await nfd }) ``` +### Renewing an NFD + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Quote the renewal price per year, in microAlgos +const pricePerYear = await manager.getRenewalPrice() + +// Renew for a whole number of years (default 1). The upper bound comes from +// the registry's maxYearsAllowed. +const renewed = await manager.renew(2) +``` + +### Listing an NFD for Sale + +An NFD can only be sold once its properties are cleared — the contract refuses to +list one that still has user-defined or verified fields. + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// List for 100 ALGO, open to anyone +await manager.listForSale(100_000_000n) + +// Or reserve the sale for one buyer +await manager.listForSale(100_000_000n, { reservedFor: 'BUYER_ADDRESS' }) + +// Take it off the market +await manager.cancelSale() +``` + +While an NFD is listed, the contract blocks the owner-driven writes that change +it — setting metadata, locking segments or the vault, and vault transfers. Call +`cancelSale()` before any of those. An expired NFD is blocked the same way until +it is renewed. + +### Making an Offer + +Offer to buy an NFD from its current owner. The owner is free to accept or ignore it. + +```typescript +const result = await nfd + .setSigner(activeAddress, transactionSigner) + .makeOffer('example.algo', 50_000_000n, 'Would love to own this') +``` + +### Locking Segments and the Vault + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Allow anyone to mint segments of this NFD at $3.00 (price is in USD cents). +// The price must be at least the registry's segmentPlatformCostInUsd. +await manager.lockSegment(false, 300) + +// Stop segment minting entirely +await manager.lockSegment(true) + +// Restrict vault opt-ins to the owner (unlocked lets anyone opt the vault in) +await manager.lockVault(true) +``` + +### Vault Operations + +An NFD's vault holds assets on behalf of the NFD itself. + +Opting the vault into an asset raises its minimum balance by 0.1 ALGO, and +`sendToVault` funds that in the same group. The contract charges it per asset +passed, whether or not the vault already holds that asset, so pass only assets it +still needs. + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Opt the vault into assets without sending anything (costs 0.2 ALGO in MBR) +await manager.sendToVault([31566704, 312769], { optInOnly: true }) + +// Opt in and send in the same group. The amount applies to one asset, so call +// this once per asset to send several. +await manager.sendToVault([31566704], { amount: 1_000_000n, note: 'deposit' }) + +// ALGO (asset 0) needs no opt-in and owes no MBR, but can be sent the same way +await manager.sendToVault([0], { amount: 5_000_000n }) + +// Send one asset out of the vault by amount +await manager.sendFromVault([31566704], 'RECEIVER_ADDRESS', { + amount: 500_000n, +}) + +// Omit the amount to send the full balance, closing the vault out of each +// asset listed. An amount cannot be combined with more than one asset. +await manager.sendFromVault([31566704, 312769], 'RECEIVER_ADDRESS') + +// ALGO leaves the vault on its own, and always by amount +await manager.sendFromVault([0], 'RECEIVER_ADDRESS', { amount: 500_000n }) + +// The receiver can also be an NFD name, resolved to its deposit account… +await manager.sendFromVault([31566704], 'friend.algo', { amount: 500_000n }) + +// …or to that NFD's own vault +await manager.sendFromVault([31566704], 'friend.algo', { + amount: 500_000n, + receiverType: 'nfdVault', +}) +``` + +### Verifying NFD Properties + +Verification is a two-step exchange with the NFD API: request a challenge, satisfy it +out of band (a DNS record, a social post), then confirm. + +```typescript +// Step 1 — request a challenge. The signer identifies the NFD owner. +const request = await nfd + .setSigner(activeAddress, transactionSigner) + .verifyRequest('example.algo', 'domain') + +// Step 2 — confirm once the challenge has been satisfied +const result = await nfd.verifyConfirm(request.id, request.challenge) +``` + +Verifiable fields: `blueskydid`, `twitter`, `github`, `domain`, `email`, `avatar`, `banner`. + +### Name Suggestions + +```typescript +const suggestions = await nfd.suggest('patrick', { + buyer: activeAddress, + limit: 10, +}) +``` + ## Client Initialization Options The NFD client can be instantiated in several ways: diff --git a/package.json b/package.json index 66b0c84..d10f9b6 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "build:examples": "pnpm --filter './examples/*' build", "test": "pnpm -r test", "lint": "pnpm -r lint", - "format": "pnpm -r format", + "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "pnpm --filter @txnlab/nfd-sdk typecheck", - "ci": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run build:examples", + "ci": "pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run build:examples", "release": "pnpm run ci && semantic-release", "fresh-install": "rm -rf node_modules packages/*/node_modules examples/*/node_modules && pnpm install" }, diff --git a/packages/sdk/.prettierignore b/packages/sdk/.prettierignore index 1b763b1..11354e9 100644 --- a/packages/sdk/.prettierignore +++ b/packages/sdk/.prettierignore @@ -1 +1,10 @@ CHANGELOG.md + +# Generated files. Prettier resolves the ignore file next to its working +# directory, so `pnpm format` (which runs in this package) never sees the root +# .prettierignore — without these it rewrites the generated contract clients +# that `pnpm format:check` correctly skips. +src/api/openapi3.yaml +src/api/*.gen.ts +src/contracts/**/*.arc56.json +src/contracts/*Client.ts diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 5686b72..99c6e17 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -170,6 +170,146 @@ const updatedNfd2 = await nfd }) ``` +### Renewing an NFD + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Quote the renewal price per year, in microAlgos +const pricePerYear = await manager.getRenewalPrice() + +// Renew for a whole number of years (default 1). The upper bound comes from +// the registry's maxYearsAllowed. +const renewed = await manager.renew(2) +``` + +### Listing an NFD for Sale + +An NFD can only be sold once its properties are cleared — the contract refuses to +list one that still has user-defined or verified fields. + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// List for 100 ALGO, open to anyone +await manager.listForSale(100_000_000n) + +// Or reserve the sale for one buyer +await manager.listForSale(100_000_000n, { reservedFor: 'BUYER_ADDRESS' }) + +// Take it off the market +await manager.cancelSale() +``` + +While an NFD is listed, the contract blocks the owner-driven writes that change +it — setting metadata, locking segments or the vault, and vault transfers. Call +`cancelSale()` before any of those. An expired NFD is blocked the same way until +it is renewed. + +### Making an Offer + +Offer to buy an NFD from its current owner. The owner is free to accept or ignore it. + +```typescript +const result = await nfd + .setSigner(activeAddress, transactionSigner) + .makeOffer('example.algo', 50_000_000n, 'Would love to own this') +``` + +### Locking Segments and the Vault + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Allow anyone to mint segments of this NFD at $3.00 (price is in USD cents). +// The price must be at least the registry's segmentPlatformCostInUsd. +await manager.lockSegment(false, 300) + +// Stop segment minting entirely +await manager.lockSegment(true) + +// Restrict vault opt-ins to the owner (unlocked lets anyone opt the vault in) +await manager.lockVault(true) +``` + +### Vault Operations + +An NFD's vault holds assets on behalf of the NFD itself. + +Opting the vault into an asset raises its minimum balance by 0.1 ALGO, and +`sendToVault` funds that in the same group. The contract charges it per asset +passed, whether or not the vault already holds that asset, so pass only assets it +still needs. + +```typescript +const manager = nfd + .setSigner(activeAddress, transactionSigner) + .manage('example.algo') + +// Opt the vault into assets without sending anything (costs 0.2 ALGO in MBR) +await manager.sendToVault([31566704, 312769], { optInOnly: true }) + +// Opt in and send in the same group. The amount applies to one asset, so call +// this once per asset to send several. +await manager.sendToVault([31566704], { amount: 1_000_000n, note: 'deposit' }) + +// ALGO (asset 0) needs no opt-in and owes no MBR, but can be sent the same way +await manager.sendToVault([0], { amount: 5_000_000n }) + +// Send one asset out of the vault by amount +await manager.sendFromVault([31566704], 'RECEIVER_ADDRESS', { + amount: 500_000n, +}) + +// Omit the amount to send the full balance, closing the vault out of each +// asset listed. An amount cannot be combined with more than one asset. +await manager.sendFromVault([31566704, 312769], 'RECEIVER_ADDRESS') + +// ALGO leaves the vault on its own, and always by amount +await manager.sendFromVault([0], 'RECEIVER_ADDRESS', { amount: 500_000n }) + +// The receiver can also be an NFD name, resolved to its deposit account… +await manager.sendFromVault([31566704], 'friend.algo', { amount: 500_000n }) + +// …or to that NFD's own vault +await manager.sendFromVault([31566704], 'friend.algo', { + amount: 500_000n, + receiverType: 'nfdVault', +}) +``` + +### Verifying NFD Properties + +Verification is a two-step exchange with the NFD API: request a challenge, satisfy it +out of band (a DNS record, a social post), then confirm. + +```typescript +// Step 1 — request a challenge. The signer identifies the NFD owner. +const request = await nfd + .setSigner(activeAddress, transactionSigner) + .verifyRequest('example.algo', 'domain') + +// Step 2 — confirm once the challenge has been satisfied +const result = await nfd.verifyConfirm(request.id, request.challenge) +``` + +Verifiable fields: `blueskydid`, `twitter`, `github`, `domain`, `email`, `avatar`, `banner`. + +### Name Suggestions + +```typescript +const suggestions = await nfd.suggest('patrick', { + buyer: activeAddress, + limit: 10, +}) +``` + ## Client Initialization Options The NFD client can be instantiated in several ways: diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index afa8d60..ff67a13 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -402,6 +402,7 @@ export class NfdClient { return this._metadata.getBannerImage(input) } } + /** * Get name suggestions for NFD registration * @param name - The name (even partial) to search for diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 48dc979..84e4a3d 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -14,6 +14,30 @@ export enum DefaultSender { export const ALGORAND_ZERO_ADDRESS = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ' +/** + * Static fee (in microAlgos) covering an NFD app call and the inner + * transactions it issues + */ +export const APP_CALL_STATIC_FEE = 3000n + +/** Static fee (in microAlgos) for an NFD renewal, which issues more inners */ +export const RENEW_STATIC_FEE = 5000n + +/** + * Additional fee (in microAlgos) per asset in a vault operation, covering the + * inner transaction the contract issues for each one + */ +export const VAULT_FEE_PER_ASSET = 1000n + +/** + * Minimum balance (in microAlgos) the vault needs per asset it opts into + * + * `vaultOptIn` verifies that the transaction immediately before it pays the + * vault exactly this much per asset in the call, and it is charged whether or + * not the vault is already opted into that asset. + */ +export const VAULT_OPT_IN_MBR = 100_000n + /** The base URLs for the NFD API for each network */ export enum NfdApiBaseUrl { MAINNET = 'https://api.nf.domains', diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6bffa95..7718e24 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -25,6 +25,7 @@ export type { export { NfdApiClient } from './api-client' // Export modules +export { LookupModule } from './modules/lookup' export { NfdManager } from './modules/manager' export { PurchasingModule } from './modules/purchasing' @@ -35,7 +36,9 @@ export type { NfdMintParams, } from './modules/minting' +export type { ResolveResult } from './modules/lookup' export type { NfdPurchaseQuote } from './modules/purchasing' +export type { AppBox } from './utils/internal/boxes' // Export NFD utility functions export { diff --git a/packages/sdk/src/modules/base.ts b/packages/sdk/src/modules/base.ts index 3c05490..e99fee5 100644 --- a/packages/sdk/src/modules/base.ts +++ b/packages/sdk/src/modules/base.ts @@ -10,6 +10,7 @@ import { decodeAppIdFromNameBox, getNameBoxName, } from '../utils/internal/registry-box' +import { isValidName } from '../utils/nfd' import type { NfdClient } from '../client' import type { Constraints } from '../contracts/NFDRegistryClient' @@ -140,6 +141,47 @@ export abstract class BaseModule { } } + /** + * Parse a name or app ID input into a bigint app ID + * + * Resolving a name costs one registry box read; a numeric input costs + * nothing. Callers that only need the app ID should use this rather than a + * full `resolve()`, which additionally reads global state and every box. + * + * @param nameOrAppId - The NFD name or application ID to parse + * @returns The NFD's application ID as a bigint + * @throws If the input is an invalid NFD name or the NFD does not exist + */ + protected async parseAppId( + nameOrAppId: string | number | bigint, + ): Promise { + // If it's already a number or bigint, just return it + if (typeof nameOrAppId !== 'string') { + return BigInt(nameOrAppId) + } + + // A wholly numeric string is an app ID. The test has to be the whole + // string: NFD names may be all digits, and `parseInt('123.algo')` reads + // that as app ID 123 — a valid app that is never the NFD asked for. + if (/^\d+$/.test(nameOrAppId)) { + return BigInt(nameOrAppId) + } + + // Validate and lookup NFD name + if (!isValidName(nameOrAppId)) { + throw new Error( + `Invalid NFD name: ${nameOrAppId}. Name must be in the format 'name.algo' or 'segment.name.algo'`, + ) + } + + const appId = await this.getAppIdFromName(nameOrAppId) + if (appId === null) { + throw new Error(`NFD not found: ${nameOrAppId}`) + } + + return appId + } + /** * Get the protocol constraints from the NFD registry * @returns The protocol constraints diff --git a/packages/sdk/src/modules/lookup.ts b/packages/sdk/src/modules/lookup.ts index d90d24b..be8d88e 100644 --- a/packages/sdk/src/modules/lookup.ts +++ b/packages/sdk/src/modules/lookup.ts @@ -1,6 +1,5 @@ import { getAllBoxes } from '../utils/internal/boxes' import { buildNfdRecord } from '../utils/internal/nfd-record' -import { isValidName } from '../utils/nfd' import { BaseModule } from './base' @@ -34,41 +33,6 @@ export interface ResolveResult { * Module for NFD lookup and resolution operations */ export class LookupModule extends BaseModule { - /** - * Parse a name or app ID input into a bigint app ID - * @param nameOrAppId - The NFD name or application ID to parse - * @returns The NFD's application ID as a bigint - * @throws If the input is an invalid NFD name or the NFD does not exist - */ - private async parseAppId( - nameOrAppId: string | number | bigint, - ): Promise { - // If it's already a number or bigint, just return it - if (typeof nameOrAppId !== 'string') { - return BigInt(nameOrAppId) - } - - // Try to parse as a number first - const parsedNumber = parseInt(nameOrAppId) - if (!isNaN(parsedNumber)) { - return BigInt(parsedNumber) - } - - // Validate and lookup NFD name - if (!isValidName(nameOrAppId)) { - throw new Error( - `Invalid NFD name: ${nameOrAppId}. Name must be in the format 'name.algo' or 'segment.name.algo'`, - ) - } - - const appId = await this.getAppIdFromName(nameOrAppId) - if (appId === null) { - throw new Error(`NFD not found: ${nameOrAppId}`) - } - - return appId - } - /** * 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/modules/manager.ts b/packages/sdk/src/modules/manager.ts index 5143d68..fce2237 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -1,9 +1,17 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' -import { Address } from 'algosdk' - -import { ALGORAND_ZERO_ADDRESS } from '../constants' +import { Address, type Transaction } from 'algosdk' + +import { + ALGORAND_ZERO_ADDRESS, + APP_CALL_STATIC_FEE, + RENEW_STATIC_FEE, + VAULT_FEE_PER_ASSET, + VAULT_OPT_IN_MBR, +} from '../constants' import { parseTransactionError } from '../utils/error-parser' import { strToUint8Array, concatUint8Arrays } from '../utils/internal/bytes' +import { toAmount } from '../utils/internal/numbers' +import { isValidName } from '../utils/nfd' import { BaseModule } from './base' import { LookupModule } from './lookup' @@ -68,6 +76,44 @@ export class NfdManager extends BaseModule { this._boxes = null } + /** + * Assert the NFD is neither listed for sale nor expired + * + * The instance contract gates most owner-driven writes behind + * `notForSaleOrExpired()`, so a live listing or a lapsed expiration blocks + * them until the owner cancels the sale or renews. Checking here turns an + * opaque `assert` failure into an error that names the cause and the cure. + * + * @param nfd - The resolved NFD + * @param action - What the caller was trying to do, for the message + * @throws If the NFD is for sale or expired + */ + private assertNotForSaleOrExpired(nfd: Nfd, action: string): void { + if (nfd.expired) { + throw new Error( + `Cannot ${action} because the NFD has expired. Call renew() first.`, + ) + } + if (nfd.sellAmount) { + throw new Error( + `Cannot ${action} while the NFD is listed for sale. Call cancelSale() first.`, + ) + } + } + + /** + * Assert the NFD is not mid-mint + * + * @param nfd - The resolved NFD + * @param action - What the caller was trying to do, for the message + * @throws If the NFD is still minting + */ + private assertNotMinting(nfd: Nfd, action: string): void { + if (nfd.state === 'minting') { + throw new Error(`Cannot ${action} while the NFD is still minting`) + } + } + /** * Split fields and values if any values exceed the byte limit * @param fieldsAndValues - Array of alternating field names and values @@ -123,8 +169,10 @@ export class NfdManager extends BaseModule { const addressToLink = typeof address === 'string' ? Address.fromString(address) : address - // If address to link is not the default signer (owner), add a signer for it - if (signer.addr !== addressToLink) { + // If address to link is not the default signer (owner), add a signer for it. + // Compare the encoded addresses: two Address instances for the same account + // are never reference-equal. + if (signer.addr.toString() !== addressToLink.toString()) { this.algorand.setSigner(addressToLink, signer.signer) } @@ -509,12 +557,32 @@ export class NfdManager extends BaseModule { /** * Renew the NFD - * @param years - Number of years to renew for (1-20, default 1) + * + * The contract derives the new expiration from the amount paid, capped by + * the registry's `maxYearsAllowed`, so the upper bound is read from the + * registry rather than assumed. + * + * @param years - Number of whole years to renew for (default 1) * @returns The updated NFD - * @throws If the renewal fails + * @throws If `years` is not a whole number of at least 1, exceeds the + * registry's maximum, or the renewal fails */ public async renew(years: number = 1): Promise { const signer = this.requireSigner() + + if (!Number.isInteger(years) || years < 1) { + throw new Error( + `Renewal years must be a whole number of at least 1, got ${years}`, + ) + } + + const { maxYearsAllowed } = await this.getConstraints() + if (BigInt(years) > maxYearsAllowed) { + throw new Error( + `Renewal years must be at most ${maxYearsAllowed}, got ${years}`, + ) + } + const nfd = await this.getNfd() if (!nfd.appID) { @@ -539,7 +607,7 @@ export class NfdManager extends BaseModule { .newGroup() .renew({ args: { payment: paymentTxn }, - staticFee: AlgoAmount.MicroAlgos(5000), + staticFee: AlgoAmount.MicroAlgos(RENEW_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { @@ -553,22 +621,45 @@ export class NfdManager extends BaseModule { /** * List the NFD for sale on the marketplace + * + * The contract refuses to sell an NFD that still has properties, so every + * user-defined and verified field has to be cleared first. Calling this on + * an NFD already listed re-prices it. + * * @param price - The sale price in microAlgos * @param options - Optional sale configuration * @returns The updated NFD - * @throws If the listing fails + * @throws If the NFD is expired, still minting, or still has properties, or + * if the listing fails */ public async listForSale( price: bigint | number, options: ListForSaleOptions = {}, ): Promise { const signer = this.requireSigner() + const sellAmount = toAmount(price, 'Sale price') const nfd = await this.getNfd() if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can list this NFD for sale') } + if (nfd.expired) { + throw new Error( + 'Cannot list an expired NFD for sale. Call renew() first.', + ) + } + this.assertNotMinting(nfd, 'list this NFD for sale') + + // offerForSale asserts the NFD has no boxes left. getNfd() already read + // them, so the count is free here. + const boxCount = this._boxes?.length ?? 0 + if (boxCount > 0) { + throw new Error( + `An NFD can only be sold once its properties are cleared, but ${boxCount} remain. Clear the user-defined and verified fields first.`, + ) + } + if (!nfd.appID) { throw new Error('NFD has no application ID') } @@ -582,10 +673,10 @@ export class NfdManager extends BaseModule { .newGroup() .offerForSale({ args: { - sellAmount: BigInt(price), + sellAmount, reservedFor, }, - staticFee: AlgoAmount.MicroAlgos(3000), + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { @@ -602,7 +693,8 @@ export class NfdManager extends BaseModule { /** * Cancel the sale listing for the NFD * @returns The updated NFD - * @throws If the cancellation fails + * @throws If the NFD is not listed for sale, is expired or still minting, or + * the cancellation fails */ public async cancelSale(): Promise { const signer = this.requireSigner() @@ -612,6 +704,17 @@ export class NfdManager extends BaseModule { throw new Error('Only the owner can cancel the sale of this NFD') } + if (!nfd.sellAmount) { + throw new Error('NFD is not listed for sale') + } + + if (nfd.expired) { + throw new Error( + 'Cannot cancel the sale of an expired NFD. Call renew() first.', + ) + } + this.assertNotMinting(nfd, 'cancel the sale of this NFD') + if (!nfd.appID) { throw new Error('NFD has no application ID') } @@ -623,7 +726,7 @@ export class NfdManager extends BaseModule { .newGroup() .cancelSale({ args: {}, - staticFee: AlgoAmount.MicroAlgos(3000), + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { @@ -637,19 +740,37 @@ export class NfdManager extends BaseModule { /** * Lock or unlock segment minting for the NFD + * + * Unlocking sets the price anyone may mint a segment at, and the contract + * requires it to be at least the registry's `segmentPlatformCostInUsd`, so + * the default of 0 is only valid when locking. + * * @param lock - Whether to lock (true) or unlock (false) segment minting * @param usdPrice - The price in USD cents for minting segments (e.g., 300 = $3.00). Set to 0 if locking. * @returns The updated NFD - * @throws If the operation fails + * @throws If unlocking below the registry minimum, if the NFD is for sale or + * expired, or if the operation fails */ public async lockSegment(lock: boolean, usdPrice: number = 0): Promise { const signer = this.requireSigner() + const segmentPrice = toAmount(usdPrice, 'Segment price') const nfd = await this.getNfd() if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can lock/unlock segments for this NFD') } + this.assertNotForSaleOrExpired(nfd, 'lock/unlock segments for this NFD') + + if (!lock) { + const { segmentPlatformCostInUsd } = await this.getConstraints() + if (segmentPrice < segmentPlatformCostInUsd) { + throw new Error( + `Segment price must be at least ${segmentPlatformCostInUsd} USD cents when unlocking segment minting, got ${segmentPrice}`, + ) + } + } + if (!nfd.appID) { throw new Error('NFD has no application ID') } @@ -662,9 +783,9 @@ export class NfdManager extends BaseModule { .segmentLock({ args: { lock, - usdPrice: BigInt(usdPrice), + usdPrice: segmentPrice, }, - staticFee: AlgoAmount.MicroAlgos(3000), + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { @@ -684,7 +805,7 @@ export class NfdManager extends BaseModule { * When locked, only the owner can opt the vault into assets. * When unlocked, anyone can opt the vault into assets. * @returns The updated NFD - * @throws If the operation fails + * @throws If the NFD is for sale or expired, or the operation fails */ public async lockVault(lock: boolean): Promise { const signer = this.requireSigner() @@ -694,6 +815,8 @@ export class NfdManager extends BaseModule { throw new Error('Only the owner can lock/unlock the vault for this NFD') } + this.assertNotForSaleOrExpired(nfd, 'lock/unlock the vault for this NFD') + if (!nfd.appID) { throw new Error('NFD has no application ID') } @@ -705,7 +828,7 @@ export class NfdManager extends BaseModule { .newGroup() .vaultOptInLock({ args: { lock }, - staticFee: AlgoAmount.MicroAlgos(3000), + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { @@ -720,69 +843,126 @@ export class NfdManager extends BaseModule { } /** - * Opt the NFD vault into assets and optionally transfer them - * @param assets - Array of ASA IDs to opt into (use 0 for ALGO) + * Opt the NFD vault into assets, and optionally transfer one of them + * + * `options.amount` sends that many base units of the asset to the vault in + * the same group. Since the amount applies to one asset, it can only be + * given alongside a single asset — call this once per asset to send several. + * + * The vault's minimum balance rises by {@link VAULT_OPT_IN_MBR} per asset, + * and the contract requires the caller to fund it in the same group. That + * payment is charged for every asset passed, whether or not the vault is + * already opted into it, so filter out assets the vault already holds. + * + * @param assets - ASA IDs to opt the vault into. `0` (ALGO) needs no opt-in + * and is only meaningful together with `amount`. * @param options - Options for the vault operation * @returns The updated NFD - * @throws If the operation fails + * @throws If `assets` is empty, if `amount` is given with more than one + * asset, if the NFD is for sale or expired, or if the operation fails */ public async sendToVault( assets: number[], options: SendToVaultOptions = {}, ): Promise { const signer = this.requireSigner() + + if (assets.length === 0) { + throw new Error('At least one asset must be specified') + } + + const sendsAsset = !options.optInOnly && options.amount !== undefined + if (sendsAsset && assets.length > 1) { + throw new Error( + 'An amount can only be sent with a single asset. Call sendToVault once per asset, or omit amount to opt the vault in without transferring.', + ) + } + + const amount = + options.optInOnly || options.amount === undefined + ? 0n + : toAmount(options.amount, 'Transfer amount') + + // ALGO needs no opt-in, so it never goes into the vaultOptIn call + const assetsToOptIn = assets.filter((assetId) => assetId !== 0) + + if (assetsToOptIn.length === 0 && !sendsAsset) { + throw new Error( + 'Nothing to do: ALGO (asset 0) needs no opt-in, so sending it requires an amount.', + ) + } + const nfd = await this.getNfd() + this.assertNotForSaleOrExpired(nfd, 'send to the vault') + if (!nfd.appID) { throw new Error('NFD has no application ID') } + + // Both the MBR payment and the transfer are paid to the vault account + const vaultAddress = nfd.nfdAccount + if (!vaultAddress) { + throw new Error('NFD has no vault account') + } + const nfdAppId = BigInt(nfd.appID) const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) - // Calculate fees based on number of assets - const feePerAsset = 1000n - const baseFee = 3000n - const totalFee = baseFee + feePerAsset * BigInt(assets.length) - - try { - const group = nfdInstanceClient.newGroup() + // Build the payments up front so a failure to construct one is not + // reported as though the transaction itself had failed - // Opt the vault into the assets - group.vaultOptIn({ - args: { assets: assets.map(BigInt) }, - staticFee: AlgoAmount.MicroAlgos(totalFee), + // vaultOptIn verifies the transaction immediately before it pays the + // vault's added minimum balance, and rejects being first in the group + let mbrTxn: Transaction | undefined + if (assetsToOptIn.length > 0) { + mbrTxn = await this.algorand.createTransaction.payment({ + sender: signer.addr, + receiver: vaultAddress, + amount: AlgoAmount.MicroAlgos( + VAULT_OPT_IN_MBR * BigInt(assetsToOptIn.length), + ), }) + } - // If not opt-in only, add asset transfer transactions - if (!options.optInOnly && assets.length > 0) { - const vaultAddress = nfd.nfdAccount - if (!vaultAddress) { - throw new Error('NFD has no vault account') - } - - for (const assetId of assets) { - if (assetId === 0) { - // ALGO transfer - const paymentTxn = await this.algorand.createTransaction.payment({ + let transferTxn: Transaction | undefined + if (sendsAsset) { + const assetId = assets[0] + transferTxn = + assetId === 0 + ? await this.algorand.createTransaction.payment({ + sender: signer.addr, + receiver: vaultAddress, + amount: AlgoAmount.MicroAlgos(amount), + note: options.note, + }) + : await this.algorand.createTransaction.assetTransfer({ sender: signer.addr, receiver: vaultAddress, - amount: AlgoAmount.MicroAlgos(options.amount ?? 0n), + assetId: BigInt(assetId), + amount, note: options.note, }) - group.addTransaction(paymentTxn) - } else { - // ASA transfer - const assetTxn = - await this.algorand.createTransaction.assetTransfer({ - sender: signer.addr, - receiver: vaultAddress, - assetId: BigInt(assetId), - amount: options.amount ?? 0n, - note: options.note, - }) - group.addTransaction(assetTxn) - } - } + } + + // One inner transaction per asset the contract opts into + const totalFee = + APP_CALL_STATIC_FEE + VAULT_FEE_PER_ASSET * BigInt(assetsToOptIn.length) + + try { + const group = nfdInstanceClient.newGroup() + + // Order matters: the MBR payment has to sit directly before the opt-in + if (mbrTxn) { + group.addTransaction(mbrTxn) + group.vaultOptIn({ + args: { assets: assetsToOptIn.map(BigInt) }, + staticFee: AlgoAmount.MicroAlgos(totalFee), + }) + } + + if (transferTxn) { + group.addTransaction(transferTxn) } await group.send({ populateAppCallResources: true }) @@ -797,13 +977,76 @@ export class NfdManager extends BaseModule { return this.getNfd() } + /** + * Resolve a vault receiver to an Algorand address + * + * `vaultSend`'s receiver argument is an ABI `address`, so an NFD name has to + * be resolved to one first. `receiverType` picks which of the receiving + * NFD's accounts to send to. + * + * @param receiver - An Algorand address, or an NFD name to resolve + * @param receiverType - Which account of a receiving NFD to send to: + * its deposit account (`'account'`) or its vault (`'nfdVault'`) + * @returns The receiving Algorand address + * @throws If the receiver is neither a valid address nor a resolvable NFD + * name, or `'nfdVault'` is used with a plain address + */ + private async resolveVaultReceiver( + receiver: string, + receiverType: 'account' | 'nfdVault' = 'account', + ): Promise { + if (!isValidName(receiver)) { + if (receiverType === 'nfdVault') { + throw new Error( + `receiverType 'nfdVault' needs an NFD name as the receiver, got the address ${receiver}`, + ) + } + + // Reject a malformed address here rather than letting ABI encoding fail + // inside the send, where it reads as a transaction failure + try { + Address.fromString(receiver) + } catch { + throw new Error( + `Receiver must be an Algorand address or an NFD name, got ${receiver}`, + ) + } + + return receiver + } + + const receiverNfd = await this.client.resolve(receiver, { view: 'tiny' }) + + if (receiverType === 'nfdVault') { + if (!receiverNfd.nfdAccount) { + throw new Error(`NFD ${receiver} has no vault account`) + } + return receiverNfd.nfdAccount + } + + const depositAccount = receiverNfd.depositAccount ?? receiverNfd.owner + if (!depositAccount) { + throw new Error(`NFD ${receiver} has no deposit account`) + } + return depositAccount + } + /** * Send assets from the NFD vault to a receiver - * @param assets - Array of ASA IDs to send from the vault - * @param receiver - The receiving Algorand address or NFD name + * + * `options.amount` applies to a single asset. Passing several assets means + * "send the full balance of each", which the contract only accepts with no + * amount — it closes the vault out of every asset in the list. + * + * @param assets - ASA IDs to send from the vault, or `[0]` to send ALGO + * @param receiver - The receiving Algorand address, or an NFD name to + * resolve to one * @param options - Options for the vault operation * @returns The updated NFD - * @throws If the operation fails + * @throws If `assets` is empty, if `amount` is given with more than one + * asset, if ALGO is combined with other assets or sent without an amount, + * if the NFD is for sale or expired, if the receiver cannot be resolved, + * or if the operation fails */ public async sendFromVault( assets: number[], @@ -811,17 +1054,66 @@ export class NfdManager extends BaseModule { options: SendFromVaultOptions = {}, ): Promise { const signer = this.requireSigner() + + if (assets.length === 0) { + throw new Error('At least one asset must be specified') + } + + const amount = + options.amount === undefined + ? 0n + : toAmount(options.amount, 'Transfer amount') + + // vaultSend applies the amount to a single asset; with more than one it + // closes out each in full, which the contract requires amount 0 for + if (amount !== 0n && assets.length > 1) { + throw new Error( + 'An amount can only be sent with a single asset. Call sendFromVault once per asset, or omit amount to send the full balance of each.', + ) + } + + // ALGO has no close-out path in the contract: it is sent by explicit + // amount, on its own + if (assets.includes(0)) { + if (assets.length > 1) { + throw new Error( + 'ALGO (asset 0) must be sent from the vault on its own, not alongside other assets', + ) + } + if (amount === 0n) { + throw new Error( + 'Sending ALGO (asset 0) from the vault requires an amount', + ) + } + } + const nfd = await this.getNfd() if (signer.addr.toString() !== nfd.owner) { throw new Error('Only the owner can send from the vault') } + this.assertNotForSaleOrExpired(nfd, 'send from the vault') + if (!nfd.appID) { throw new Error('NFD has no application ID') } - if (assets.length === 0) { - throw new Error('At least one asset must be specified') + + const receiverAddress = await this.resolveVaultReceiver( + receiver, + options.receiverType, + ) + + // The contract claws its own ASA back rather than transferring it, and + // only ever to the owner + if ( + nfd.asaID && + assets.includes(nfd.asaID) && + receiverAddress !== nfd.owner + ) { + throw new Error( + `The NFD's own ASA (${nfd.asaID}) can only be sent from the vault to the owner`, + ) } const nfdAppId = BigInt(nfd.appID) @@ -830,18 +1122,17 @@ export class NfdManager extends BaseModule { const primaryAsset = BigInt(assets[0]) const otherAssets = assets.slice(1).map(BigInt) - // Calculate fees based on number of assets - const feePerAsset = 1000n - const baseFee = 3000n - const totalFee = baseFee + feePerAsset * BigInt(assets.length) + // One inner transaction per asset the contract sends + const totalFee = + APP_CALL_STATIC_FEE + VAULT_FEE_PER_ASSET * BigInt(assets.length) try { await nfdInstanceClient .newGroup() .vaultSend({ args: { - amount: options.amount ?? 0n, - receiver, + amount, + receiver: receiverAddress, note: options.note ?? '', asset: primaryAsset, otherAssets, diff --git a/packages/sdk/src/modules/purchasing.ts b/packages/sdk/src/modules/purchasing.ts index f33036f..0a4a62b 100644 --- a/packages/sdk/src/modules/purchasing.ts +++ b/packages/sdk/src/modules/purchasing.ts @@ -1,8 +1,10 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import { Address } from 'algosdk' +import { APP_CALL_STATIC_FEE } from '../constants' import { Nfd } from '../types' import { parseTransactionError } from '../utils/error-parser' +import { toAmount } from '../utils/internal/numbers' import { BaseModule } from './base' @@ -307,32 +309,28 @@ export class PurchasingModule extends BaseModule { note: string = '', ): Promise { const signer = this.requireSigner() + const offer = toAmount(amount, 'Offer amount') - const nfd = await this.client.resolve(nameOrAppId, { view: 'full' }) - if (!nfd.appID) { - throw new Error(`Cannot determine app ID for NFD: ${nfd.name}`) - } - - const nfdInstanceClient = this.getInstanceClient( - BigInt(nfd.appID), - signer.addr, - ) + // Only the app ID is needed to post the offer, so this takes a single + // registry box read rather than a full resolve + const nfdAppId = await this.parseAppId(nameOrAppId) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) try { await nfdInstanceClient .newGroup() .postOffer({ args: { - offer: BigInt(amount), + offer, note, }, - staticFee: AlgoAmount.MicroAlgos(3000), + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), }) .send({ populateAppCallResources: true }) } catch (error) { throw new Error(`Failed to make offer: ${parseTransactionError(error)}`) } - return this.client.resolve(nameOrAppId, { view: 'full' }) + return this.client.resolve(nfdAppId, { view: 'full' }) } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 7a2c695..dc63472 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -299,8 +299,10 @@ export interface SendToVaultOptions { optInOnly?: boolean /** - * Amount to send in base units of the asset. Required when sending a single asset. - * Ignored when sending multiple assets (all of each asset is sent). + * Amount to send, in base units of the asset. The amount applies to one + * asset, so it can only be given alongside a single asset — call + * `sendToVault` once per asset to send several. Omit it to opt the vault + * into the assets without transferring anything. */ amount?: bigint @@ -315,8 +317,11 @@ export interface SendToVaultOptions { */ export interface SendFromVaultOptions { /** - * Amount to send in base units of the asset. Required when sending a single asset. - * Ignored when sending multiple assets (all of each asset is sent). + * Amount to send, in base units of the asset. The amount applies to one + * asset, so it can only be given alongside a single asset — passing several + * assets sends the full balance of each and closes the vault out of them. + * Required when sending ALGO (asset 0), which has no close-out path. + * @default 0n */ amount?: bigint @@ -326,7 +331,9 @@ export interface SendFromVaultOptions { note?: string /** - * Type of receiver + * Which account to send to when `receiver` is an NFD name: the NFD's + * deposit account, or its vault. Ignored when `receiver` is already an + * Algorand address, and an error to combine `'nfdVault'` with one. * @default 'account' */ receiverType?: 'account' | 'nfdVault' diff --git a/packages/sdk/src/utils/internal/numbers.ts b/packages/sdk/src/utils/internal/numbers.ts new file mode 100644 index 0000000..72078ea --- /dev/null +++ b/packages/sdk/src/utils/internal/numbers.ts @@ -0,0 +1,35 @@ +/** + * Coerce an amount to a bigint, rejecting values the contract cannot accept + * + * `BigInt(1.5)` throws a bare `RangeError: The number 1.5 cannot be converted + * to a BigInt because it is not an integer`, which says nothing about which + * argument was wrong. Every public method taking a `bigint | number` amount + * goes through here so the caller gets the parameter name instead. + * + * @param value - The value to coerce + * @param label - The parameter name to use in error messages + * @returns The value as a bigint + * @throws If the value is not a non-negative, finite whole number + */ +export function toAmount(value: bigint | number, label: string): bigint { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`${label} must be a finite number, got ${value}`) + } + if (!Number.isInteger(value)) { + throw new Error(`${label} must be a whole number, got ${value}`) + } + if (!Number.isSafeInteger(value)) { + throw new Error( + `${label} exceeds the safe integer range and would lose precision, got ${value}. Pass a bigint instead.`, + ) + } + } + + const amount = BigInt(value) + if (amount < 0n) { + throw new Error(`${label} must not be negative, got ${amount}`) + } + + return amount +} diff --git a/packages/sdk/tests/modules/lookup.test.ts b/packages/sdk/tests/modules/lookup.test.ts index 506cf35..42542ec 100644 --- a/packages/sdk/tests/modules/lookup.test.ts +++ b/packages/sdk/tests/modules/lookup.test.ts @@ -180,6 +180,17 @@ describe('LookupModule', () => { expect(nfd.appID).toBe(123) expect(registryFake.appClient.getBoxValue).not.toHaveBeenCalled() }) + + it('treats an all-digit NFD name as a name, not an app ID', async () => { + // parseInt('123.algo') is 123 — a real app, but never this NFD + const { lookup, registryFake } = setup() + registryFake.appClient.getBoxValue.mockResolvedValue(nameBox) + + const nfd = await lookup.resolve('123.algo') + + expect(registryFake.appClient.getBoxValue).toHaveBeenCalledTimes(1) + expect(nfd.appID).toBe(Number(NFD_APP_ID)) + }) }) describe('error handling', () => { diff --git a/packages/sdk/tests/modules/manager.test.ts b/packages/sdk/tests/modules/manager.test.ts index 1be92ed..710c4a5 100644 --- a/packages/sdk/tests/modules/manager.test.ts +++ b/packages/sdk/tests/modules/manager.test.ts @@ -23,6 +23,23 @@ const mockOwnedNfd: Nfd = { nfdAccount: VAULT_ADDRESS, } +const mockForSaleNfd: Nfd = { + ...mockOwnedNfd, + state: 'forSale', + sellAmount: 10000000, +} + +// What the registry reports, mirroring the shape of the Constraints struct +const mockConstraints = { + segmentPlatformCostInUsd: 200n, + segmentPlatformCostInAlgo: 1000000n, + maxYearsAllowed: 20n, + treasuryAddress: OTHER_ADDRESS, + expiredAuctionDuration: 86400n, + expiredStartingPrice: 10000000n, + maxMintCarryCost: 0n, +} + // Mock types interface MockSigner { addr: { toString: () => string } @@ -174,6 +191,12 @@ describe('NfdManager', () => { vi.spyOn(manager as any, 'getInstanceClient').mockReturnValue( mockInstanceClient, ) + + // Registry constraints bound the renewal term and the segment price + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'getConstraints').mockResolvedValue( + mockConstraints, + ) }) describe('getRenewalPrice', () => { @@ -263,6 +286,37 @@ describe('NfdManager', () => { await expect(manager.renew()).rejects.toThrow('NFD has no application ID') }) + + it.each([0, 1.5, -1])( + 'should reject %s years before paying anything', + async (years) => { + await expect(manager.renew(years)).rejects.toThrow( + 'Renewal years must be a whole number of at least 1', + ) + + expect(mockAlgorand.createTransaction.payment).not.toHaveBeenCalled() + }, + ) + + it('should reject more years than the registry allows', async () => { + await expect(manager.renew(21)).rejects.toThrow( + 'Renewal years must be at most 20', + ) + + expect(mockAlgorand.createTransaction.payment).not.toHaveBeenCalled() + }) + + it('should take the maximum from the registry, not a fixed 20', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'getConstraints').mockResolvedValue({ + ...mockConstraints, + maxYearsAllowed: 5n, + }) + + await expect(manager.renew(6)).rejects.toThrow( + 'Renewal years must be at most 5', + ) + }) }) describe('listForSale', () => { @@ -312,10 +366,73 @@ describe('NfdManager', () => { 'Only the owner can list this NFD for sale', ) }) + + it('should refuse to list an NFD that still has properties', async () => { + // offerForSale asserts the NFD has no boxes left + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockOwnedNfd, + boxes: [{ name: 'u.url', value: new Uint8Array([1]) }], + }) + + await expect(manager.listForSale(10000000n)).rejects.toThrow( + 'An NFD can only be sold once its properties are cleared, but 1 remain', + ) + + expect(mockInstanceClient.newGroup().offerForSale).not.toHaveBeenCalled() + }) + + it('should refuse to list an expired NFD', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, expired: true }, + boxes: [], + }) + + await expect(manager.listForSale(10000000n)).rejects.toThrow( + 'Cannot list an expired NFD for sale', + ) + }) + + it('should refuse to list an NFD that is still minting', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, state: 'minting' }, + boxes: [], + }) + + await expect(manager.listForSale(10000000n)).rejects.toThrow( + 'Cannot list this NFD for sale while the NFD is still minting', + ) + }) + }) + + describe('price validation', () => { + it('should reject a fractional sale price', async () => { + await expect(manager.listForSale(1.5)).rejects.toThrow( + 'Sale price must be a whole number, got 1.5', + ) + + expect(mockInstanceClient.newGroup().offerForSale).not.toHaveBeenCalled() + }) + + it('should reject a negative sale price', async () => { + await expect(manager.listForSale(-1)).rejects.toThrow( + 'Sale price must not be negative, got -1', + ) + }) + + it('should reject a fractional segment price', async () => { + await expect(manager.lockSegment(false, 3.5)).rejects.toThrow( + 'Segment price must be a whole number, got 3.5', + ) + }) }) describe('cancelSale', () => { it('should cancel the sale listing', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockForSaleNfd, + boxes: [], + }) + const result = await manager.cancelSale() expect(mockInstanceClient.newGroup().cancelSale).toHaveBeenCalledWith({ @@ -325,7 +442,37 @@ describe('NfdManager', () => { }), }) - expect(result).toEqual(mockOwnedNfd) + expect(result).toEqual(mockForSaleNfd) + }) + + it('should throw if the NFD is not listed for sale', async () => { + await expect(manager.cancelSale()).rejects.toThrow( + 'NFD is not listed for sale', + ) + + expect(mockInstanceClient.newGroup().cancelSale).not.toHaveBeenCalled() + }) + + it('should throw if the listed NFD has expired', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockForSaleNfd, expired: true }, + boxes: [], + }) + + await expect(manager.cancelSale()).rejects.toThrow( + 'Cannot cancel the sale of an expired NFD', + ) + }) + + it('should throw if the NFD is still minting', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockForSaleNfd, state: 'minting' }, + boxes: [], + }) + + await expect(manager.cancelSale()).rejects.toThrow( + 'Cannot cancel the sale of this NFD while the NFD is still minting', + ) }) it('should throw if not the owner', async () => { @@ -362,12 +509,12 @@ describe('NfdManager', () => { }) it('should unlock segment minting', async () => { - await manager.lockSegment(false) + await manager.lockSegment(false, 300) expect(mockInstanceClient.newGroup().segmentLock).toHaveBeenCalledWith({ args: { lock: false, - usdPrice: 0n, + usdPrice: 300n, }, staticFee: expect.objectContaining({ amountInMicroAlgo: 3000n, @@ -375,6 +522,34 @@ describe('NfdManager', () => { }) }) + it('should reject an unlock price below the registry minimum', async () => { + // The default price of 0 is only valid when locking + await expect(manager.lockSegment(false)).rejects.toThrow( + 'Segment price must be at least 200 USD cents when unlocking segment minting, got 0', + ) + + expect(mockInstanceClient.newGroup().segmentLock).not.toHaveBeenCalled() + }) + + it('should not require a price when locking', async () => { + await manager.lockSegment(true) + + expect(mockInstanceClient.newGroup().segmentLock).toHaveBeenCalledWith( + expect.objectContaining({ args: { lock: true, usdPrice: 0n } }), + ) + }) + + it('should throw if the NFD is listed for sale', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockForSaleNfd, + boxes: [], + }) + + await expect(manager.lockSegment(true)).rejects.toThrow( + 'Cannot lock/unlock segments for this NFD while the NFD is listed for sale', + ) + }) + it('should throw if not the owner', async () => { const otherSigner: MockSigner = { addr: { toString: () => OTHER_ADDRESS }, @@ -436,6 +611,21 @@ describe('NfdManager', () => { 'Only the owner can lock/unlock the vault for this NFD', ) }) + + it('should throw if the NFD is listed for sale', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockForSaleNfd, + boxes: [], + }) + + await expect(manager.lockVault(true)).rejects.toThrow( + 'Cannot lock/unlock the vault for this NFD while the NFD is listed for sale', + ) + + expect( + mockInstanceClient.newGroup().vaultOptInLock, + ).not.toHaveBeenCalled() + }) }) describe('sendToVault', () => { @@ -449,17 +639,39 @@ describe('NfdManager', () => { }), }) - // Should not create transfer transactions - expect(mockAlgorand.createTransaction.payment).not.toHaveBeenCalled() + // The MBR payment is the only payment: 0.1 ALGO per asset, no transfer + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledTimes(1) + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ + sender: expect.anything(), + receiver: VAULT_ADDRESS, + amount: expect.objectContaining({ amountInMicroAlgo: 200000n }), + }) expect( mockAlgorand.createTransaction.assetTransfer, ).not.toHaveBeenCalled() }) - it('should opt in and transfer ALGO (asset 0)', async () => { + it('should place the MBR payment directly before the opt-in', async () => { + // vaultOptIn asserts it is not first in the group and that the + // transaction immediately before it pays the vault's MBR + const group = mockInstanceClient.newGroup() + await manager.sendToVault([42], { amount: 500n }) + + const [mbrCall, transferCall] = + group.addTransaction.mock.invocationCallOrder + const [optInCall] = group.vaultOptIn.mock.invocationCallOrder + + expect(mbrCall).toBeLessThan(optInCall) + expect(optInCall).toBeLessThan(transferCall) + }) + + it('should transfer ALGO (asset 0) without a vault opt-in', async () => { await manager.sendToVault([0], { amount: 1000000n }) - expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalled() + // ALGO needs no opt-in, so the contract is never asked to make one and + // no MBR is owed — the only payment is the transfer itself + expect(mockInstanceClient.newGroup().vaultOptIn).not.toHaveBeenCalled() + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledTimes(1) expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith({ sender: expect.anything(), receiver: VAULT_ADDRESS, @@ -470,6 +682,52 @@ describe('NfdManager', () => { }) }) + it('should exclude ALGO from the opt-in list, its fee and its MBR', async () => { + await manager.sendToVault([0, 42], { optInOnly: true }) + + expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalledWith({ + args: { assets: [42n] }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 4000n, // 3000 + 1000 * 1, not 1000 * 2 + }), + }) + + expect(mockAlgorand.createTransaction.payment).toHaveBeenCalledWith( + expect.objectContaining({ + amount: expect.objectContaining({ amountInMicroAlgo: 100000n }), + }), + ) + }) + + it('should reject an amount sent with more than one asset', async () => { + await expect( + manager.sendToVault([100, 200], { amount: 500n }), + ).rejects.toThrow('An amount can only be sent with a single asset') + + expect(mockInstanceClient.newGroup().vaultOptIn).not.toHaveBeenCalled() + }) + + it('should not emit a zero-amount transfer when no amount is given', async () => { + await manager.sendToVault([42]) + + expect(mockInstanceClient.newGroup().vaultOptIn).toHaveBeenCalled() + expect( + mockAlgorand.createTransaction.assetTransfer, + ).not.toHaveBeenCalled() + }) + + it('should throw if no assets are specified', async () => { + await expect(manager.sendToVault([])).rejects.toThrow( + 'At least one asset must be specified', + ) + }) + + it('should throw when ALGO is given with nothing to send', async () => { + await expect(manager.sendToVault([0])).rejects.toThrow( + 'ALGO (asset 0) needs no opt-in, so sending it requires an amount', + ) + }) + it('should opt in and transfer ASA', async () => { await manager.sendToVault([42], { amount: 500n, note: 'test' }) @@ -485,6 +743,33 @@ describe('NfdManager', () => { ) }) + it('should throw if the NFD is listed for sale', async () => { + // vaultOptIn is gated on notForSaleOrExpired() + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockForSaleNfd, + boxes: [], + }) + + await expect( + manager.sendToVault([42], { optInOnly: true }), + ).rejects.toThrow( + 'Cannot send to the vault while the NFD is listed for sale', + ) + + expect(mockAlgorand.createTransaction.payment).not.toHaveBeenCalled() + }) + + it('should throw if the NFD has expired', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, expired: true }, + boxes: [], + }) + + await expect( + manager.sendToVault([42], { optInOnly: true }), + ).rejects.toThrow('Cannot send to the vault because the NFD has expired') + }) + it('should throw if NFD has no vault account', async () => { vi.spyOn( LookupModule.prototype, @@ -560,6 +845,78 @@ describe('NfdManager', () => { ) }) + it('should reject an amount sent with more than one asset', async () => { + // vaultSend asserts otherAssets is empty whenever amount is non-zero + await expect( + manager.sendFromVault([100, 200], OTHER_ADDRESS, { amount: 500n }), + ).rejects.toThrow('An amount can only be sent with a single asset') + + expect(mockInstanceClient.newGroup().vaultSend).not.toHaveBeenCalled() + }) + + it('should reject ALGO alongside other assets', async () => { + await expect( + manager.sendFromVault([0, 100], OTHER_ADDRESS), + ).rejects.toThrow('ALGO (asset 0) must be sent from the vault on its own') + }) + + it('should reject ALGO without an amount', async () => { + // The contract has no close-out path for ALGO, so it asserts amount > 0 + await expect(manager.sendFromVault([0], OTHER_ADDRESS)).rejects.toThrow( + 'Sending ALGO (asset 0) from the vault requires an amount', + ) + }) + + it("should reject sending the NFD's own ASA to anyone but the owner", async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, asaID: 777 }, + boxes: [], + }) + + await expect(manager.sendFromVault([777], OTHER_ADDRESS)).rejects.toThrow( + "The NFD's own ASA (777) can only be sent", + ) + + expect(mockInstanceClient.newGroup().vaultSend).not.toHaveBeenCalled() + }) + + it("should allow the NFD's own ASA to go to the owner", async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, asaID: 777 }, + boxes: [], + }) + + await manager.sendFromVault([777], OWNER_ADDRESS) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalled() + }) + + it('should throw if the NFD is listed for sale', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: mockForSaleNfd, + boxes: [], + }) + + await expect( + manager.sendFromVault([100], OTHER_ADDRESS, { amount: 1n }), + ).rejects.toThrow( + 'Cannot send from the vault while the NFD is listed for sale', + ) + }) + + it('should throw if the NFD has expired', async () => { + vi.spyOn(LookupModule.prototype, 'resolveWithBoxes').mockResolvedValue({ + nfd: { ...mockOwnedNfd, expired: true }, + boxes: [], + }) + + await expect( + manager.sendFromVault([100], OTHER_ADDRESS, { amount: 1n }), + ).rejects.toThrow( + 'Cannot send from the vault because the NFD has expired', + ) + }) + it('should include note when provided', async () => { await manager.sendFromVault([100], OTHER_ADDRESS, { amount: 100n, @@ -574,6 +931,93 @@ describe('NfdManager', () => { }), ) }) + + describe('receiver resolution', () => { + // vaultSend's receiver argument is an ABI address, so a name has to be + // resolved to one before the call + const receiverNfd: Nfd = { + name: 'receiver.algo', + appID: 999, + state: 'owned', + owner: OTHER_ADDRESS, + depositAccount: OTHER_ADDRESS, + nfdAccount: VAULT_ADDRESS, + } + + it('passes a plain address straight through without resolving', async () => { + const resolve = vi.spyOn(client, 'resolve') + + await manager.sendFromVault([100], OTHER_ADDRESS) + + expect(resolve).not.toHaveBeenCalled() + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ receiver: OTHER_ADDRESS }), + }), + ) + }) + + it('resolves an NFD name to its deposit account by default', async () => { + vi.spyOn(client, 'resolve').mockResolvedValue(receiverNfd) + + await manager.sendFromVault([100], 'receiver.algo') + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ receiver: OTHER_ADDRESS }), + }), + ) + }) + + it("resolves an NFD name to its vault for receiverType 'nfdVault'", async () => { + vi.spyOn(client, 'resolve').mockResolvedValue(receiverNfd) + + await manager.sendFromVault([100], 'receiver.algo', { + receiverType: 'nfdVault', + }) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ receiver: VAULT_ADDRESS }), + }), + ) + }) + + it('rejects a receiver that is neither an address nor an NFD name', async () => { + await expect( + manager.sendFromVault([100], 'not-an-address'), + ).rejects.toThrow( + 'Receiver must be an Algorand address or an NFD name, got not-an-address', + ) + + expect(mockInstanceClient.newGroup().vaultSend).not.toHaveBeenCalled() + }) + + it("rejects receiverType 'nfdVault' given a plain address", async () => { + await expect( + manager.sendFromVault([100], OTHER_ADDRESS, { + receiverType: 'nfdVault', + }), + ).rejects.toThrow("receiverType 'nfdVault' needs an NFD name") + + expect(mockInstanceClient.newGroup().vaultSend).not.toHaveBeenCalled() + }) + + it('falls back to the owner when the NFD has no deposit account', async () => { + vi.spyOn(client, 'resolve').mockResolvedValue({ + ...receiverNfd, + depositAccount: undefined, + }) + + await manager.sendFromVault([100], 'receiver.algo') + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ receiver: OTHER_ADDRESS }), + }), + ) + }) + }) }) describe('linkAddress', () => { diff --git a/packages/sdk/tests/modules/purchasing.test.ts b/packages/sdk/tests/modules/purchasing.test.ts index a1b87a4..e63083a 100644 --- a/packages/sdk/tests/modules/purchasing.test.ts +++ b/packages/sdk/tests/modules/purchasing.test.ts @@ -148,23 +148,45 @@ describe('PurchasingModule', () => { client.setSigner(BUYER_ADDRESS, mockSigner.signer) purchasing = new PurchasingModule(client) - // Mock client methods + // Mock client methods. App IDs may arrive as a number or a bigint, so + // match on the numeric value rather than the exact type. vi.spyOn(client, 'resolve').mockImplementation(async (nameOrAppId) => { - if (nameOrAppId === 'reserved.algo' || nameOrAppId === 123) { + const key = + typeof nameOrAppId === 'string' ? nameOrAppId : Number(nameOrAppId) + + if (key === 'reserved.algo' || key === 123) { return mockReservedNfd } - if (nameOrAppId === 'forsale.algo' || nameOrAppId === 456) { + if (key === 'forsale.algo' || key === 456) { return mockForSaleNfd } - if (nameOrAppId === 'forsale-reserved.algo' || nameOrAppId === 789) { + if (key === 'forsale-reserved.algo' || key === 789) { return mockForSaleReservedNfd } - if (nameOrAppId === 'owned.algo' || nameOrAppId === 321) { + if (key === 'owned.algo' || key === 321) { return mockOwnedNfd } throw new Error('NFD not found') }) + // Name → app ID, which is a single registry box read in production + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(purchasing as any, 'parseAppId').mockImplementation( + async (nameOrAppId: unknown) => { + const appIds: Record = { + 'reserved.algo': 123n, + 'forsale.algo': 456n, + 'forsale-reserved.algo': 789n, + 'owned.algo': 321n, + } + const appId = appIds[String(nameOrAppId)] + if (appId === undefined) { + throw new Error(`NFD not found: ${String(nameOrAppId)}`) + } + return appId + }, + ) + // Mock getInstanceClient method // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(purchasing as any, 'getInstanceClient').mockReturnValue( @@ -439,7 +461,11 @@ describe('PurchasingModule', () => { describe('makeOffer', () => { it('should successfully make an offer on an NFD', async () => { - const result = await purchasing.makeOffer('forsale.algo', 5000000n, 'I want this NFD') + const result = await purchasing.makeOffer( + 'forsale.algo', + 5000000n, + 'I want this NFD', + ) expect(mockInstanceClient.newGroup).toHaveBeenCalled() expect(mockInstanceClient.newGroup().postOffer).toHaveBeenCalledWith({ @@ -457,8 +483,10 @@ describe('PurchasingModule', () => { populateAppCallResources: true, }) - // Should re-resolve the NFD after the offer - expect(client.resolve).toHaveBeenCalledTimes(2) + // Posting the offer only needs the app ID, so the NFD is resolved once — + // afterwards, to return the updated record + expect(client.resolve).toHaveBeenCalledTimes(1) + expect(client.resolve).toHaveBeenCalledWith(456n, { view: 'full' }) expect(result).toEqual(mockForSaleNfd) }) @@ -476,17 +504,22 @@ describe('PurchasingModule', () => { }) }) - it('should throw error if NFD has no appID', async () => { - const noAppIdNfd: Nfd = { - name: 'noapp.algo', - state: 'forSale', - owner: SELLER_ADDRESS, - } - vi.spyOn(client, 'resolve').mockResolvedValueOnce(noAppIdNfd) + it('should reject a fractional amount', async () => { + await expect(purchasing.makeOffer('forsale.algo', 1.5)).rejects.toThrow( + 'Offer amount must be a whole number, got 1.5', + ) + }) + + it('should reject a negative amount', async () => { + await expect(purchasing.makeOffer('forsale.algo', -1)).rejects.toThrow( + 'Offer amount must not be negative, got -1', + ) + }) + it('should throw if the NFD cannot be found', async () => { await expect( purchasing.makeOffer('noapp.algo', 5000000n), - ).rejects.toThrow('Cannot determine app ID for NFD: noapp.algo') + ).rejects.toThrow('NFD not found: noapp.algo') }) it('should wrap transaction errors', async () => { diff --git a/packages/sdk/tests/utils/numbers.test.ts b/packages/sdk/tests/utils/numbers.test.ts new file mode 100644 index 0000000..f58c5e6 --- /dev/null +++ b/packages/sdk/tests/utils/numbers.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' + +import { toAmount } from '../../src/utils/internal/numbers' + +describe('toAmount', () => { + it('passes bigints through unchanged', () => { + expect(toAmount(500n, 'Amount')).toBe(500n) + expect(toAmount(0n, 'Amount')).toBe(0n) + }) + + it('widens whole numbers to bigint', () => { + expect(toAmount(500, 'Amount')).toBe(500n) + expect(toAmount(0, 'Amount')).toBe(0n) + }) + + it.each([NaN, Infinity, -Infinity])('rejects %s', (value) => { + expect(() => toAmount(value, 'Sale price')).toThrow( + `Sale price must be a finite number, got ${value}`, + ) + }) + + it('rejects a fractional number', () => { + expect(() => toAmount(1.5, 'Sale price')).toThrow( + 'Sale price must be a whole number, got 1.5', + ) + }) + + it('rejects a number too large to represent exactly', () => { + // 2 ** 53 is the first integer a double cannot distinguish from its + // neighbour, so converting it would silently change the amount + expect(() => toAmount(2 ** 53, 'Offer amount')).toThrow( + /Offer amount exceeds the safe integer range/, + ) + }) + + it.each([-1, -1n])('rejects the negative value %s', (value) => { + expect(() => toAmount(value, 'Transfer amount')).toThrow( + 'Transfer amount must not be negative, got -1', + ) + }) + + it('names the parameter in the message', () => { + expect(() => toAmount(1.5, 'Segment price')).toThrow(/^Segment price /) + }) +}) From 1379a3e3b06055912bdeae940999dab4aa6f57aa Mon Sep 17 00:00:00 2001 From: Patrick Bennett Date: Thu, 27 Aug 2026 17:48:50 -0400 Subject: [PATCH 4/5] docs: describe the actual semantic-release publishing flow PUBLISHING.md still documented the pre-semantic-release setup: an NPM_TOKEN secret, a "Publish Package" workflow with manual dispatch and an override tag input, and alpha/beta/next prerelease branches. None of those exist. Releases come from .releaserc.js (branches: ['main']) via release.yml, which authenticates to npm with OIDC trusted publishing and to GitHub with an App token. Documents what is actually there, plus two things that are easy to get wrong: - The Angular preset has no breakingHeaderPattern, so `feat(core)!:` does not parse as a feat and contributes no bump on its own. A major needs the BREAKING CHANGE footer. - Squash-merging makes the PR title the commit header semantic-release reads, so a PR titled without a conventional type releases nothing. Also records the plugins-only invocation for a local dry run, since the npm and github plugins abort in verifyConditions without credentials. --- PUBLISHING.md | 127 +++++++++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 53 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index c1ebfcf..3f745f0 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,78 +1,99 @@ # Publishing @txnlab/nfd-sdk -This document outlines the process for publishing new versions of the `@txnlab/nfd-sdk` package. +Releases are fully automated by [semantic-release](https://semantic-release.gitbook.io/). **Merging to `main` is the release.** There is no manual publish step, no version to bump by hand, and no release notes to write. -## Automated Publishing via GitHub Actions +## The pipeline -We use GitHub Actions to automate the publishing process. The workflow is triggered either: +`.github/workflows/release.yml` runs on every push to `main` (and only in the `TxnLab/nfd-sdk` repository, so forks never publish): -1. Automatically when pushing to specific branches (`main`, `alpha`, `beta`, `next`) -2. Manually through the GitHub UI +1. Mint a token from the release GitHub App. +2. Check out the full history — semantic-release needs the tags to work out the last release. +3. Install dependencies with the pnpm version pinned by the root `packageManager` field, on the Node version in `.nvmrc`. +4. `pnpm run ci` — lint, format:check, typecheck, test, build, build:examples. The same sequence PRs run. +5. `npm audit signatures` — verify the registry signatures of installed dependencies. +6. `npx semantic-release`. -### Publishing Process +If any step fails, nothing is published. -#### Automatic Publishing +## What semantic-release does -1. **Make your changes** and commit them with semantic commit messages: +Configured in `.releaserc.js`, releasing from `main` only, tagged `v${version}`, with `pkgRoot` pointing at `packages/sdk`: - - `feat:` for new features (triggers a minor version bump) - - `fix:` for bug fixes (triggers a patch version bump) - - Include `BREAKING CHANGE:` in the commit body for breaking changes (requires manual version tag) +| Plugin | Effect | +| ------------------------- | -------------------------------------------------------------------------------- | +| `commit-analyzer` | Reads the commits since the last tag and decides the bump | +| `release-notes-generator` | Renders the notes from those commits | +| `changelog` | Prepends them to `packages/sdk/CHANGELOG.md` | +| `npm` | Writes the version into `packages/sdk/package.json` and publishes to npm | +| `github` | Creates the `vX.Y.Z` tag and the GitHub Release | +| `git` | Commits the changelog and package.json back as `chore(release): x.y.z [skip ci]` | -2. **Push to a publishing branch**: +**Do not hand-edit the `version` field in `packages/sdk/package.json`.** `@semantic-release/npm` overwrites it and `@semantic-release/git` commits the result, so a hand-set version is either ignored or fights the tool. - - `main` branch: Regular releases (latest tag) - - `next` branch: Next releases (next tag) - - `beta` branch: Beta releases (beta tag) - - `alpha` branch: Alpha releases (alpha tag) +## Version bumps -3. **The workflow will**: - - Build and test the package - - Determine the appropriate version bump based on commit messages - - Update the package.json version - - Publish to npm - - Create a git tag - - Create a GitHub release with a changelog +The bump comes from the commit types since the last tag, following the [Angular convention](./CONTRIBUTING.md#git-commit-guidelines): -#### Manual Publishing +| Commit | Bump | +| ------------------------------------------- | ----- | +| `feat:` | minor | +| `fix:`, `perf:`, `refactor:` | patch | +| `BREAKING CHANGE:` in the footer | major | +| `docs:`, `style:`, `test:`, `chore:`, `ci:` | none | -1. **Go to the "Actions" tab** in the GitHub repository -2. **Select the "Publish Package" workflow** -3. **Click "Run workflow"** -4. **Configure the workflow**: - - Optionally provide a specific version tag (e.g., `v1.0.0`) for manual versioning - - Optionally specify a branch to publish from (defaults to the current branch) -5. **Click "Run workflow"** +A push containing only no-bump types publishes nothing — the workflow succeeds and semantic-release logs that there is no release to make. -### Required Secrets +### A major needs the `BREAKING CHANGE:` footer -The following secrets must be configured in your GitHub repository: +The `!` shorthand does **not** work on its own here, and it is worse than a no-op. The Angular preset's header pattern is `/^(\w*)(?:\((.*)\))?: (.*)$/` — it has no `breakingHeaderPattern` — so `feat(core)!: …` fails to parse as a `feat` at all. Without a footer, such a commit contributes no bump and never reaches the Features section. -- `NPM_TOKEN`: An npm access token with publish permissions for the @txnlab organization +Write the footer, and keep the `!` only as a visual marker: -## Version Bumping +``` +feat(core)!: read NFD boxes in a single algod request -The package version is determined by: +BREAKING CHANGE: algosdk must now be v3.6.0 or later. +``` -1. If a manual tag is provided (e.g., `v1.0.0`), that version will be used -2. Otherwise, the version is determined based on commit messages since the last tag: - - `feat:` commits trigger a minor version bump - - `fix:`, `refactor:`, `perf:` commits trigger a patch version bump - - Commits with `BREAKING CHANGE:` in the body require a manual version tag +## Release notes -## First-time Publishing +`release-notes-generator` is configured to show only the sections that matter to consumers: **Features**, **Bug Fixes**, **Code Refactoring** and **Performance Improvements**. `docs`, `style`, `chore`, `test`, `build` and `ci` commits are hidden — they still count toward the history, they just do not appear in the notes. Breaking changes get their own section regardless of type. -For the first release, you need to provide a manual tag since there's no previous tag to compare against: +This means the commit subject and body **are** the release notes. Write them for someone reading the GitHub Release, not for the diff. -1. Go to the "Actions" tab in the GitHub repository -2. Select the "Publish Package" workflow -3. Click "Run workflow" -4. Enter `v0.1.0` (or your desired initial version) in the "Override release tag" field -5. Click "Run workflow" +## Merging: mind the squash title -## Prerelease Branches +Pull requests are squash-merged, and the squash title defaults to the PR title. That title becomes the commit header semantic-release parses, so **the PR title must carry the right type** — a PR titled `Add vault helpers` yields no release at all, where `feat(core): add vault helpers` yields a minor. -- `main` branch: Regular releases (latest) -- `next` branch: Next releases (next) -- `beta` branch: Beta releases (beta) -- `alpha` branch: Alpha releases (alpha) +The squash body is the concatenated commit messages, so a `BREAKING CHANGE:` footer written in a branch commit does survive the squash. Still, for a release that spans several meaningful commits — a major especially — prefer a merge commit so each subject reaches the notes intact instead of collapsing into one entry. + +## Authentication + +Nothing in the release path uses a long-lived npm token. + +- **npm** — publishing uses [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers). The npm package is configured to trust `TxnLab/nfd-sdk`'s `release.yml`, which is why the job needs `id-token: write`. `publishConfig.provenance` is `true`, so every release carries a provenance attestation. There is no `NPM_TOKEN` secret and adding one is not the fix for a publish failure. +- **GitHub** — the tag, release and release commit are made with a token minted from a GitHub App, so the release commit is attributable and can pass branch protection. It needs the `RELEASE_BOT_APP_ID` repository variable and the `RELEASE_BOT_PRIVATE_KEY` secret. + +## Checking what a release would do + +From a branch, before merging: + +```bash +nvm use # semantic-release requires the Node version in .nvmrc + +npx semantic-release --dry-run --no-ci \ + --branches "$(git branch --show-current)" \ + --plugins @semantic-release/commit-analyzer @semantic-release/release-notes-generator +``` + +Overriding `--plugins` restricts the run to analysis and note rendering, which is what makes it work locally — the npm and github plugins verify credentials during `verifyConditions` and abort without them. The output prints the computed next version and the notes as they will appear. + +## Troubleshooting + +**Nothing was published.** Check the release job log for `no relevant changes`. Every commit since the last tag was a non-bumping type — most often a PR squash-merged under a title with no conventional-commit type. + +**The bump was wrong.** semantic-release reads what was committed, not what was intended. Once a tag is published it stays; correct it by landing a follow-up commit with the right type rather than by moving the tag or unpublishing. + +**`npm ERR! 404` or an auth error during publish.** The trusted publisher configuration on npmjs.com no longer matches the workflow — check the repository, workflow filename and environment recorded there against `release.yml`. + +**A `node version ... is required` error.** semantic-release supports a narrow Node range; the workflow pins it from `.nvmrc`. Locally, run `nvm use` first. From 93ebc4cda1e4baaeb7bb3f437284966104b921a1 Mon Sep 17 00:00:00 2001 From: Patrick Bennett Date: Thu, 27 Aug 2026 21:24:38 -0400 Subject: [PATCH 5/5] docs: correct the contributor guide's drifted commands and conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md described a repository that has moved on: - "early development (pre-v1.0.0)" — the package is at 1.1.0. - "We use pnpm v9" — the root packageManager field pins pnpm 10, and engines requires >=10. - Branches named feature/*, where every branch since has been /* matching the commit type. - The type list omitted build and ci, and gave no hint that the type decides the version bump. - The pre-PR checklist listed pnpm format, which is the writer, not the check CI runs, and skipped build and build:examples entirely. `pnpm run ci` is the whole sequence in CI's order. - The Angular convention link pointed at the archived angular.js repo. Adds the two traps a new contributor has no way to see: the generated files under src/api and src/contracts that a regeneration will discard, and the `!` breaking-change shorthand, which the Angular preset's header pattern rejects outright — so `feat(core)!:` without the footer parses as no type at all and releases nothing. Also records what the mocked module tests can and cannot prove, and points at PUBLISHING.md for the release mechanics. CLAUDE.md listed pnpm test:watch and pnpm test:coverage under commands that "run from the repo root". Neither exists at the root — both are SDK package scripts and need --filter. --- CLAUDE.md | 10 ++++- CONTRIBUTING.md | 112 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8df8d67..556a434 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,13 +21,19 @@ All commands run from the repo root unless noted. pnpm install # Install dependencies pnpm build # Build the SDK (Vite → dist/esm + dist/cjs) pnpm test # Run tests once (Vitest) -pnpm test:watch # Run tests in watch mode -pnpm test:coverage # Run tests with v8 coverage pnpm lint # ESLint across all packages pnpm format # Prettier --write across all packages pnpm format:check # Prettier --check from the root (what CI runs) pnpm typecheck # TypeScript type checking (SDK only) pnpm build:examples # Build all example apps +pnpm run ci # The full PR sequence, in CI's order +``` + +Watch and coverage runs exist only in the SDK package, not at the root: + +```bash +pnpm --filter @txnlab/nfd-sdk test:watch +pnpm --filter @txnlab/nfd-sdk test:coverage ``` Run a single test file: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6df49c6..8fe9d2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thank you for considering contributing to `@txnlab/nfd-sdk`! -> **Note:** This SDK is in early development (pre-v1.0.0) and there are many features that are planned and soon to be added. In some cases, a feature you're considering proposing may already be in the works. Feel free to open an issue to discuss before investing significant time in implementation. +> **Note:** The SDK is under active development and there are features already planned or in progress. A feature you're considering proposing may already be in the works, so feel free to open an issue to discuss before investing significant time in implementation. ## Reporting Issues @@ -33,23 +33,22 @@ If you want to contribute to `@txnlab/nfd-sdk`, please follow these steps to get - Fork the repository. - Clone the repository. -- Create a new branch from `main` using the following naming convention: +- Create a new branch from `main`, named `/` using the same type as the commit will carry — `feat/vault-transfers`, `fix/reserved-for-address`, `ci/bump-actions`, `docs/publishing-flow`. - - For features: `feature/my-awesome-feature` - - For bug fixes: `fix/my-bug-fix` +- Match your Node and pnpm versions to the repository. -- Install dependencies. + - We use [nvm](https://github.com/nvm-sh/nvm) to manage node versions. Use the version in `.nvmrc`: - ```bash - pnpm install - ``` + ```bash + nvm use + ``` - - We use pnpm v9 as our package manager. If you are not familiar with pnpm, please refer to the [pnpm documentation](https://pnpm.io/cli/install). + - pnpm is pinned by the root `packageManager` field, and Corepack will select it for you. If you are not familiar with pnpm, see the [pnpm documentation](https://pnpm.io/cli/install). - - We use [nvm](https://github.com/nvm-sh/nvm) to manage node versions. Please make sure to use the version mentioned in the `.nvmrc` file. +- Install dependencies. ```bash - nvm use + pnpm install ``` - Build the SDK. @@ -64,6 +63,33 @@ If you want to contribute to `@txnlab/nfd-sdk`, please follow these steps to get - Submit PR for review (see PR guidelines below). +### Generated code + +Two sets of files under `packages/sdk/src/` are generated and must not be hand-edited — a regeneration will silently discard your changes: + +- `src/api/*.gen.ts` — the OpenAPI client, generated from `src/api/openapi3.yaml` +- `src/contracts/NFD*Client.ts` — the Algorand contract clients, generated from the ARC-56 specs in `src/contracts/minimal/` + +Regenerate both with `pnpm --filter @txnlab/nfd-sdk generate`. Edit the source of truth (the OpenAPI document or the ARC-56 spec) instead. + +### Running tests + +```bash +pnpm test # run once, from the root +pnpm --filter @txnlab/nfd-sdk test:watch # watch mode +pnpm --filter @txnlab/nfd-sdk test:coverage # with v8 coverage +``` + +A single file: + +```bash +pnpm --filter @txnlab/nfd-sdk exec vitest run tests/utils/nfd.test.ts +``` + +Only `test` exists at the root; the watch and coverage scripts live in the SDK package, hence the `--filter`. + +Note that the module tests mock the typed contract client and the transaction composer wholesale. They verify the SDK's own logic — which guard fires, which arguments and fees a call is given, what order transactions are added in — and prove nothing about whether a node would accept the resulting group. When a change is driven by a contract `assert`, quote that assert in the test comment. + ### Running Examples - Make sure you have installed dependencies in the repository's root directory. @@ -72,18 +98,22 @@ If you want to contribute to `@txnlab/nfd-sdk`, please follow these steps to get pnpm install ``` -- If you want to run an example against your local changes, navigate to the project in the `examples/` directory and run the following command: +- If you want to run an example against your local changes, build the SDK first (`pnpm build` from the root), then navigate to the project in the `examples/` directory and run the following command: ```bash pnpm dev ``` + The `lookup` example is a plain Node script rather than a Vite app; run it with `pnpm start`. + ## Git Commit Guidelines -`TxnLab/nfd-sdk` is using [Angular Commit Message Conventions](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines). +`TxnLab/nfd-sdk` is using [Angular Commit Message Conventions](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md). We have very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. +These rules are not only cosmetic. Releases are automated by semantic-release, so the commit type determines the version bump and the commit subject and body become the published release notes. See [PUBLISHING.md](./PUBLISHING.md) for the full picture. + ### Commit Message Format Each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**: @@ -102,16 +132,22 @@ Any line of the commit message cannot be longer than 100 characters! This allows ### Type -Must be one of the following: +Must be one of the following. The right-hand column is the version bump the type produces: + +| Type | Meaning | Bump | +| ------------ | --------------------------------------------------------------------------------- | ----- | +| **feat** | A new feature | minor | +| **fix** | A bug fix | patch | +| **perf** | A code change that improves performance | patch | +| **refactor** | A code change that neither fixes a bug nor adds a feature | patch | +| **docs** | Documentation only changes | none | +| **style** | Changes that do not affect the meaning of the code (white-space, formatting, etc) | none | +| **test** | Adding missing or correcting existing tests | none | +| **build** | Changes to the build system or dependencies | none | +| **ci** | Changes to the CI or release workflows | none | +| **chore** | Other changes to auxiliary tools and libraries | none | -- **feat**: A new feature -- **fix**: A bug fix -- **docs**: Documentation only changes -- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) -- **refactor**: A code change that neither fixes a bug nor adds a feature -- **perf**: A code change that improves performance -- **test**: Adding missing or correcting existing tests -- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation +Only `feat`, `fix`, `perf` and `refactor` appear in the release notes. The rest are still part of the history; they just do not produce a release on their own. ### Scope @@ -135,7 +171,15 @@ Just as in the **subject**, use the imperative, present tense: "change" not "cha The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit closes. -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. +**Breaking Changes** must start with the words `BREAKING CHANGE:` followed by a space or two newlines. The rest of the commit message is then used for this. + +The `!` shorthand (`feat(core)!: …`) does **not** work on its own here, and it is worse than doing nothing. The Angular preset's header pattern does not allow the `!`, so such a header fails to parse as a `feat` at all — without the footer, the commit produces no version bump and never reaches the release notes. Write the footer; keep the `!` only as a marker for human readers: + +``` +feat(core)!: read NFD boxes in a single algod request + +BREAKING CHANGE: algosdk must now be v3.6.0 or later. +``` ### Revert @@ -143,29 +187,21 @@ If the commit reverts a previous commit, it should begin with `revert: `, follow ## Pull Requests -- Pull requests will not be reviewed until all checks pass. Before submitting a pull request, ensure that you have run the following commands in the repository's root directory: +- Pull requests will not be reviewed until all checks pass. Before submitting a pull request, run the whole CI sequence from the repository's root directory: ```bash - pnpm lint + pnpm run ci ``` - ```bash - pnpm format - ``` - - ```bash - pnpm typecheck - ``` - - ```bash - pnpm test - ``` + That is `lint`, `format:check`, `typecheck`, `test`, `build` and `build:examples`, in the order CI runs them. Run `pnpm format` first if `format:check` fails — both operate on the whole repository from the root. - If possible/appropriate, create new tests that fail without your changes and pass with them. -- Pull requests are merged by squashing all commits and editing the commit message if necessary using the GitHub user interface. +- **The pull request title must be a valid commit header.** Pull requests are normally squash-merged, and the squash title defaults to the PR title, which becomes the commit header semantic-release parses. A PR titled `Add vault helpers` releases nothing; `feat(core): add vault helpers` releases a minor. + +- Use an appropriate commit type, and be especially careful with breaking changes — see the footer rules above. -- Use an appropriate commit type. Be especially careful with breaking changes. +- If a pull request contains several commits that each deserve their own release-note entry, say so in the description and ask for a merge commit instead of a squash. ## Documentation