Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions tests/signing/canonicalize_conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,3 +782,58 @@ describe("canonicalize() fast path (issue #8)", () => {
});
});
});

describe("canonicalize() fast-path variant and union checks", () => {
test("a canonical variant child passes by reference", () => {
const schema = v.object({
t: v.variant("type", [
v.object({ type: v.literal("a"), x: v.number() }),
v.object({ type: v.literal("b"), y: v.number() }),
]),
});
const value = { t: { type: "a" as const, x: 1 } };

// Already canonical: the identity check must descend into the variant child and match its option.
expect(canonicalize(schema, value)).toBe(value);
});

test("a non-canonical variant child is rebuilt in schema order", () => {
const schema = v.object({
t: v.variant("type", [
v.object({ type: v.literal("a"), x: v.number() }),
v.object({ type: v.literal("b"), y: v.number() }),
]),
});
const value = { t: { x: 1, type: "a" as const } };

const out = canonicalize(schema, value);
expect(out).not.toBe(value);
expect(Object.keys(out.t)).toEqual(["type", "x"]);
expect(out).toEqual(value);
});

test("an unmatched variant child takes the slow path and throws CanonicalizeError", () => {
const schema = v.object({
t: v.variant("type", [v.object({ type: v.literal("a"), x: v.number() })]),
});

expect(() => canonicalize(schema, { t: { type: "z" } })).toThrow(CanonicalizeError);
});

test("union matching skips an option that is missing a required key", () => {
const options = [v.object({ a: v.string(), b: v.string() }), v.object({ a: v.string() })];
// Top-level union: `walk` consults the structural matcher directly.
expect(canonicalize(v.union(options), { a: "1" })).toEqual({ a: "1" });
// Union as a child: the identity check consults it too, and the value passes by reference.
const schema = v.object({ u: v.union(options) });
const value = { u: { a: "1" } };
expect(canonicalize(schema, value)).toBe(value);
});

test("union matching accepts an option whose only missing key is optional", () => {
const options = [v.object({ a: v.string(), b: v.optional(v.string()) }), v.object({ a: v.string() })];
// The first option matches despite `b` being absent: an optional key is not required.
const value = { a: "1" };
expect(canonicalize(v.union(options), value)).toBe(value);
});
});
30 changes: 30 additions & 0 deletions tests/signing/fastWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,36 @@ describe("createFastLocalWallet() fallback", () => {
});
});

// --- WASM module validation -----------------------------------------------------

describe("createFastLocalWallet() WASM module validation", () => {
test("throws when the module cannot derive a public key from the private key", async () => {
// `pointFromScalar` returning null: the guard rejects instead of building a broken address.
_setEccLoaderForTests(() =>
Promise.resolve({
isPrivate: () => true,
pointFromScalar: () => null,
signRecoverable: () => {
throw new Error("unreachable");
},
}),
);
await expect(createFastLocalWallet(PRIVATE_KEYS[0])).rejects.toThrow("Failed to derive the public key");

// A malformed (wrong-length) point hits the same guard.
_setEccLoaderForTests(() =>
Promise.resolve({
isPrivate: () => true,
pointFromScalar: () => new Uint8Array(33),
signRecoverable: () => {
throw new Error("unreachable");
},
}),
);
await expect(createFastLocalWallet(PRIVATE_KEYS[0])).rejects.toThrow("Failed to derive the public key");
});
});

// --- JSON-RPC wallets stay untouched --------------------------------------------

describe("createFastLocalWallet() alongside JSON-RPC wallets", () => {
Expand Down
52 changes: 52 additions & 0 deletions tests/signing/keccak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { rename } from "node:fs/promises";
import { createRequire } from "node:module";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { privateKeyToAccount } from "viem/accounts";
Expand Down Expand Up @@ -314,6 +316,22 @@ describe("keccak256() load semantics", () => {
expect(createL1ActionHash({ action: ORDER_WITH_CLOID, nonce: NONCE })).toBe(ORDER_ACTION_HASH);
});

test("a hasher that throws during the self-check is never trusted", async () => {
_setKeccakLoaderForTests(() =>
Promise.resolve({
init() {
throw new Error("broken WASM build");
},
update() {},
digest: (_outputType: "binary") => new Uint8Array(32),
}),
);
await preloadWasmKeccak();

expect(bytesToHex(keccak256(new Uint8Array(0)))).toBe(KECCAK256_EMPTY);
expect(createL1ActionHash({ action: ORDER_WITH_CLOID, nonce: NONCE })).toBe(ORDER_ACTION_HASH);
});

test("a partially linked module (missing createKeccak) falls back to noble", async () => {
_setKeccakLoaderForTests(() => Promise.resolve(undefined));
await preloadWasmKeccak();
Expand Down Expand Up @@ -341,6 +359,40 @@ if (hashWasmAvailable) {
expect(bytesToHex(keccak256(new Uint8Array(0)))).toBe(KECCAK256_EMPTY);
});
});

describe("loadWasmKeccak() with an unreadable hash-wasm entry", () => {
test("the real loader routes a failed import to the noble fallback", async () => {
// Hiding the package's entry file makes the real loader's dynamic import reject — the one
// loader-failure path `_setKeccakLoaderForTests` cannot reach (it replaces the loader), and
// module mocking cannot simulate (a mocked specifier stays poisoned for the process — see
// the module header). hash-wasm ships CJS, so forcing a re-read only takes dropping the
// `require.cache` record; the file is restored in `finally` and the failed load leaves no
// cache entry behind, so the real module loads again on the next import.
const nodeRequire = createRequire(import.meta.url);
const entry = nodeRequire.resolve("hash-wasm");
const hidden = `${entry}.hidden-by-test`;
delete nodeRequire.cache[entry];
await rename(entry, hidden);
try {
// The failure must be real — if a future runtime serves the import from an immutable
// module cache instead, this test covers nothing and must say so.
const served = await import("hash-wasm").then(
() => true,
() => false,
);
expect(served).toBe(false);

_setKeccakLoaderForTests(undefined); // the real loader — its import now rejects
await preloadWasmKeccak();

expect(bytesToHex(keccak256(new Uint8Array(0)))).toBe(KECCAK256_EMPTY);
expect(createL1ActionHash({ action: ORDER_WITH_CLOID, nonce: NONCE })).toBe(ORDER_ACTION_HASH);
} finally {
await rename(hidden, entry);
delete nodeRequire.cache[entry]; // drop any failed-load record so the real module re-loads
}
});
});
}

// --- Cross-provider signature identity ------------------------------------------
Expand Down
152 changes: 151 additions & 1 deletion tests/signing/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@
* @module
*/

import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { assertEquals } from "@jsr/std__assert";
import { createWalletClient, custom } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arbitrum } from "viem/chains";

import {
AbstractWalletError,
createL1ActionHash,
getWalletAddress,
getWalletChainId,
signL1Action,
signMultiSigL1,
signMultiSigUserSigned,
signUserSignedAction,
} from "@bloxwap/hyperliquid/signing";
import { signRawDigest, signTypedData } from "../../src/signing/_abstractWallet.ts";

// ============================================================
// Test Data
Expand Down Expand Up @@ -372,3 +375,150 @@ describe("signing", () => {
});
});
});

// ============================================================
// Wallet error wrapping
// ============================================================

describe("wallet error wrapping (AbstractWalletError)", () => {
const TYPED_DATA_ARGS = {
domain: {
name: "HyperliquidSignTransaction",
version: "1",
chainId: 421614,
verifyingContract: "0x0000000000000000000000000000000000000000",
},
types: { Agent: [{ name: "source", type: "string" }] },
primaryType: "Agent",
message: { source: "a" },
} as const;
const DIGEST = `0x${"11".repeat(32)}` as const;
const VALID_HEX_SIGNATURE = `0x${"1".repeat(64)}${"2".repeat(64)}1b` as const;

/** Minimal JSON-RPC wallet stub; individual methods are overridden per test. */
function jsonRpcWallet(overrides: {
signTypedData?: (_params: never) => Promise<`0x${string}`>;
getAddresses?: () => Promise<`0x${string}`[]>;
getChainId?: () => Promise<number>;
}) {
return {
signTypedData: overrides.signTypedData ?? ((_params: never) => Promise.resolve(VALID_HEX_SIGNATURE)),
getAddresses:
overrides.getAddresses ?? (() => Promise.resolve(["0x1111111111111111111111111111111111111111" as const])),
getChainId: overrides.getChainId ?? (() => Promise.resolve(1337)),
};
}

/** Awaits a rejection and returns the caught error (fails if the call resolves). */
async function caught(promise: Promise<unknown>): Promise<Error> {
return promise.then(
() => {
throw new Error("expected the call to reject");
},
(error: unknown) => error as Error,
);
}

describe("signTypedData()", () => {
test("rejects a wallet signature that is not 65 bytes", async () => {
const wallet = jsonRpcWallet({ signTypedData: (_params: never) => Promise.resolve("0x1234" as const) });

const error = await caught(signTypedData({ wallet, ...TYPED_DATA_ARGS }));
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Expected 65-byte signature (132 hex chars), got 6");
});

test("rejects a wallet signature with an invalid recovery value", async () => {
const wallet = jsonRpcWallet({
signTypedData: (_params: never) => Promise.resolve(`0x${"1".repeat(64)}${"2".repeat(64)}1d` as const),
});

const error = await caught(signTypedData({ wallet, ...TYPED_DATA_ARGS }));
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Invalid signature recovery value: 29, expected 0/1 or 27/28");
});

test("wraps a non-SDK wallet failure, keeping the cause", async () => {
const wallet = jsonRpcWallet({
signTypedData: (_params: never) => Promise.reject(new Error("user rejected the request")),
});

const error = await caught(signTypedData({ wallet, ...TYPED_DATA_ARGS }));
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Failed to sign the typed data using the wallet");
expect(error.cause).toBeInstanceOf(Error);
});

test("rethrows an AbstractWalletError unchanged", async () => {
const failure = new AbstractWalletError("already an SDK error");
const wallet = jsonRpcWallet({ signTypedData: (_params: never) => Promise.reject(failure) });

const error = await caught(signTypedData({ wallet, ...TYPED_DATA_ARGS }));
expect(error).toBe(failure);
});
});

describe("signRawDigest()", () => {
/** Minimal viem-local wallet stub whose raw-digest `sign` fails as instructed. */
function localWallet(failure: Error) {
return {
address: "0x1111111111111111111111111111111111111111" as const,
sign: (_args: { hash: `0x${string}` }) => Promise.reject(failure),
signTypedData: (_params: never) => Promise.resolve(VALID_HEX_SIGNATURE),
};
}

test("wraps a non-SDK wallet failure, keeping the cause", async () => {
const error = await caught(
signRawDigest({ wallet: localWallet(new Error("device disconnected")), digest: DIGEST }),
);
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Failed to sign the digest using the wallet");
expect(error.cause).toBeInstanceOf(Error);
});

test("rethrows an AbstractWalletError unchanged", async () => {
const failure = new AbstractWalletError("already an SDK error");
const error = await caught(signRawDigest({ wallet: localWallet(failure), digest: DIGEST }));
expect(error).toBe(failure);
});
});

describe("getWalletAddress()", () => {
test("wraps a non-SDK lookup failure, keeping the cause", async () => {
const wallet = jsonRpcWallet({ getAddresses: () => Promise.reject(new Error("rpc down")) });

const error = await caught(getWalletAddress(wallet));
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Failed to get an address from the wallet");
expect(error.cause).toBeInstanceOf(Error);
});

test("rethrows an AbstractWalletError unchanged", async () => {
const failure = new AbstractWalletError("already an SDK error");
const wallet = jsonRpcWallet({ getAddresses: () => Promise.reject(failure) });

const error = await caught(getWalletAddress(wallet));
expect(error).toBe(failure);
});
});

describe("getWalletChainId()", () => {
test("wraps a non-SDK lookup failure, keeping the cause", async () => {
const wallet = jsonRpcWallet({ getChainId: () => Promise.reject(new Error("rpc down")) });

const error = await caught(getWalletChainId(wallet));
expect(error).toBeInstanceOf(AbstractWalletError);
expect(error.message).toBe("Failed to get the chain ID from the wallet");
expect(error.cause).toBeInstanceOf(Error);
});

test("rethrows an AbstractWalletError unchanged", async () => {
const failure = new AbstractWalletError("already an SDK error");
const wallet = jsonRpcWallet({ getChainId: () => Promise.reject(failure) });

const error = await caught(getWalletChainId(wallet));
expect(error).toBe(failure);
});
});
});
Loading
Loading