Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

* [FEATURE][rust,cli,web] Added `get_network_note_status` to `NodeRpcClient` trait for querying the processing status of notes submitted to the network (pending, nullifier-inflight, discarded, nullifier-committed), along with attempt count and error details. Exposed as `miden-client network-note-status <note_id>` CLI command and `RpcClient.getNetworkNoteStatus()` in the web client. ([#1981](https://github.com/0xMiden/miden-client/pull/1981))
* [FEATURE][web,react] Added partial-swap (PSWAP) support: `transactions.pswapCreate / pswapConsume / pswapCancel` on `MidenClient` (and matching `preview()` operations) plus three React hooks `usePswapCreate`, `usePswapConsume`, `usePswapCancel`. PSWAP notes can be filled by multiple consumers; each fill emits a payback note to the creator and, on a partial fill, a remainder PSWAP note carrying the unfilled amount. ([#159](https://github.com/0xMiden/web-sdk/pull/159)).
* [FEATURE][web] `compile.component({ code, slots?, supportAllTypes?, libraries? })` now accepts `libraries` to link dependency modules (e.g. auth libraries) into an account component, filling the gap that previously forced consumers onto the low-level `WasmWebClient.createCodeBuilder()`. Modules are static-linked via `linkModule`, so the compiled component (and therefore the account's code commitment) is identical to building it off a raw code builder. ([#170](https://github.com/0xMiden/web-sdk/pull/170)).
* [FEATURE][web] Added `notes.listConsumable({ account? })` to `MidenClient`, returning `ConsumableNoteRecord[]` with consumability metadata preserved. Unlike `notes.listAvailable`, callers can read `noteConsumability()` to distinguish notes consumable now from block-locked ones (status `consumableAfterBlock`). ([#170](https://github.com/0xMiden/web-sdk/pull/170)).

### Changes

Expand Down
64 changes: 64 additions & 0 deletions crates/web-client/js/__tests__/resources/compiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function makeBuilder() {
compileTxScript: vi.fn(),
compileNoteScript: vi.fn(),
buildLibrary: vi.fn(),
linkModule: vi.fn(),
linkStaticLibrary: vi.fn(),
linkDynamicLibrary: vi.fn(),
};
Expand Down Expand Up @@ -108,6 +109,69 @@ describe("CompilerResource", () => {
// Should not throw even without client
await resource.component({ code: "code" });
});

it("static-links dependency modules via linkModule before compiling", async () => {
builder.compileAccountComponentCode.mockReturnValue("compiled");
const resource = new CompilerResource(inner, getWasm, client);
await resource.component({
code: "component code",
slots: [],
libraries: [
{ namespace: "oz::auth::guardian", code: "guardian masm" },
{ namespace: "oz::auth::multisig", code: "multisig masm" },
],
});
// Modules are linked as source (linkModule), not via buildLibrary, so the
// compiled component is identical to building it off a raw code builder.
expect(builder.linkModule).toHaveBeenNthCalledWith(
1,
"oz::auth::guardian",
"guardian masm"
);
expect(builder.linkModule).toHaveBeenNthCalledWith(
2,
"oz::auth::multisig",
"multisig masm"
);
expect(builder.buildLibrary).not.toHaveBeenCalled();
expect(builder.compileAccountComponentCode).toHaveBeenCalledWith(
"component code"
);
});

it("compiles with no libraries without calling linkModule", async () => {
builder.compileAccountComponentCode.mockReturnValue("compiled");
const resource = new CompilerResource(inner, getWasm, client);
await resource.component({ code: "code", libraries: [] });
expect(builder.linkModule).not.toHaveBeenCalled();
expect(builder.compileAccountComponentCode).toHaveBeenCalledWith("code");
});

it("throws a descriptive error for a malformed library entry", async () => {
const resource = new CompilerResource(inner, getWasm, client);
await expect(
resource.component({
code: "code",
libraries: [{ namespace: "oz::auth", code: "masm" }, { code: "x" }],
})
).rejects.toThrow(/libraries\[1\]/);
// Must fail before producing a component, not link a bad entry.
expect(builder.compileAccountComponentCode).not.toHaveBeenCalled();
});

it("propagates linkModule errors (e.g. duplicate namespace)", async () => {
builder.linkModule.mockImplementation(() => {
throw new Error("DuplicateModule");
});
const resource = new CompilerResource(inner, getWasm, client);
await expect(
resource.component({
code: "code",
libraries: [{ namespace: "oz::auth", code: "masm" }],
})
).rejects.toThrow(/DuplicateModule/);
expect(builder.compileAccountComponentCode).not.toHaveBeenCalled();
});
});

describe("txScript", () => {
Expand Down
38 changes: 38 additions & 0 deletions crates/web-client/js/__tests__/resources/notes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,44 @@ describe("NotesResource", () => {
});
});

describe("listConsumable", () => {
it("returns the consumable records unmapped (consumability preserved)", async () => {
const record = {
inputNoteRecord: vi.fn(),
noteConsumability: vi.fn(),
};
inner.getConsumableNotes.mockResolvedValue([record]);
const resource = makeResource();
const result = await resource.listConsumable({ account: "0xacc" });
expect(client.assertNotTerminated).toHaveBeenCalledOnce();
// Unlike listAvailable, the record itself is returned so callers can read
// noteConsumability() — it must not be mapped to inputNoteRecord().
expect(result).toEqual([record]);
expect(record.inputNoteRecord).not.toHaveBeenCalled();
});

it("passes undefined to getConsumableNotes when account is omitted", async () => {
inner.getConsumableNotes.mockResolvedValue([]);
const resource = makeResource();
await resource.listConsumable();
expect(inner.getConsumableNotes).toHaveBeenCalledWith(undefined);
});

it("treats null account as 'all accounts' (matches underlying API)", async () => {
inner.getConsumableNotes.mockResolvedValue([]);
const resource = makeResource();
await resource.listConsumable({ account: null });
expect(inner.getConsumableNotes).toHaveBeenCalledWith(undefined);
});

it("resolves the account ref when provided", async () => {
inner.getConsumableNotes.mockResolvedValue([]);
const resource = makeResource();
await resource.listConsumable({ account: "mBech32Account" });
expect(wasm.AccountId.fromBech32).toHaveBeenCalledWith("mBech32Account");
});
});

describe("import", () => {
it("delegates to inner.importNoteFile", async () => {
inner.importNoteFile.mockResolvedValue("imported");
Expand Down
25 changes: 23 additions & 2 deletions crates/web-client/js/resources/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,34 @@ export class CompilerResource {
/**
* Compiles MASM code + slots into an AccountComponent ready for accounts.create().
*
* @param {{ code: string, slots: StorageSlot[], supportAllTypes?: boolean }} opts
* Dependency modules the component imports (e.g. auth libraries) are linked
* via `libraries` before compilation. Each entry is statically linked as a
* source module with `linkModule`, so the compiled component — and therefore
* the account's code commitment — is identical to building it directly off a
* `createCodeBuilder()`. This matters: changing the link path would change the
* MAST and break accounts already created with the original component. Two
* entries sharing a `namespace` cause a link error.
*
* @param {{ code: string, slots?: StorageSlot[], supportAllTypes?: boolean, libraries?: Array<{ namespace: string, code: string }> }} opts
* @returns {Promise<AccountComponent>}
*/
async component({ code, slots = [], supportAllTypes = true }) {
async component({
code,
slots = [],
supportAllTypes = true,
libraries = [],
}) {
this.#client?.assertNotTerminated();
const wasm = await this.#getWasm();
const builder = await this.#inner.createCodeBuilder();
libraries.forEach((lib, i) => {
if (typeof lib?.namespace !== "string" || typeof lib?.code !== "string") {
throw new TypeError(
`compile.component: libraries[${i}] must be { namespace: string, code: string }`
);
}
builder.linkModule(lib.namespace, lib.code);
});
const compiled = builder.compileAccountComponentCode(code);
const component = wasm.AccountComponent.compile(compiled, slots);
return supportAllTypes ? component.withSupportsAllTypes() : component;
Expand Down
14 changes: 14 additions & 0 deletions crates/web-client/js/resources/notes.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export class NotesResource {
return consumable.map((c) => c.inputNoteRecord());
}

// Like `listAvailable`, but keeps each note's consumability metadata
// (`noteConsumability()`) instead of mapping it away. Callers that must
// distinguish consumable-now from block-locked notes (status
// `consumableAfterBlock`) need this; `listAvailable` cannot express it.
// Omit `account` (or pass null) to list notes consumable by any tracked
// account — matching the underlying `getConsumableNotes(account?)`.
async listConsumable(opts) {
this.#client.assertNotTerminated();
const wasm = await this.#getWasm();
const accountId =
opts?.account == null ? undefined : resolveAccountRef(opts.account, wasm);
return await this.#inner.getConsumableNotes(accountId);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exposes the metadata, but I think it leaves the confusing part of the API unchanged.

listAvailable() still calls getConsumableNotes() and then drops the result of noteConsumability(). Since getConsumableNotes() can return notes that are only ConsumableAfter, “available” can still mean “not actually consumable yet”. listConsumable() gives callers a way around that, but existing users of listAvailable() can still get block-locked notes from an API name that suggests they are usable now.

Should we fix the existing surface instead? For example, listAvailable() could return only notes that are consumable now, and we could add a separate metadata-preserving method with a more precise name. Alternatively, we could deprecate or rename the current behavior so the semantics are explicit.


async import(noteFile) {
this.#client.assertNotTerminated();
return await this.#inner.importNoteFile(noteFile);
Expand Down
27 changes: 27 additions & 0 deletions crates/web-client/js/types/api-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TransactionRecord,
InputNoteRecord,
OutputNoteRecord,
ConsumableNoteRecord,
NoteId,
NoteFile,
NoteTag,
Expand Down Expand Up @@ -826,6 +827,17 @@ export interface NotesResource {
*/
listAvailable(options: { account: AccountRef }): Promise<InputNoteRecord[]>;

/**
* List notes consumable by an account, keeping each note's consumability
* metadata. Unlike {@link NotesResource.listAvailable}, the returned records
* expose `noteConsumability()`, so callers can distinguish notes consumable
* now from block-locked ones (status `consumableAfterBlock`).
*
* @param options - Optional account to check; omit to list notes consumable
* by any tracked account.
*/
listConsumable(options?: { account?: AccountRef }): Promise<ConsumableNoteRecord[]>;

/**
* Import a note from a {@link NoteFile}.
*
Expand Down Expand Up @@ -873,6 +885,21 @@ export interface CompileComponentOptions {
* auth transaction kernel invocation or intentionally omits one.
*/
supportAllTypes?: boolean;
/**
* Dependency modules the component imports (e.g. auth libraries), linked as
* source modules before compilation. Components always static-link their
* dependencies (so a component's MAST stays stable), hence no `linking`
* option here. Two entries sharing a `namespace` cause a link error.
*/
libraries?: ComponentLibrary[];
}

/** A dependency module linked into an account component (always static). */
export interface ComponentLibrary {
/** MASM namespace for the module (e.g. "openzeppelin::auth::multisig"). */
namespace: string;
/** MASM source code for the module. */
code: string;
}

export interface CompileTxScriptLibrary {
Expand Down
58 changes: 58 additions & 0 deletions crates/web-client/test/compile_and_contract.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,64 @@ test.describe("compile.component()", () => {
expect(compA.getProcedureHash("get_count")).not.toBeNull();
expect(compB.getProcedureHash("get_count")).not.toBeNull();
});

test("linking a dependency module matches the raw createCodeBuilder().linkModule() path", async ({
sdk,
}) => {
// A component's compiled MAST determines the account's code commitment, which
// is baked into the account ID. `compile.component({ libraries })` must produce
// a byte-identical component to building it off a raw code builder with
// `linkModule` — otherwise enabling library linking would silently change the
// commitment and break accounts already created the manual way (e.g. the
// OpenZeppelin multisig auth component).
const MidenClient = await createMidenClient(sdk);
test.skip(!MidenClient, "requires napi binary (Node.js only)");

const NS = "external_contract::counter_contract";
// Component imports the linked module and inlines one of its procedures,
// so compilation fails unless the dependency is linked.
const componentCode = `
use external_contract::counter_contract
use miden::core::sys

pub proc wrapped_increment
exec.counter_contract::increment_count
exec.sys::truncate_stack
end
`;
// Fresh slots per path: wasm-bindgen moves/frees StorageSlot handles
// when AccountComponent.compile consumes them, so an array can't be
// reused for a second compilation.
const makeSlots = () => [sdk.StorageSlot.emptyValue(COUNTER_SLOT_NAME)];

const digests = (component) =>
component
.getProcedures()
.map((p) => p.digest.toHex())
.sort();

// High-level path: compile.component with inline library linking.
const client = await MidenClient.createMock();
const viaResource = await client.compile.component({
code: componentCode,
slots: makeSlots(),
libraries: [{ namespace: NS, code: COUNTER_CODE }],
});

// Raw path: exactly what the multisig client does today.
const raw = await MidenClient._MockWasmWebClient.createClient();
const builder = await raw.createCodeBuilder();
builder.linkModule(NS, COUNTER_CODE);
const compiledCode = builder.compileAccountComponentCode(componentCode);
const viaRaw = sdk.AccountComponent.compile(
compiledCode,
makeSlots()
).withSupportsAllTypes();

const resourceDigests = digests(viaResource);
expect(resourceDigests.length).toBeGreaterThan(0);
expect(resourceDigests).toEqual(digests(viaRaw));
});
});

// ════════════════════════════════════════════════════════════════
Expand Down
64 changes: 64 additions & 0 deletions crates/web-client/test/compile_and_contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,70 @@ test.describe("compile.component()", () => {
expect(result.aHasProc).toBe(true);
expect(result.bHasProc).toBe(true);
});

test("linking a dependency module matches the raw createCodeBuilder().linkModule() path", async ({
page,
}) => {
// A component's compiled MAST determines the account's code commitment, which
// is baked into the account ID. `compile.component({ libraries })` must produce
// a byte-identical component to building it off a raw code builder with
// `linkModule` — otherwise enabling library linking would silently change the
// commitment and break accounts already created the manual way (e.g. the
// OpenZeppelin multisig auth component).
const result = await page.evaluate(
async ({ counterCode, slotName }) => {
const NS = "external_contract::counter_contract";
// Component imports the linked module and inlines one of its procedures,
// so compilation fails unless the dependency is linked.
const componentCode = `
use external_contract::counter_contract
use miden::core::sys

pub proc wrapped_increment
exec.counter_contract::increment_count
exec.sys::truncate_stack
end
`;
// Fresh slots per path: wasm-bindgen moves/frees StorageSlot handles
// when AccountComponent.compile consumes them, so an array can't be
// reused for a second compilation.
const makeSlots = () => [window.StorageSlot.emptyValue(slotName)];

const digests = (component) =>
component
.getProcedures()
.map((p) => p.digest.toHex())
.sort();

// High-level path: compile.component with inline library linking.
const client = await window.MidenClient.createMock();
const viaResource = await client.compile.component({
code: componentCode,
slots: makeSlots(),
libraries: [{ namespace: NS, code: counterCode }],
});

// Raw path: exactly what the multisig client does today.
const raw = await window.MockWasmWebClient.createClient();
const builder = await raw.createCodeBuilder();
builder.linkModule(NS, counterCode);
const compiledCode = builder.compileAccountComponentCode(componentCode);
const viaRaw = window.AccountComponent.compile(
compiledCode,
makeSlots()
).withSupportsAllTypes();

return {
resourceDigests: digests(viaResource),
rawDigests: digests(viaRaw),
};
},
{ counterCode: COUNTER_CODE, slotName: COUNTER_SLOT_NAME }
);

expect(result.resourceDigests.length).toBeGreaterThan(0);
expect(result.resourceDigests).toEqual(result.rawDigests);
});
});

// ════════════════════════════════════════════════════════════════
Expand Down
Loading