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 new file mode 100644 index 0000000..556a434 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# 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 v10+** with workspaces (pinned by the root `packageManager` field, which CI reads too) +- 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 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: + +```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, 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. + +### 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. + +### 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`. + +### Constants + +`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 + +- **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. + +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 (`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/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 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. diff --git a/README.md b/README.md index 2449979..0b4b9a1 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 @@ -234,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/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..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" }, @@ -59,7 +60,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/.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 811a467..99c6e17 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 @@ -165,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/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/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..ff67a13 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,77 @@ 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..84e4a3d 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -10,6 +10,34 @@ export enum DefaultSender { TESTNET = 'A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE', } +/** The Algorand zero address (all zeros, used as a placeholder for "no address") */ +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 111794a..7718e24 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -6,18 +6,26 @@ 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 export { NfdApiClient } from './api-client' // Export modules +export { LookupModule } from './modules/lookup' export { NfdManager } from './modules/manager' export { PurchasingModule } from './modules/purchasing' @@ -28,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/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..e99fee5 100644 --- a/packages/sdk/src/modules/base.ts +++ b/packages/sdk/src/modules/base.ts @@ -5,13 +5,18 @@ 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, } from '../utils/internal/registry-box' +import { isValidName } from '../utils/nfd' 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 +99,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 @@ -119,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 76f60d1..be8d88e 100644 --- a/packages/sdk/src/modules/lookup.ts +++ b/packages/sdk/src/modules/lookup.ts @@ -1,9 +1,10 @@ +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,73 +19,72 @@ 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 */ 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 + * Resolve an NFD by name or application ID by reading directly from the blockchain + * @param nameOrAppId - The NFD name or application ID to resolve + * @param options - Optional parameters + * @returns The NFD record + * @throws If the NFD name is invalid or not found */ - private async parseAppId( + public async resolve( 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 + options: ResolveOptions = {}, + ): Promise { + const { nfd } = await this.resolveWithBoxes(nameOrAppId, options) + return nfd } /** - * Resolve an NFD by name or application ID by reading directly from the blockchain + * 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 + * @returns The NFD record and every box on its instance app * @throws If the NFD name is invalid or not found */ - public async resolve( + public async resolveWithBoxes( nameOrAppId: string | number | bigint, options: ResolveOptions = {}, - ): Promise { + ): 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 99693c7..fce2237 100644 --- a/packages/sdk/src/modules/manager.ts +++ b/packages/sdk/src/modules/manager.ts @@ -1,19 +1,36 @@ import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' -import { Address } from 'algosdk' - +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' +import type { AppBox } 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 */ 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) { @@ -28,11 +45,75 @@ 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 + } + + /** + * 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 @@ -88,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) } @@ -106,13 +189,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[] = [ @@ -187,7 +267,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -240,7 +320,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -339,7 +419,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -391,7 +471,7 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() return this.getNfd() } @@ -438,7 +518,636 @@ export class NfdManager extends BaseModule { } // Refresh the NFD data - this._nfd = null + this.invalidate() + 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 + * + * 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 `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) { + 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(RENEW_STATIC_FEE), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error(`Failed to renew NFD: ${parseTransactionError(error)}`) + } + + // Refresh the NFD data + this.invalidate() + return this.getNfd() + } + + /** + * 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 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') + } + 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, + reservedFor, + }, + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to list NFD for sale: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this.invalidate() + return this.getNfd() + } + + /** + * Cancel the sale listing for the NFD + * @returns The updated NFD + * @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() + 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.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') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + try { + await nfdInstanceClient + .newGroup() + .cancelSale({ + args: {}, + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error(`Failed to cancel sale: ${parseTransactionError(error)}`) + } + + // Refresh the NFD data + this.invalidate() + return this.getNfd() + } + + /** + * 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 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') + } + const nfdAppId = BigInt(nfd.appID) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + try { + await nfdInstanceClient + .newGroup() + .segmentLock({ + args: { + lock, + usdPrice: segmentPrice, + }, + staticFee: AlgoAmount.MicroAlgos(APP_CALL_STATIC_FEE), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to ${lock ? 'lock' : 'unlock'} segments: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this.invalidate() + 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 NFD is for sale or expired, or 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') + } + + this.assertNotForSaleOrExpired(nfd, '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(APP_CALL_STATIC_FEE), + }) + .send({ populateAppCallResources: true }) + } catch (error) { + throw new Error( + `Failed to ${lock ? 'lock' : 'unlock'} vault: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this.invalidate() + return this.getNfd() + } + + /** + * 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 `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) + + // Build the payments up front so a failure to construct one is not + // reported as though the transaction itself had failed + + // 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), + ), + }) + } + + 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, + assetId: BigInt(assetId), + amount, + note: options.note, + }) + } + + // 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 }) + } catch (error) { + throw new Error( + `Failed to send to vault: ${parseTransactionError(error)}`, + ) + } + + // Refresh the NFD data + this.invalidate() + 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 + * + * `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 `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[], + receiver: string, + 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') + } + + 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) + const nfdInstanceClient = this.getInstanceClient(nfdAppId, signer.addr) + + const primaryAsset = BigInt(assets[0]) + const otherAssets = assets.slice(1).map(BigInt) + + // 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, + receiver: receiverAddress, + 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.invalidate() return this.getNfd() } } diff --git a/packages/sdk/src/modules/purchasing.ts b/packages/sdk/src/modules/purchasing.ts index 1656731..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' @@ -292,4 +294,43 @@ 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 offer = toAmount(amount, 'Offer amount') + + // 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, + note, + }, + 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(nfdAppId, { view: 'full' }) + } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 44a1030..dc63472 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,109 @@ 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. 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 + + /** + * 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. 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 + + /** + * Optional note to include in the transaction + */ + note?: string + + /** + * 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' +} + +/** + * 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/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/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/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/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..42542ec 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) }) }) @@ -153,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 new file mode 100644 index 0000000..710c4a5 --- /dev/null +++ b/packages/sdk/tests/modules/manager.test.ts @@ -0,0 +1,1103 @@ +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' + +// 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, +} + +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 } + signer: ReturnType +} + +interface MockInstanceClient { + appAddress: string + appId: bigint + newGroup: ReturnType + appClient: { getBoxValue: 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(), + }), + 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', () => ({ + 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() + mockRegistryClient = createMockRegistryClient() + + mockSigner = { + addr: { toString: () => OWNER_ADDRESS }, + signer: vi.fn(), + } + + client = new NfdClient() + client.setSigner(OWNER_ADDRESS, mockSigner.signer) + manager = new NfdManager(client, 'test.algo') + + // 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 + 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', () => { + 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( + LookupModule.prototype, + 'resolveWithBoxes', + ).mockResolvedValueOnce({ + nfd: { ...mockOwnedNfd, appID: undefined }, + boxes: [], + }) + // 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( + 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') + }) + + 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', () => { + 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', + ) + }) + + 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({ + args: {}, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + + 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 () => { + 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, 300) + + expect(mockInstanceClient.newGroup().segmentLock).toHaveBeenCalledWith({ + args: { + lock: false, + usdPrice: 300n, + }, + staticFee: expect.objectContaining({ + amountInMicroAlgo: 3000n, + }), + }) + }) + + 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 }, + 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', + ) + }) + + 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', () => { + 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 + }), + }) + + // 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 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 }) + + // 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, + amount: expect.objectContaining({ + amountInMicroAlgo: 1000000n, + }), + note: undefined, + }) + }) + + 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' }) + + 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 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, + 'resolveWithBoxes', + ).mockResolvedValueOnce({ + nfd: { ...mockOwnedNfd, nfdAccount: undefined }, + boxes: [], + }) + // 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 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, + note: 'payment', + }) + + expect(mockInstanceClient.newGroup().vaultSend).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + note: 'payment', + }), + }), + ) + }) + + 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', () => { + /** 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/modules/purchasing.test.ts b/packages/sdk/tests/modules/purchasing.test.ts index 0770a73..e63083a 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({}), + }), }), } @@ -145,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( @@ -434,6 +459,82 @@ 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, + }) + + // 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) + }) + + 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 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('NFD not found: 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( 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/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 /) + }) +}) 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)