Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
10 changes: 5 additions & 5 deletions crates/web-client/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
20 changes: 11 additions & 9 deletions crates/web-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions crates/web-client/js/__tests__/account-type-declarations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions crates/web-client/js/__tests__/account-type-exports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
24 changes: 20 additions & 4 deletions crates/web-client/js/__tests__/resources/accounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,6 @@ describe("AccountsResource", () => {
auth: "falcon",
name: "Collectible",
symbol: "NFT",
decimals: 0,
maxSupply: 1,
});
expect(inner.newNonFungibleFaucet).toHaveBeenCalledWith(
"public",
Expand All @@ -154,8 +152,6 @@ describe("AccountsResource", () => {
auth: "falcon",
name: "Collectible",
symbol: "NFT",
decimals: 0,
maxSupply: 1,
});
expect(inner.newNonFungibleFaucet).toHaveBeenCalledWith(
"public",
Expand All @@ -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({
Expand Down
194 changes: 194 additions & 0 deletions crates/web-client/js/__tests__/resources/transactions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading