miden-client-web 0.17.0

Web Client library that facilitates interaction with the Miden network
/**
 * NAPI compatibility layer.
 *
 * Normalizes differences between the napi (Node.js) and wasm-bindgen (browser)
 * SDK surfaces so the shared MidenClient wrapper works on both platforms.
 *
 * Key normalizations:
 * - Uint8Array/Buffer -> Array for Vec<u8> parameters (e.g. RNG seeds)
 * - deserialize args pass through: napi's JsBytes accepts any Uint8Array view
 * - BigUint64Array/BigInt64Array -> Array (napi's Vec<u64>/Vec<BigInt> expects plain arrays)
 * - null -> undefined (napi returns null for Option::None, wasm-bindgen returns undefined)
 * - camelCase -> snake_case aliases (napi uses camelCase, wasm-bindgen uses snake_case)
 * - Array type polyfills (browser has typed WASM arrays, napi accepts plain JS arrays)
 */

// ── Argument normalization ───────────────────────────────────────────

/**
 * Normalizes a single argument for napi compatibility.
 *
 * `BigInt` values are passed through untouched — napi-rs accepts JS `BigInt`
 * for `u64` parameters (via `napi::bindgen_prelude::BigInt`), so no conversion
 * is needed. Typed arrays of BigInts and bytes are converted to plain arrays.
 */
export function normalizeArg(val) {
  if (val instanceof BigUint64Array) return Array.from(val);
  if (val instanceof BigInt64Array) return Array.from(val);
  if (val instanceof Uint8Array || Buffer.isBuffer(val)) return Array.from(val);
  return val;
}

// ── Class wrapping ───────────────────────────────────────────────────

/**
 * Wraps a napi class so constructor and static method args are normalized,
 * except `deserialize`, whose JsBytes argument passes through unchanged.
 */
export function wrapClass(Cls) {
  if (!Cls) return Cls;
  const Wrapper = function (...args) {
    return new Cls(...args.map(normalizeArg));
  };
  Wrapper.prototype = Cls.prototype;
  for (const key of Object.getOwnPropertyNames(Cls)) {
    if (key === "prototype" || key === "length" || key === "name") continue;
    const desc = Object.getOwnPropertyDescriptor(Cls, key);
    if (desc && typeof desc.value === "function") {
      // deserialize takes JsBytes, which reads a Buffer or any Uint8Array view
      // at its own offset; flattening it to an Array (as for Vec<u8>) fails.
      Wrapper[key] =
        key === "deserialize"
          ? desc.value.bind(Cls)
          : (...args) => desc.value.apply(Cls, args.map(normalizeArg));
    } else if (desc) {
      try {
        Object.defineProperty(Wrapper, key, desc);
      } catch {
        /* skip non-configurable */
      }
    }
  }
  return Wrapper;
}

// ── Client wrapping ──────────────────────────────────────────────────

/**
 * Wraps a raw napi WebClient to normalize API differences with the browser SDK.
 *
 * - syncState() -> syncStateImpl() (no browser lock coordination needed)
 * - syncChain() -> syncChainImpl()
 * - syncNoteTransport() -> syncNoteTransportImpl()
 * - null -> undefined for Option<T> returns
 * - BigInt/Uint8Array args normalized
 */
export function wrapClient(rawClient, storeName) {
  return new Proxy(rawClient, {
    get(target, prop) {
      if (prop === "syncState") {
        return (...args) => target.syncStateImpl(...args);
      }
      if (prop === "syncChain") {
        return () => target.syncChainImpl();
      }
      if (prop === "syncNoteTransport") {
        return () => target.syncNoteTransportImpl();
      }
      if (prop === "storeName") {
        return storeName || "default";
      }
      if (prop === "wasmWebClient") {
        return target;
      }
      if (prop === "storeIdentifier") {
        return () => target.storeIdentifier?.() ?? storeName ?? "unknown";
      }
      // terminate is a no-op on Node.js (no Web Worker to terminate)
      if (prop === "terminate") {
        return () => {};
      }
      // onStateChanged is browser-only (uses BroadcastChannel)
      if (prop === "onStateChanged") {
        return () => undefined;
      }
      // waitForIdle drains the browser SDK's detached `_serializeWasmCall`
      // chain. The napi binding has no such chain — every call is awaited
      // directly by its caller and serialized inside Rust — so there is
      // nothing in flight by the time a caller could invoke this. Resolve
      // immediately to keep the cross-platform MidenClient surface intact.
      if (prop === "waitForIdle") {
        return () => Promise.resolve();
      }
      // lastAuthError surfaces the raw value a JS sign callback threw.
      // The Node binding signs with FilesystemKeyStore (no JS callback can
      // ever run), so "no sign error" is the semantically correct answer,
      // not a stub.
      if (prop === "lastAuthError") {
        return () => null;
      }
      if (prop === "newWallet") {
        return (mode, authScheme, seed) => {
          const normSeed =
            seed instanceof Uint8Array || Buffer.isBuffer(seed)
              ? Array.from(seed)
              : seed;
          return target
            .newWallet(mode, authScheme, normSeed ?? null)
            .then((v) => (v === null ? undefined : v));
        };
      }
      const val = target[prop];
      if (typeof val === "function") {
        const bound = val.bind(target);
        return (...args) => {
          const result = bound(...args.map(normalizeArg));
          if (result && typeof result.then === "function") {
            return result.then((v) => (v === null ? undefined : v));
          }
          return result === null ? undefined : result;
        };
      }
      return val;
    },
  });
}

// ── Prototype patching ───────────────────────────────────────────────

/**
 * Patches the raw SDK module:
 * - Adds snake_case aliases for camelCase methods
 * - Converts null -> undefined for Option<T> returns
 * - Aliases static methods
 */
function patchSdkPrototypes(rawSdk) {
  // snake_case aliases for instance methods
  /* eslint-disable camelcase */
  for (const [cls, aliases] of [
    [rawSdk.Account, { to_commitment: "toCommitment" }],
    [rawSdk.AccountHeader, { to_commitment: "toCommitment" }],
  ]) {
    if (!cls?.prototype) continue;
    for (const [snake, camel] of Object.entries(aliases)) {
      if (typeof cls.prototype[camel] === "function" && !cls.prototype[snake]) {
        cls.prototype[snake] = cls.prototype[camel];
      }
    }
  }
  /* eslint-enable camelcase */

  // null -> undefined for Option<T> return methods
  for (const [cls, methods] of [
    [rawSdk.AccountPatch, ["finalNonce"]],
    [rawSdk.AccountStorage, ["getItem", "getMapEntries", "getMapItem"]],
    [rawSdk.AdviceMap, ["get", "insert"]],
    // `feeNote` is absent whenever the chain charges nothing, which is the common case on a
    // local chain, so the "no fee note" reading has to be the same on both bindings.
    [rawSdk.ExecutedTransaction, ["feeNote"]],
    [rawSdk.NoteConsumptionStatus, ["consumableAfterBlock"]],
    // `authArg` and `feeConversionSalt` are how a caller checks what a request
    // declared about paying its fee, so they have to read the same on both
    // bindings — the salt tests in `fee_conversion_salt.test.ts` compare with
    // `== null` for exactly this reason.
    [rawSdk.TransactionRequest, ["authArg", "feeConversionSalt", "scriptArg"]],
  ]) {
    if (!cls?.prototype) continue;
    for (const method of methods) {
      const original = cls.prototype[method];
      if (typeof original === "function") {
        cls.prototype[method] = function (...args) {
          const result = original.apply(this, args);
          return result === null ? undefined : result;
        };
      }
    }
  }

  // snake_case aliases for static methods
  if (rawSdk.NoteScript) {
    if (!rawSdk.NoteScript.p2id && rawSdk.NoteScript.p2Id)
      rawSdk.NoteScript.p2id = rawSdk.NoteScript.p2Id;
    if (!rawSdk.NoteScript.p2ide && rawSdk.NoteScript.p2Ide)
      rawSdk.NoteScript.p2ide = rawSdk.NoteScript.p2Ide;
  }
}

// ── Array polyfills ──────────────────────────────────────────────────

/**
 * Array containers declared by `declare_js_miden_arrays!` in src/models/mod.rs.
 * On Node they are JS polyfills, so node-index.js re-exports them from this
 * list (see scripts/gen-node-reexports.js).
 */
export const NODE_ARRAY_TYPES = Object.freeze([
  "AccountArray",
  "AccountIdArray",
  "FeltArray",
  "ForeignAccountArray",
  "NoteAndArgsArray",
  "NoteArray",
  "NoteDetailsAndTagArray",
  "NoteIdAndArgsArray",
  "NoteRecipientArray",
  "OutputNoteArray",
  "StorageSlotArray",
  "TransactionScriptInputPairArray",
]);

/**
 * Creates polyfill constructors for WASM typed array types.
 * napi accepts plain JS arrays directly, but the browser SDK requires
 * typed wrappers (NoteAndArgsArray, FeltArray, etc.). These polyfills
 * let `new sdk.FeltArray([a, b])` work on Node.js by returning a plain array.
 */
function makeArrayPolyfills() {
  function polyfill(items) {
    const arr =
      items === undefined || items === null
        ? []
        : Array.isArray(items)
          ? [...items]
          : [items];
    // Match the browser containers (miden_array.rs), which reject any index
    // outside the array instead of reading undefined or growing it.
    const checkIndex = (i) => {
      if (!Number.isInteger(i) || i < 0 || i >= arr.length) {
        throw new RangeError(
          `out of bounds access -- tried to access at index: ${i} with length ${arr.length}`
        );
      }
    };
    arr.get = (i) => {
      checkIndex(i);
      return arr[i];
    };
    arr.replaceAt = (i, val) => {
      checkIndex(i);
      arr[i] = val;
      return arr;
    };
    // A plain array owns no native memory, but callers written against the
    // wasm-bindgen classes free them.
    arr.free = () => {};
    if (Symbol.dispose) arr[Symbol.dispose] = arr.free;
    return arr;
  }
  return Object.fromEntries(NODE_ARRAY_TYPES.map((name) => [name, polyfill]));
}

// ── SDK wrapper ──────────────────────────────────────────────────────

/**
 * Creates a wrapped SDK module suitable for use with the MidenClient wrapper.
 * Applies all patches and returns an object that can be used as `getWasm()` return value.
 */
export function createSdkWrapper(rawSdk) {
  patchSdkPrototypes(rawSdk);

  return {
    ...rawSdk,
    // Wrap classes whose constructors/static methods accept BigInt or Uint8Array
    AccountBuilder: wrapClass(rawSdk.AccountBuilder),
    AccountComponent: wrapClass(rawSdk.AccountComponent),
    AuthSecretKey: wrapClass(rawSdk.AuthSecretKey),
    Felt: wrapClass(rawSdk.Felt),
    FungibleAsset: wrapClass(rawSdk.FungibleAsset),
    Word: wrapClass(rawSdk.Word),
    NoteTag: wrapClass(rawSdk.NoteTag),
    // Array type polyfills
    ...makeArrayPolyfills(),
  };
}