diff --git a/CHANGELOG.md b/CHANGELOG.md index 3021a77a..cd164586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +* [FEATURE][web] `MidenClient` supports non-fungible faucets and assets. Non-fungible faucets can be created with `client.accounts.create({ type: FaucetType.NonFungibleFaucet, symbol })`. Non-fungible assets can be built with `new NonFungibleAsset(faucetId, valueWord)` or `NonFungibleAsset.fromData(faucetId, data, salt)`, and they can be minted, transferred and burned with `client.transactions.mintNonFungible`, `client.transactions.sendNonFungible`, and `client.transactions.burnNonFungible`, respectively. `batch()` and `preview()` accept the matching `sendNonFungible`, `mintNonFungible` and `burnNonFungible` operations ([#474](https://github.com/0xMiden/web-sdk/pull/474)). * [FEATURE][web] `WebClient` supports non-fungible assets. Non-fungible faucets can be created with `WebClient.newNonFungibleFaucet`, and `newMintNonFungibleTransactionRequest`, `newSendNonFungibleAssetTransactionRequest` and `newBurnNonFungibleAssetTransactionRequest` build the mint, send and burn requests, respectively. Non-fungible assets can be built with `NonFungibleAsset.fromData` and `new NonFungibleAsset(faucetId, value)`. `NonFungibleFaucetComponent.fromAccount(account)` allows reading a faucet's symbol, name, description, logo URI and contract URI, and `Account.isNonFungibleFaucet()` identifies a non-fungible faucet ([#473](https://github.com/0xMiden/web-sdk/pull/473)). * [FEATURE][web] `AccountVaultDelta.addedNonFungibleAssets()` / `removedNonFungibleAssets()`, `AccountVaultPatch.updatedNonFungibleAssets()` and `AccountProof.vaultNonFungibleAssets()` return the non-fungible assets next to the existing fungible getters ([rust-sdk#2688](https://github.com/0xMiden/rust-sdk/pull/2688)). * [FEATURE][web,react] Transaction batches can span several local accounts. Every `client.transactions.batch` operation names its own executing `account`, so one atomic batch can mix any tracked accounts, and a later transaction may consume a note an earlier one produced, including across accounts. `submitBatch` takes `{ account, request }` pairs, backed by a new `BatchItem` (`new BatchItem(accountId, request)`) that the WASM `submitNewTransactionBatch` accepts as an array. The React SDK adds `useBatch()`, whose `batch({ items, skipSync? })` submits pre-built requests as one batch and resolves to `{ blockNumber }`. Each batch transaction is proven inside the batch primitive by the client's built-in local prover, so `MidenClient`'s `proverUrl` and `MidenProvider`'s `prover` setting, fallback included, do not apply to batches ([#161](https://github.com/0xMiden/web-sdk/pull/161), [rust-sdk#2177](https://github.com/0xMiden/rust-sdk/pull/2177)). diff --git a/crates/web-client/AGENTS.md b/crates/web-client/AGENTS.md index fdd2ddda..55bffcb7 100644 --- a/crates/web-client/AGENTS.md +++ b/crates/web-client/AGENTS.md @@ -88,12 +88,12 @@ with it, and free the object wrappers the skills call out individually. **Account visibility and faucet kind are separate.** Use native `AccountType.Private` / `AccountType.Public` with `AccountBuilder.accountType()`. -Use `FaucetType.FungibleFaucet` for `accounts.create({ type })`, and `storage` -for visibility. Older `AccountType.FungibleFaucet` references must migrate to +Use `FaucetType.FungibleFaucet` or `FaucetType.NonFungibleFaucet` for +`accounts.create({ type })`, and `storage` for visibility. Older `AccountType.FungibleFaucet` references must migrate to `FaucetType.FungibleFaucet`; `create()` throws a `TypeError` for an unrecognised `type` rather than creating a wallet. The legacy `0` / `1` are still faucet -selectors and equal `AccountType.Private` / `Public`, so never pass a visibility -value as `type`. +selectors (fungible / non-fungible) and equal `AccountType.Private` / `Public`, +so never pass a visibility value as `type`. ## Read non-fungible assets @@ -136,7 +136,7 @@ For registry publishing, use `Note.withAttachments()` with a single name asset, public metadata, the registry's approved script and inputs, and `[new NetworkAccountTarget(registryId).toAttachment()]`. The registry account must be public. A tag alone does not make a network note. Consume the returned P2ID note to put -the asset back in the vault. The amount-based `send` helper remains fungible-only. +the asset back in the vault. ## Going deeper diff --git a/crates/web-client/README.md b/crates/web-client/README.md index 42e29871..f8f68b00 100644 --- a/crates/web-client/README.md +++ b/crates/web-client/README.md @@ -478,17 +478,19 @@ const builder = new AccountBuilder(new Uint8Array(32)) ``` For `client.accounts.create()`, select visibility with `storage: "public"` or -`"private"`, and create a fungible faucet with `type: FaucetType.FungibleFaucet`. +`"private"`, create a fungible faucet with `type: FaucetType.FungibleFaucet`, +and a non-fungible faucet with `type: FaucetType.NonFungibleFaucet` (`symbol` +and an optional `name`; no `decimals` or `maxSupply`). Migrate previous `AccountType.FungibleFaucet` uses to `FaucetType.FungibleFaucet`. Omit `type` to create a wallet, or pass `components` to create a contract. -The legacy selectors `0`, `1` and `"NonFungibleFaucet"` are still read as -faucet types (non-fungible faucets are not supported yet and are rejected); -`0` and `1` are also `AccountType.Private` / `AccountType.Public`, so never pass -a visibility value as `type`. `create()` throws a `TypeError` for any other -`type`, for faucet fields (`name`, `symbol`, `decimals`, `maxSupply`) without a -faucet type, for `components` on a faucet, and for a faucet missing `symbol`, -`decimals` or `maxSupply`, so a missed migration fails instead of creating a -wallet. +The legacy selectors `0` (fungible) and `1` (non-fungible) are still read as +faucet types; they are also `AccountType.Private` / `AccountType.Public`, so +never pass a visibility value as `type`. `create()` throws a `TypeError` for any +other `type`, for faucet fields (`name`, `symbol`, `decimals`, `maxSupply`) +without a faucet type, for `components` on a faucet, for a fungible faucet +missing `symbol`, `decimals` or `maxSupply`, and for a non-fungible faucet +missing `symbol` or given `decimals` or `maxSupply`, so a missed migration +fails instead of creating a wallet. ### Create a New Wallet diff --git a/crates/web-client/js/__tests__/account-type-declarations.test.js b/crates/web-client/js/__tests__/account-type-declarations.test.js index aa2e6126..c242f487 100644 --- a/crates/web-client/js/__tests__/account-type-declarations.test.js +++ b/crates/web-client/js/__tests__/account-type-declarations.test.js @@ -26,17 +26,22 @@ it("type-checks visibility and faucet selectors through the public declarations" consumer, ` import { AccountBuilder, AccountType, FaucetType, - type FaucetCreateOptions } from "./index"; + type CreateAccountOptions, type FaucetCreateOptions, + type NonFungibleFaucetCreateOptions } from "./index"; new AccountBuilder(new Uint8Array(32)).accountType(AccountType.Public); new AccountBuilder(new Uint8Array(32)).accountType(AccountType.Private); const visibility: AccountType = AccountType.Public; const kind: FaucetType = FaucetType.FungibleFaucet; - const literal: "FungibleFaucet" = kind; + const literal: "FungibleFaucet" = FaucetType.FungibleFaucet; const faucet: FaucetCreateOptions = { - type: kind, symbol: "TOK", decimals: 8, maxSupply: 1000n + type: FaucetType.FungibleFaucet, symbol: "TOK", decimals: 8, maxSupply: 1000n }; - // @ts-expect-error Non-fungible faucets have no public selector. - FaucetType.NonFungibleFaucet; + const nftKind: "NonFungibleFaucet" = FaucetType.NonFungibleFaucet; + const nftFaucet: NonFungibleFaucetCreateOptions = { + type: FaucetType.NonFungibleFaucet, symbol: "NFT" + }; + // @ts-expect-error A non-fungible faucet takes no decimals or maxSupply. + const badNftFaucet: CreateAccountOptions = { type: FaucetType.NonFungibleFaucet, symbol: "NFT", decimals: 8 }; // @ts-expect-error AccountTypeValue was removed with the rename. import type { AccountTypeValue } from "./index"; // @ts-expect-error Faucet kinds are no longer members of AccountType. diff --git a/crates/web-client/js/__tests__/account-type-exports.test.js b/crates/web-client/js/__tests__/account-type-exports.test.js index 20aaba9c..cb2753b1 100644 --- a/crates/web-client/js/__tests__/account-type-exports.test.js +++ b/crates/web-client/js/__tests__/account-type-exports.test.js @@ -43,8 +43,10 @@ describe.each([ }); it("exports an immutable string faucet selector distinct from AccountType", () => { - expect(sdk.FaucetType).toEqual({ FungibleFaucet: "FungibleFaucet" }); - expect(sdk.FaucetType.NonFungibleFaucet).toBeUndefined(); + expect(sdk.FaucetType).toEqual({ + FungibleFaucet: "FungibleFaucet", + NonFungibleFaucet: "NonFungibleFaucet", + }); expect(Object.isFrozen(sdk.FaucetType)).toBe(true); }); diff --git a/crates/web-client/js/__tests__/resources/accounts.test.js b/crates/web-client/js/__tests__/resources/accounts.test.js index 5507c05c..1611a0c0 100644 --- a/crates/web-client/js/__tests__/resources/accounts.test.js +++ b/crates/web-client/js/__tests__/resources/accounts.test.js @@ -134,8 +134,6 @@ describe("AccountsResource", () => { auth: "falcon", name: "Collectible", symbol: "NFT", - decimals: 0, - maxSupply: 1, }); expect(inner.newNonFungibleFaucet).toHaveBeenCalledWith( "public", @@ -154,8 +152,6 @@ describe("AccountsResource", () => { auth: "falcon", name: "Collectible", symbol: "NFT", - decimals: 0, - maxSupply: 1, }); expect(inner.newNonFungibleFaucet).toHaveBeenCalledWith( "public", @@ -166,6 +162,26 @@ describe("AccountsResource", () => { expect(inner.newFaucet).not.toHaveBeenCalled(); }); + it("rejects decimals and maxSupply on a non-fungible faucet", async () => { + const resource = makeResource(); + await expect( + resource.create({ + type: "NonFungibleFaucet", + symbol: "NFT", + decimals: 0, + maxSupply: 1, + }) + ).rejects.toThrow("decimals, maxSupply only apply to fungible faucets"); + expect(inner.newNonFungibleFaucet).not.toHaveBeenCalled(); + }); + + it("requires only symbol for a non-fungible faucet", async () => { + const resource = makeResource(); + await expect( + resource.create({ type: "NonFungibleFaucet" }) + ).rejects.toThrow("a faucet request needs symbol."); + }); + it("defaults storage to 'public' when not specified for faucet", async () => { const resource = makeResource(); await resource.create({ diff --git a/crates/web-client/js/__tests__/resources/transactions.test.js b/crates/web-client/js/__tests__/resources/transactions.test.js index 5a82264f..5445df6c 100644 --- a/crates/web-client/js/__tests__/resources/transactions.test.js +++ b/crates/web-client/js/__tests__/resources/transactions.test.js @@ -130,6 +130,8 @@ function makeWasm(overrides = {}) { }, NoteAssets: vi.fn().mockReturnValue("noteAssets"), FungibleAsset: vi.fn().mockReturnValue("fungibleAsset"), + NonFungibleAsset: MockNonFungibleAsset, + VaultAsset: class {}, NoteAttachment: vi.fn().mockImplementation((v) => ({ attachment: v })), NoteArray: vi.fn().mockImplementation(makeNoteArray), NoteAndArgs: vi.fn().mockImplementation((note, args) => ({ note, args })), @@ -221,6 +223,15 @@ function makeInner(overrides = {}) { applyTransaction: vi.fn().mockResolvedValue(undefined), newSendTransactionRequest: vi.fn().mockResolvedValue("sendRequest"), newMintTransactionRequest: vi.fn().mockResolvedValue("mintRequest"), + newMintNonFungibleTransactionRequest: vi + .fn() + .mockResolvedValue("mintNftRequest"), + newSendNonFungibleAssetTransactionRequest: vi + .fn() + .mockResolvedValue("sendAssetRequest"), + newBurnNonFungibleAssetTransactionRequest: vi + .fn() + .mockResolvedValue("burnAssetRequest"), newB2AggTransactionRequest: vi.fn().mockResolvedValue("b2aggRequest"), newConsumeTransactionRequest: vi.fn().mockResolvedValue("consumeRequest"), // Keep the builder the resource actually used, so a test can assert what was @@ -266,6 +277,18 @@ function makeClient(overrides = {}) { // ── Helpers ──────────────────────────────────────────────────────────────────── +// Stands in for the WASM NonFungibleAsset class. +class MockNonFungibleAsset { + constructor(faucetHex) { + this.intoWord = vi.fn(() => "valueWord"); + this.faucetId = vi.fn(() => ({ toString: () => faucetHex })); + } +} + +function makeNft(faucetHex = "0xnftfaucet") { + return new MockNonFungibleAsset(faucetHex); +} + function makeResource( innerOverrides = {}, clientOverrides = {}, @@ -357,6 +380,48 @@ describe("TransactionsResource", () => { }); }); + describe("sendNonFungible", () => { + it("builds a send-asset request with the asset", async () => { + const { resource, inner } = makeResource(); + const nft = makeNft(); + await resource.sendNonFungible({ + account: "0xsender", + to: "0xrecipient", + nonFungibleAsset: nft, + reclaimAfter: 10, + }); + expect(inner.newSendTransactionRequest).not.toHaveBeenCalled(); + expect( + inner.newSendNonFungibleAssetTransactionRequest + ).toHaveBeenCalledWith( + expect.objectContaining({ hex: "0xsender" }), + expect.objectContaining({ hex: "0xrecipient" }), + nft, + "Public", + 10, + undefined + ); + expect(inner.executeTransaction).toHaveBeenCalledWith( + expect.anything(), + "sendAssetRequest" + ); + }); + + it("puts the asset in the returned P2ID note", async () => { + const { resource, wasm } = makeResource(); + const nft = makeNft(); + const result = await resource.sendNonFungible({ + account: "0xsender", + to: "0xrecipient", + nonFungibleAsset: nft, + returnNote: true, + }); + expect(wasm.FungibleAsset).not.toHaveBeenCalled(); + expect(wasm.NoteAssets).toHaveBeenCalledWith([nft]); + expect(result.note).toBe("p2idNote"); + }); + }); + describe("send — returnNote path", () => { it("throws when reclaimAfter is set with returnNote=true", async () => { const { resource } = makeResource(); @@ -541,6 +606,19 @@ describe("TransactionsResource", () => { expect(wasm.NoteAssets).toHaveBeenCalledWith([expect.anything()]); }); + it("passes a non-fungible asset through to NoteAssets", async () => { + const { resource, wasm } = makeResource(); + const nft = makeNft(); + await resource.createNetworkNote({ + account: "0xsender", + target: "0xtarget", + script: "s", + assets: [nft, { token: "0xtoken", amount: 5 }], + }); + expect(wasm.NoteAssets).toHaveBeenCalledWith([nft, expect.anything()]); + expect(wasm.FungibleAsset).toHaveBeenCalledTimes(1); + }); + it("uses a pre-built NetworkAccountTarget directly without reconstructing it", async () => { const { resource, wasm } = makeResource(); const preBuilt = Object.create(wasm.NetworkAccountTarget.prototype); @@ -603,6 +681,119 @@ describe("TransactionsResource", () => { }); }); + describe("mintNonFungible", () => { + it("builds a non-fungible mint request from `nonFungibleAsset`", async () => { + const { resource, inner } = makeResource(); + const nft = makeNft("0xfaucet"); + await resource.mintNonFungible({ + account: "0xfaucet", + to: "0xrecipient", + nonFungibleAsset: nft, + }); + expect(inner.newMintTransactionRequest).not.toHaveBeenCalled(); + expect(inner.newMintNonFungibleTransactionRequest).toHaveBeenCalledWith( + expect.objectContaining({ hex: "0xrecipient" }), + nft, + "Public" + ); + expect(inner.executeTransaction).toHaveBeenCalledWith( + expect.objectContaining({ hex: "0xfaucet" }), + "mintNftRequest" + ); + }); + + it("rejects an asset issued by another faucet", async () => { + const { resource, inner } = makeResource(); + await expect( + resource.mintNonFungible({ + account: "0xfaucet", + to: "0xrecipient", + nonFungibleAsset: makeNft("0xother"), + }) + ).rejects.toThrow("must be issued by the minting faucet"); + expect(inner.newMintNonFungibleTransactionRequest).not.toHaveBeenCalled(); + }); + + it("previews and batches the non-fungible send and mint operations", async () => { + const { resource, inner } = makeResource(); + inner.submitNewTransactionBatch = vi.fn().mockResolvedValue(1); + const nft = makeNft("0xfaucet"); + const send = { + account: "0xsender", + to: "0xrecipient", + nonFungibleAsset: nft, + }; + const mint = { + account: "0xfaucet", + to: "0xrecipient", + nonFungibleAsset: nft, + }; + await resource.preview({ operation: "sendNonFungible", ...send }); + await resource.preview({ operation: "mintNonFungible", ...mint }); + await resource.batch({ + operations: [ + { kind: "sendNonFungible", ...send }, + { kind: "mintNonFungible", ...mint }, + ], + }); + expect( + inner.newSendNonFungibleAssetTransactionRequest + ).toHaveBeenCalledTimes(2); + expect(inner.newMintNonFungibleTransactionRequest).toHaveBeenCalledTimes( + 2 + ); + }); + }); + + describe("missing nonFungibleAsset", () => { + it.each([ + ["sendNonFungible", { account: "0xsender", to: "0xrecipient" }], + ["mintNonFungible", { account: "0xfaucet", to: "0xrecipient" }], + ["burnNonFungible", { account: "0xholder" }], + ])("%s names the missing option", async (method, opts) => { + const { resource, inner } = makeResource(); + await expect(resource[method](opts)).rejects.toThrow( + `${method} requires \`nonFungibleAsset\`.` + ); + await expect( + resource.preview({ operation: method, ...opts }) + ).rejects.toThrow(`${method} requires \`nonFungibleAsset\`.`); + expect(inner.executeTransaction).not.toHaveBeenCalled(); + }); + }); + + describe("burnNonFungible", () => { + it("builds a burn request from the holding account and submits it", async () => { + const { resource, inner } = makeResource(); + const nft = makeNft(); + const result = await resource.burnNonFungible({ + account: "0xholder", + nonFungibleAsset: nft, + }); + expect( + inner.newBurnNonFungibleAssetTransactionRequest + ).toHaveBeenCalledWith(expect.objectContaining({ hex: "0xholder" }), nft); + expect(inner.executeTransaction).toHaveBeenCalledWith( + expect.objectContaining({ hex: "0xholder" }), + "burnAssetRequest" + ); + expect(result.txId).toBeDefined(); + }); + + it("previews and batches the burnNonFungible operation", async () => { + const { resource, inner } = makeResource(); + inner.submitNewTransactionBatch = vi.fn().mockResolvedValue(1); + const burn = { account: "0xholder", nonFungibleAsset: makeNft() }; + await resource.preview({ operation: "burnNonFungible", ...burn }); + await resource.batch({ + operations: [{ kind: "burnNonFungible", ...burn }], + }); + expect( + inner.newBurnNonFungibleAssetTransactionRequest + ).toHaveBeenCalledTimes(2); + }); + }); + describe("bridge", () => { it("builds a B2AGG request and submits", async () => { const { resource, inner, wasm } = makeResource(); @@ -1385,8 +1576,11 @@ describe("TransactionsResource", () => { // would execute at the tip while the caller believed it was pinned. const OPTS_METHODS = [ "send", + "sendNonFungible", "createNetworkNote", "mint", + "mintNonFungible", + "burnNonFungible", "bridge", "consume", "consumeAll", diff --git a/crates/web-client/js/enums.js b/crates/web-client/js/enums.js index 0e2ecea8..fea33183 100644 --- a/crates/web-client/js/enums.js +++ b/crates/web-client/js/enums.js @@ -8,6 +8,7 @@ // Public = 1). create() still accepts the legacy numeric 0 and 1. export const FaucetType = Object.freeze({ FungibleFaucet: "FungibleFaucet", + NonFungibleFaucet: "NonFungibleFaucet", }); export const AuthScheme = Object.freeze({ diff --git a/crates/web-client/js/resources/accounts.js b/crates/web-client/js/resources/accounts.js index 45251fe6..6907ca14 100644 --- a/crates/web-client/js/resources/accounts.js +++ b/crates/web-client/js/resources/accounts.js @@ -5,12 +5,12 @@ import { hashSeed, } from "../utils.js"; -// Legacy numeric 0/1 and "NonFungibleFaucet" are still accepted; non-fungible -// requests reach the Rust rejection. +// Legacy numeric 0 and 1 select a fungible and a non-fungible faucet. const FAUCET_TYPES = new Set(["FungibleFaucet", "NonFungibleFaucet", 0, 1]); const CONTRACT_TYPES = new Set(["ImmutableContract", "MutableContract"]); const FAUCET_FIELDS = ["name", "symbol", "decimals", "maxSupply"]; -const REQUIRED_FAUCET_FIELDS = ["symbol", "decimals", "maxSupply"]; +const REQUIRED_FUNGIBLE_FAUCET_FIELDS = ["symbol", "decimals", "maxSupply"]; +const REQUIRED_NON_FUNGIBLE_FAUCET_FIELDS = ["symbol"]; function display(value) { try { @@ -22,7 +22,7 @@ function display(value) { function selectorError(problem) { return new TypeError( - `accounts.create(): ${problem} Pass type: FaucetType.FungibleFaucet for a faucet, omit type for a wallet, or pass components for a contract.` + `accounts.create(): ${problem} Pass type: FaucetType.FungibleFaucet or FaucetType.NonFungibleFaucet for a faucet, omit type for a wallet, or pass components for a contract.` ); } @@ -41,12 +41,14 @@ export class AccountsResource { * Create a wallet by default, a faucet via `FaucetType`, or a contract via * `components`. Visibility is selected separately with `storage`. * - * The legacy 0, 1 and "NonFungibleFaucet" still select a faucet; 0 and 1 are - * also AccountType.Private/Public, so a visibility value passed as `type` is - * read as a faucet selector. Throws a TypeError naming `FaucetType`, before - * creating anything, for an unrecognised `type`, for faucet fields (`name`, - * `symbol`, `decimals`, `maxSupply`) without a faucet type, for `components` - * on a faucet, and for a faucet missing `symbol`, `decimals` or `maxSupply`. + * The legacy 0 and 1 still select a faucet (0 fungible, 1 non-fungible); 0 + * and 1 are also AccountType.Private/Public, so a visibility value passed as + * `type` is read as a faucet selector. Throws a TypeError naming `FaucetType`, + * before creating anything, for an unrecognised `type`, for faucet fields + * (`name`, `symbol`, `decimals`, `maxSupply`) without a faucet type, for + * `components` on a faucet, for a faucet missing `symbol` (or `decimals` or + * `maxSupply` when fungible), and for a non-fungible faucet given `decimals` + * or `maxSupply`. */ async create(opts) { this.#client.assertNotTerminated(); @@ -58,16 +60,31 @@ export class AccountsResource { if (opts.components !== undefined) { throw selectorError("a faucet request cannot carry components."); } - const missing = REQUIRED_FAUCET_FIELDS.filter( - (field) => opts[field] === undefined - ); + const nonFungible = type === 1 || type === "NonFungibleFaucet"; + if (nonFungible) { + const extra = REQUIRED_FUNGIBLE_FAUCET_FIELDS.filter( + (field) => + !REQUIRED_NON_FUNGIBLE_FAUCET_FIELDS.includes(field) && + opts[field] !== undefined + ); + if (extra.length > 0) { + throw selectorError( + `${extra.join(", ")} only apply to fungible faucets.` + ); + } + } + const missing = ( + nonFungible + ? REQUIRED_NON_FUNGIBLE_FAUCET_FIELDS + : REQUIRED_FUNGIBLE_FAUCET_FIELDS + ).filter((field) => opts[field] === undefined); if (missing.length > 0) { throw selectorError(`a faucet request needs ${missing.join(", ")}.`); } const storageMode = resolveStorageMode(opts.storage ?? "public", wasm); const authScheme = resolveAuthScheme(opts.auth, wasm); const name = opts.name ?? opts.symbol; - if (type === 1 || type === "NonFungibleFaucet") { + if (nonFungible) { return await this.#inner.newNonFungibleFaucet( storageMode, name, diff --git a/crates/web-client/js/resources/transactions.js b/crates/web-client/js/resources/transactions.js index 9727e5c3..4a57763e 100644 --- a/crates/web-client/js/resources/transactions.js +++ b/crates/web-client/js/resources/transactions.js @@ -8,7 +8,10 @@ import { /** Preview operations that build their own request, and so cannot be anchored. */ export const PREVIEW_BUILT_IN_OPERATIONS = new Set([ "send", + "sendNonFungible", "mint", + "mintNonFungible", + "burnNonFungible", "bridge", "consume", "swap", @@ -17,6 +20,34 @@ export const PREVIEW_BUILT_IN_OPERATIONS = new Set([ "pswapCancel", ]); +/** + * True for a WASM asset object (`NonFungibleAsset`, `FungibleAsset` or + * `VaultAsset`), false for a fungible `{ token, amount }`. + */ +function isAssetObject(asset, wasm) { + return ( + asset instanceof wasm.NonFungibleAsset || + asset instanceof wasm.FungibleAsset || + asset instanceof wasm.VaultAsset + ); +} + +/** Throws a clear error when a non-fungible operation is missing its asset. */ +function requireNonFungibleAsset(opts, operation) { + if (opts?.nonFungibleAsset == null) { + throw new Error(`${operation} requires \`nonFungibleAsset\`.`); + } +} + +/** Converts `{ token, amount }` or a WASM asset object into a WASM asset. */ +function toWasmAsset(asset, wasm) { + if (isAssetObject(asset, wasm)) return asset; + return new wasm.FungibleAsset( + resolveAccountRef(asset.token, wasm), + BigInt(asset.amount) + ); +} + /** * Reject an `anchor` that is present but falsy — except `undefined`, which is * how an optional property spells "absent". @@ -107,6 +138,21 @@ export class TransactionsResource { async send(opts) { rejectUnexpectedAnchor(opts, "send", "submit()"); + return this.#sendP2ID(opts, false); + } + + /** + * Sends one non-fungible asset in a P2ID note (P2IDE with `reclaimAfter` / + * `timelockUntil`). Takes the same options as `send`, with + * `nonFungibleAsset` in place of `token` and `amount`. + */ + async sendNonFungible(opts) { + rejectUnexpectedAnchor(opts, "sendNonFungible", "submit()"); + requireNonFungibleAsset(opts, "sendNonFungible"); + return this.#sendP2ID(opts, true); + } + + async #sendP2ID(opts, nonFungible) { this.#client.assertNotTerminated(); const wasm = await this.#getWasm(); @@ -121,15 +167,18 @@ export class TransactionsResource { const senderId = resolveAccountRef(opts.account, wasm); const receiverId = resolveAccountRef(opts.to, wasm); - const faucetId = resolveAccountRef(opts.token, wasm); const noteType = resolveNoteType(opts.type, wasm); + const asset = nonFungible + ? opts.nonFungibleAsset + : new wasm.FungibleAsset( + resolveAccountRef(opts.token, wasm), + BigInt(opts.amount) + ); const note = wasm.Note.createP2IDNote( senderId, receiverId, - new wasm.NoteAssets([ - new wasm.FungibleAsset(faucetId, BigInt(opts.amount)), - ]), + new wasm.NoteAssets([asset]), noteType, new wasm.NoteAttachment() ); @@ -156,7 +205,9 @@ export class TransactionsResource { } // Default path — note built in WASM with optional reclaim/timelock - const { accountId, request } = await this.#buildSendRequest(opts, wasm); + const { accountId, request } = nonFungible + ? await this.#buildSendNonFungibleRequest(opts, wasm) + : await this.#buildSendRequest(opts, wasm); const { txId, result } = await this.#submitOrSubmitWithProver( accountId, request, @@ -198,12 +249,8 @@ export class TransactionsResource { const noteAssets = opts.assets ? new wasm.NoteAssets( - (Array.isArray(opts.assets) ? opts.assets : [opts.assets]).map( - (a) => - new wasm.FungibleAsset( - resolveAccountRef(a.token, wasm), - BigInt(a.amount) - ) + (Array.isArray(opts.assets) ? opts.assets : [opts.assets]).map((a) => + toWasmAsset(a, wasm) ) ) : new wasm.NoteAssets(); @@ -284,9 +331,53 @@ export class TransactionsResource { async mint(opts) { rejectUnexpectedAnchor(opts, "mint", "submit()"); + return this.#mint(opts, false); + } + + /** + * Mints one non-fungible asset from a non-fungible faucet. Takes the same + * options as `mint`, with `nonFungibleAsset` (issued by `account`) in place + * of `amount`. + */ + async mintNonFungible(opts) { + rejectUnexpectedAnchor(opts, "mintNonFungible", "submit()"); + return this.#mint(opts, true); + } + + async #mint(opts, nonFungible) { this.#client.assertNotTerminated(); const wasm = await this.#getWasm(); - const { accountId, request } = await this.#buildMintRequest(opts, wasm); + const { accountId, request } = nonFungible + ? await this.#buildMintNonFungibleRequest(opts, wasm) + : await this.#buildMintRequest(opts, wasm); + + const { txId, result } = await this.#submitOrSubmitWithProver( + accountId, + request, + opts.prover + ); + + if (opts.waitForConfirmation) { + await this.waitFor(txId.toHex(), { timeout: opts.timeout }); + } + + return { txId, result }; + } + + /** + * Sends one non-fungible asset back to its faucet in a public BURN note. The + * asset is burned when the faucet that issued it consumes the note. A BURN + * note for a public faucet is a network note, so a public faucet must be a + * network account. + */ + async burnNonFungible(opts) { + rejectUnexpectedAnchor(opts, "burnNonFungible", "submit()"); + this.#client.assertNotTerminated(); + const wasm = await this.#getWasm(); + const { accountId, request } = await this.#buildBurnNonFungibleRequest( + opts, + wasm + ); const { txId, result } = await this.#submitOrSubmitWithProver( accountId, @@ -531,10 +622,31 @@ export class TransactionsResource { ({ accountId, request } = await this.#buildSendRequest(opts, wasm)); break; } + case "sendNonFungible": { + ({ accountId, request } = await this.#buildSendNonFungibleRequest( + opts, + wasm + )); + break; + } case "mint": { ({ accountId, request } = await this.#buildMintRequest(opts, wasm)); break; } + case "mintNonFungible": { + ({ accountId, request } = await this.#buildMintNonFungibleRequest( + opts, + wasm + )); + break; + } + case "burnNonFungible": { + ({ accountId, request } = await this.#buildBurnNonFungibleRequest( + opts, + wasm + )); + break; + } case "bridge": { ({ accountId, request } = await this.#buildB2AggRequest(opts, wasm)); break; @@ -646,9 +758,18 @@ export class TransactionsResource { case "send": built = await this.#buildSendRequest(op, wasm); break; + case "sendNonFungible": + built = await this.#buildSendNonFungibleRequest(op, wasm); + break; case "mint": built = await this.#buildMintRequest(op, wasm); break; + case "mintNonFungible": + built = await this.#buildMintNonFungibleRequest(op, wasm); + break; + case "burnNonFungible": + built = await this.#buildBurnNonFungibleRequest(op, wasm); + break; case "consume": built = await this.#buildConsumeRequest(op, wasm); break; @@ -1086,6 +1207,46 @@ export class TransactionsResource { return { accountId, request }; } + async #buildSendNonFungibleRequest(opts, wasm) { + requireNonFungibleAsset(opts, "sendNonFungible"); + const accountId = resolveAccountRef(opts.account, wasm); + const request = await this.#inner.newSendNonFungibleAssetTransactionRequest( + accountId, + resolveAccountRef(opts.to, wasm), + opts.nonFungibleAsset, + resolveNoteType(opts.type, wasm), + opts.reclaimAfter, + opts.timelockUntil + ); + return { accountId, request }; + } + + async #buildMintNonFungibleRequest(opts, wasm) { + requireNonFungibleAsset(opts, "mintNonFungible"); + const accountId = resolveAccountRef(opts.account, wasm); + if (opts.nonFungibleAsset.faucetId().toString() !== accountId.toString()) { + throw new Error( + "mintNonFungible: `nonFungibleAsset` must be issued by the minting faucet `account`." + ); + } + const request = await this.#inner.newMintNonFungibleTransactionRequest( + resolveAccountRef(opts.to, wasm), + opts.nonFungibleAsset, + resolveNoteType(opts.type, wasm) + ); + return { accountId, request }; + } + + async #buildBurnNonFungibleRequest(opts, wasm) { + requireNonFungibleAsset(opts, "burnNonFungible"); + const accountId = resolveAccountRef(opts.account, wasm); + const request = await this.#inner.newBurnNonFungibleAssetTransactionRequest( + accountId, + opts.nonFungibleAsset + ); + return { accountId, request }; + } + async #buildMintRequest(opts, wasm) { const accountId = resolveAccountRef(opts.account, wasm); const targetId = resolveAccountRef(opts.to, wasm); diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index 1c032deb..993e6d83 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -8,6 +8,7 @@ import type { AccountFile, AccountCode, AssetVault, + NonFungibleAsset, Word, Felt, TransactionId, @@ -158,6 +159,7 @@ export type FaucetType = (typeof FaucetType)[keyof typeof FaucetType]; */ export declare const FaucetType: { readonly FungibleFaucet: "FungibleFaucet"; + readonly NonFungibleFaucet: "NonFungibleFaucet"; }; // ════════════════════════════════════════════════════════════════ @@ -300,6 +302,12 @@ export interface Asset { amount: number | bigint; } +/** + * An asset to transfer: a fungible amount (`{ token, amount }`) or one + * `NonFungibleAsset`. + */ +export type TransferAsset = Asset | NonFungibleAsset; + /** * A note reference: hex note ID string, NoteId object, InputNoteRecord, or Note object. */ @@ -311,12 +319,14 @@ export type NoteInput = string | NoteId | Note | InputNoteRecord; /** * Create a wallet, faucet, or contract. A faucet sets `type: - * FaucetType.FungibleFaucet`, a contract passes `components`, and a wallet is - * the default (neither). Visibility comes from `storage`. + * FaucetType.FungibleFaucet` or `type: FaucetType.NonFungibleFaucet`, a + * contract passes `components`, and a wallet is the default (neither). + * Visibility comes from `storage`. */ export type CreateAccountOptions = | WalletCreateOptions | FaucetCreateOptions + | NonFungibleFaucetCreateOptions | ContractCreateOptions; export interface WalletCreateOptions { @@ -327,7 +337,7 @@ export interface WalletCreateOptions { export interface FaucetCreateOptions { /** Use `FaucetType.FungibleFaucet`. */ - type: FaucetType; + type: typeof FaucetType.FungibleFaucet; /** Human-readable token name. Defaults to `symbol` when omitted. */ name?: string; symbol: string; @@ -337,6 +347,20 @@ export interface FaucetCreateOptions { auth?: AuthSchemeType; } +/** + * Options for a non-fungible faucet. It mints `NonFungibleAsset`s with + * `transactions.mintNonFungible({ account, to, nonFungibleAsset })`. + */ +export interface NonFungibleFaucetCreateOptions { + /** Use `FaucetType.NonFungibleFaucet`. */ + type: typeof FaucetType.NonFungibleFaucet; + /** Human-readable collection name. Defaults to `symbol` when omitted. */ + name?: string; + symbol: string; + storage?: StorageMode; + auth?: AuthSchemeType; +} + export interface ContractCreateOptions { /** Raw 32-byte seed (Uint8Array). Required. */ seed: Uint8Array; @@ -495,6 +519,30 @@ export interface SendOptionsReturnNote extends TransactionOptions { returnNote: true; } +/** Options for `sendNonFungible`: one non-fungible asset in a P2ID note (P2IDE with `reclaimAfter` / `timelockUntil`). */ +export interface SendNonFungibleOptions extends TransactionOptions { + account: AccountRef; + to: AccountRef; + /** The non-fungible asset to send. */ + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; + returnNote?: false; + /** Block height after which the sender can reclaim the note. This is a block number, not wall-clock time. */ + reclaimAfter?: number; + /** Block height until which the note is timelocked. This is a block number, not wall-clock time. */ + timelockUntil?: number; +} + +/** Options for `sendNonFungible` that return the created P2ID note. */ +export interface SendNonFungibleReturnNoteOptions extends TransactionOptions { + account: AccountRef; + to: AccountRef; + /** The non-fungible asset to send. */ + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; + returnNote: true; +} + /** @deprecated Use SendOptionsDefault or SendOptionsReturnNote instead */ export type SendOptions = SendOptionsDefault | SendOptionsReturnNote; @@ -533,7 +581,7 @@ export interface NetworkNoteOptions extends TransactionOptions { /** Note storage / inputs the script reads (used with `script`). */ inputs?: bigint[]; /** Assets locked into the note. Optional — a note may carry no assets. */ - assets?: Asset | Asset[]; + assets?: TransferAsset | TransferAsset[]; /** Extra attachment payload appended AFTER the required `NetworkAccountTarget`. */ attachment?: bigint[]; } @@ -563,6 +611,29 @@ export interface MintOptions extends TransactionOptions { type?: NoteVisibility; } +/** Options for `mintNonFungible`. */ +export interface MintNonFungibleOptions extends TransactionOptions { + /** Non-fungible faucet (executing account). */ + account: AccountRef; + /** Recipient account. */ + to: AccountRef; + /** + * The asset to mint. It must be issued by `account`; build it with + * `new NonFungibleAsset` or `NonFungibleAsset.fromData`. + */ + nonFungibleAsset: NonFungibleAsset; + /** Note visibility. Defaults to "public". */ + type?: NoteVisibility; +} + +/** Options for `burnNonFungible`. */ +export interface BurnNonFungibleOptions extends TransactionOptions { + /** Account that holds the asset and creates the BURN note. */ + account: AccountRef; + /** The non-fungible asset to burn. Its faucet burns it when it consumes the note. */ + nonFungibleAsset: NonFungibleAsset; +} + export interface BridgeOptions extends TransactionOptions { /** Account that creates and funds the bridge note (the sender / executing account). */ account: AccountRef; @@ -608,6 +679,15 @@ export type BatchOperation = reclaimAfter?: number; timelockUntil?: number; } + | { + kind: "sendNonFungible"; + account: AccountRef; + to: AccountRef; + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; + reclaimAfter?: number; + timelockUntil?: number; + } | { kind: "mint"; account: AccountRef; @@ -615,6 +695,18 @@ export type BatchOperation = amount: number | bigint; type?: NoteVisibility; } + | { + kind: "mintNonFungible"; + account: AccountRef; + to: AccountRef; + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; + } + | { + kind: "burnNonFungible"; + account: AccountRef; + nonFungibleAsset: NonFungibleAsset; + } | { kind: "consume"; account: AccountRef; @@ -664,7 +756,6 @@ export interface BatchOptions { timeout?: number; } - export interface BatchSubmitResult { /** * The node's chain tip as of submission — not the block the batch commits @@ -859,6 +950,30 @@ export interface PreviewMintOptions { type?: NoteVisibility; } +export interface PreviewSendNonFungibleOptions { + operation: "sendNonFungible"; + account: AccountRef; + to: AccountRef; + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; + reclaimAfter?: number; + timelockUntil?: number; +} + +export interface PreviewMintNonFungibleOptions { + operation: "mintNonFungible"; + account: AccountRef; + to: AccountRef; + nonFungibleAsset: NonFungibleAsset; + type?: NoteVisibility; +} + +export interface PreviewBurnNonFungibleOptions { + operation: "burnNonFungible"; + account: AccountRef; + nonFungibleAsset: NonFungibleAsset; +} + export interface PreviewBridgeOptions { operation: "bridge"; account: AccountRef; @@ -915,7 +1030,10 @@ export interface PreviewCustomOptions extends AnchoredOptions { export type PreviewOptions = | PreviewSendOptions + | PreviewSendNonFungibleOptions | PreviewMintOptions + | PreviewMintNonFungibleOptions + | PreviewBurnNonFungibleOptions | PreviewBridgeOptions | PreviewConsumeOptions | PreviewSwapOptions @@ -1163,9 +1281,10 @@ export interface AccountsResource { */ addAddress(accountId: AccountRef, address: string): Promise; /** - * Remove a Bech32 address from an account. + * Remove a Bech32 address from the account it belongs to. * - * @param accountId - The account to remove the address from. + * @param accountId - Not used: the address identifies its account. Kept so + * existing calls keep working. * @param address - The Bech32 address string to remove. */ removeAddress(accountId: AccountRef, address: string): Promise; @@ -1185,12 +1304,47 @@ export interface TransactionsResource { options: SendOptionsReturnNote ): Promise<{ txId: TransactionId; note: Note; result: TransactionResult }>; send(options: SendOptions): Promise; + /** + * Send one non-fungible asset to another account by creating a pay-to-ID + * note. Set `returnNote: true` to get the created note back. + * + * @param options - Send options including sender, recipient, and the non-fungible asset. + */ + sendNonFungible( + options: SendNonFungibleOptions + ): Promise<{ txId: TransactionId; note: null; result: TransactionResult }>; + sendNonFungible( + options: SendNonFungibleReturnNoteOptions + ): Promise<{ txId: TransactionId; note: Note; result: TransactionResult }>; /** * Mint new tokens from a faucet account. * * @param options - Mint options including the faucet, recipient, and amount. */ mint(options: MintOptions): Promise; + /** + * Mint one non-fungible asset from a non-fungible faucet. The asset must be + * issued by `account`. + * + * @param options - Mint options including the faucet, recipient, and the non-fungible asset. + */ + mintNonFungible( + options: MintNonFungibleOptions + ): Promise; + /** + * Send one non-fungible asset back to its faucet in a public BURN note. The + * asset is burned when the faucet that issued it consumes the note, for + * example with `consume({ account: faucet, notes })`. + * + * A BURN note for a public faucet is a network note targeted at that faucet, + * so a public faucet must be a network account. Private faucets are not + * affected. + * + * @param options - The holding account and the non-fungible asset. + */ + burnNonFungible( + options: BurnNonFungibleOptions + ): Promise; /** * Builds a Public custom-script note carrying a `NetworkAccountTarget` * attachment, submits it as an own output note, and (optionally) waits for diff --git a/crates/web-client/skills/web-client-usage/SKILL.md b/crates/web-client/skills/web-client-usage/SKILL.md index d1b1344d..ccbb31ca 100644 --- a/crates/web-client/skills/web-client-usage/SKILL.md +++ b/crates/web-client/skills/web-client-usage/SKILL.md @@ -309,6 +309,7 @@ AccountType.Public; // 1 // accounts.create({ type }) selects a faucet: FaucetType.FungibleFaucet; // "FungibleFaucet" +FaucetType.NonFungibleFaucet; // "NonFungibleFaucet" AuthScheme.Falcon; // default - Falcon-512 over Poseidon2 AuthScheme.ECDSA; // EcdsaK256Keccak @@ -323,14 +324,16 @@ separate enum exported for the low-level WASM APIs and is easy to confuse with `AuthScheme.Falcon` for the Poseidon2-based Falcon-512 scheme. `AccountType.Private` / `AccountType.Public` select visibility in -`AccountBuilder.accountType()`. `FaucetType.FungibleFaucet` selects a fungible -faucet in `accounts.create({ type })`; its value is a string, so it cannot be -mistaken for an `AccountType` value. Replace older `AccountType.FungibleFaucet` -references with `FaucetType.FungibleFaucet`: `create()` throws a `TypeError` -for any unrecognised `type`, for faucet fields (`name`, `symbol`, `decimals`, -`maxSupply`) without a faucet type, for `components` on a faucet, and for a -faucet missing `symbol`, `decimals` or `maxSupply`. The legacy `0`, `1` and -`"NonFungibleFaucet"` are still read as faucet types, and `0` / `1` are also +`AccountBuilder.accountType()`. `FaucetType.FungibleFaucet` and +`FaucetType.NonFungibleFaucet` select a faucet in `accounts.create({ type })`; +their values are strings, so they cannot be mistaken for an `AccountType` +value. Replace older `AccountType.FungibleFaucet` references with +`FaucetType.FungibleFaucet`: `create()` throws a `TypeError` for any +unrecognised `type`, for faucet fields (`name`, `symbol`, `decimals`, +`maxSupply`) without a faucet type, for `components` on a faucet, for a fungible +faucet missing `symbol`, `decimals` or `maxSupply`, and for a non-fungible +faucet missing `symbol` or given `decimals` or `maxSupply`. The legacy `0` +(fungible) and `1` (non-fungible) are still read as faucet types, and are also `AccountType.Private` / `Public`, so never pass a visibility value as `type`. Neither enum has wallet or contract members. Omit `type` for a wallet, or pass @@ -340,9 +343,6 @@ are also accepted). Use `storage` to select visibility in `accounts.create()`. `StorageMode` has only `Public`/`Private`. There is no `StorageMode.Network` (accessing it yields `undefined`, which silently resolves to private). -Non-fungible faucets are not supported yet, so `FaucetType` has no -non-fungible member. - ## Read Non-Fungible Assets ```typescript @@ -384,7 +384,7 @@ For registry publishing, use `Note.withAttachments()` with a single name asset, public metadata, the registry's approved script and inputs, and `[new NetworkAccountTarget(registryId).toAttachment()]`. The registry account must be public. A tag alone does not make a network note. Consume the returned P2ID note to put -the asset back in the vault. The amount-based `send` helper remains fungible-only. +the asset back in the vault. ## Account Creation diff --git a/crates/web-client/test/eager_entry.test.ts b/crates/web-client/test/eager_entry.test.ts index b4ed7c4b..59b32b13 100644 --- a/crates/web-client/test/eager_entry.test.ts +++ b/crates/web-client/test/eager_entry.test.ts @@ -106,7 +106,10 @@ for (const entry of ["./eager.js", "./index.js"]) { }, entry); expect(result.accepted).toEqual([0, 1]); expect(result.native).toBe(true); - expect(result.faucetType).toEqual({ FungibleFaucet: "FungibleFaucet" }); + expect(result.faucetType).toEqual({ + FungibleFaucet: "FungibleFaucet", + NonFungibleFaucet: "NonFungibleFaucet", + }); } ); } diff --git a/crates/web-client/test/miden_client_api.test.ts b/crates/web-client/test/miden_client_api.test.ts index 5df22f85..7564f88b 100644 --- a/crates/web-client/test/miden_client_api.test.ts +++ b/crates/web-client/test/miden_client_api.test.ts @@ -73,6 +73,83 @@ mockTest.describe("MidenClient API - Mock Chain", () => { } ); + mockTest( + "non-fungible flow: create NFT faucet, mintNonFungible, sendNonFungible and burnNonFungible", + async ({ page }) => { + const result = await page.evaluate(async () => { + const client = await window.MidenClient.createMock(); + const alice = await client.accounts.create(); + const bob = await client.accounts.create(); + const faucet = await client.accounts.create({ + type: window.FaucetType.NonFungibleFaucet, + storage: "private", + name: "Collectible", + symbol: "NFT", + }); + const value = new window.Word(new BigUint64Array([1n, 2n, 3n, 4n])); + const asset = new window.NonFungibleAsset(faucet.id(), value); + + const consumeOutput = async (account, txId) => { + client.proveBlock(); + await client.sync(); + const [record] = await client.transactions.list({ + ids: [txId.toHex()], + }); + const noteId = record.outputNotes().notes()[0].id().toString(); + const { txId: consumeTxId } = await client.transactions.consume({ + account, + notes: noteId, + }); + client.proveBlock(); + await client.sync(); + return consumeTxId; + }; + const nfts = async (account) => + (await client.accounts.get(account)) + .vault() + .nonFungibleAssets() + .map((nft) => nft.intoWord().toHex()); + + const { txId: mintTxId } = await client.transactions.mintNonFungible({ + account: faucet, + to: alice, + nonFungibleAsset: asset, + }); + await consumeOutput(alice, mintTxId); + const aliceAfterMint = await nfts(alice); + + const { txId: sendTxId } = await client.transactions.sendNonFungible({ + account: alice, + to: bob, + nonFungibleAsset: asset, + }); + await consumeOutput(bob, sendTxId); + const bobAfterSend = await nfts(bob); + + const { txId: burnTxId } = await client.transactions.burnNonFungible({ + account: bob, + nonFungibleAsset: asset, + }); + await consumeOutput(faucet, burnTxId); + + return { + isNonFungibleFaucet: faucet.isNonFungibleFaucet(), + expected: value.toHex(), + aliceAfterMint, + aliceAfterSend: await nfts(alice), + bobAfterSend, + bobAfterBurn: await nfts(bob), + }; + }); + + expect(result.isNonFungibleFaucet).toBe(true); + expect(result.aliceAfterMint).toEqual([result.expected]); + expect(result.aliceAfterSend).toEqual([]); + expect(result.bobAfterSend).toEqual([result.expected]); + expect(result.bobAfterBurn).toEqual([]); + } + ); + mockTest( "endpoint: a mock client reports none, an RpcClient its own", async ({ page }) => { diff --git a/docs/external/src/web-client/library/accounts.md b/docs/external/src/web-client/library/accounts.md index 6150fc57..90d0913a 100644 --- a/docs/external/src/web-client/library/accounts.md +++ b/docs/external/src/web-client/library/accounts.md @@ -26,6 +26,10 @@ const faucet = await client.accounts.create({ decimals: 8, maxSupply: 10_000_000n, }); +const collection = await client.accounts.create({ + type: FaucetType.NonFungibleFaucet, + symbol: "NFT", +}); ``` `FaucetType.FungibleFaucet` is the string `"FungibleFaucet"` on both runtimes, @@ -34,13 +38,18 @@ so it cannot be mistaken for an `AccountType` value. Migrate older enum has wallet or contract members: omit `type` for a wallet, or pass `components` for a contract, and choose visibility with `storage` only. -`accounts.create()` still reads the legacy selectors `0`, `1` and -`"NonFungibleFaucet"` as faucet types (`0` fungible; the non-fungible ones are -rejected, since non-fungible faucets are not supported yet). `0` and `1` are -also the values of `AccountType.Private` and `AccountType.Public`, so never pass -a visibility value as `type`: it is read as a legacy faucet selector. It throws -a `TypeError` for any other `type`, for faucet fields (`name`, `symbol`, -`decimals`, `maxSupply`) without a faucet type, for `components` on a faucet, -and for a faucet missing `symbol`, `decimals` or `maxSupply`. +`FaucetType.NonFungibleFaucet` is the string `"NonFungibleFaucet"`. A +non-fungible faucet takes `symbol` and an optional `name`, and no `decimals` or +`maxSupply`. See [non-fungible assets](./non-fungible-assets.md) to mint and +transfer its assets. + +`accounts.create()` still reads the legacy selectors `0` (fungible) and `1` +(non-fungible) as faucet types. They are also the values of +`AccountType.Private` and `AccountType.Public`, so never pass a visibility value +as `type`: it is read as a legacy faucet selector. It throws a `TypeError` for +any other `type`, for faucet fields (`name`, `symbol`, `decimals`, `maxSupply`) +without a faucet type, for `components` on a faucet, for a fungible faucet +missing `symbol`, `decimals` or `maxSupply`, for a non-fungible faucet missing +`symbol`, and for a non-fungible faucet given `decimals` or `maxSupply`. Wallets default to private storage; faucets and contracts default to public. diff --git a/docs/external/src/web-client/library/non-fungible-assets.md b/docs/external/src/web-client/library/non-fungible-assets.md index f1c2607b..8e7cc890 100644 --- a/docs/external/src/web-client/library/non-fungible-assets.md +++ b/docs/external/src/web-client/library/non-fungible-assets.md @@ -4,6 +4,58 @@ title: Read and send non-fungible assets # Read and send non-fungible assets +## Create a faucet, mint and send + +```typescript +import { FaucetType, NonFungibleAsset, Word } from "@miden-sdk/miden-sdk"; + +const collection = await client.accounts.create({ + type: FaucetType.NonFungibleFaucet, + name: "Collectible", + symbol: "NFT", +}); + +// The value word commits to the NFT's data and a random salt. Keep both to show +// later which data the NFT refers to; the faucet only sees the commitment. +const data = new TextEncoder().encode("ipfs://bafy.../1.json"); +const salt = new Word( + BigUint64Array.from(crypto.getRandomValues(new Uint32Array(4)), BigInt) +); +const nft = NonFungibleAsset.fromData(collection.id(), data, salt); + +await client.transactions.mintNonFungible({ account: collection, to: alice, nonFungibleAsset: nft }); +// After Alice consumes the P2ID note: +await client.transactions.sendNonFungible({ account: alice, to: bob, nonFungibleAsset: nft }); +// Bob sends it back to the faucet in a BURN note; the faucet burns it when it +// consumes the note. +const { txId } = await client.transactions.burnNonFungible({ account: bob, nonFungibleAsset: nft }); +``` + +A non-fungible faucet takes `symbol` and an optional `name`; it has no +`decimals` or `maxSupply`, and passing either throws. +`NonFungibleFaucetComponent.fromAccount(account)` reads the collection's +`symbol()`, `tokenName()` and optional `description()`, `logoUri()` and +`contractUri()` back. `NonFungibleAsset.fromData(faucetId, data, salt)` builds +the asset from its data; `new NonFungibleAsset(faucetId, value)` takes a value +word you already have. The first two elements of the value word are the token +ID the faucet records as issued, so two different NFTs from one faucet must not +share them. +`Account.isNonFungibleFaucet()` is true for it and `Account.isFaucet()` is +false. `mintNonFungible` requires that `account` is the faucet that +issued the asset. A faucet issues each token ID once; minting the same token ID +again fails. `sendNonFungible` takes the same `type`, `reclaimAfter`, +`timelockUntil` and `returnNote` options as a fungible send. `burnNonFungible` +creates a public BURN note; the asset is burned only when the faucet that issued +it consumes the note, for example with +`client.transactions.consume({ account: collection, notes })`. A BURN note for +a public faucet is a network note targeted at that faucet, so a public faucet +must be a network account; private faucets are not affected. `createNetworkNote` +accepts a `NonFungibleAsset` in `assets`. `batch()` and `preview()` take the +`sendNonFungible`, `mintNonFungible` and `burnNonFungible` operations. Swap, PSWAP and bridge remain +fungible-only. + +## Read owned assets + Read owned non-fungible assets after you restore and import an account with its complete vault. Local registration history is not required. @@ -83,8 +135,8 @@ allow the note script. Use this note with `TransactionRequestBuilder.withOwnOutputNotes()` and the custom transaction flow in [Transactions](./transactions.md). The SDK does not supply or validate a registry-specific script or its allowlist. Use the -contract's current script and input schema. The amount-based `send` helper -remains a fungible-token API. +contract's current script and input schema. For a plain transfer without a +registry script, use `client.transactions.sendNonFungible({ account, to, nonFungibleAsset })`. When the registry returns the NFA in a P2ID note, consume that note through the normal note-consumption flow. The asset then appears in the owner's