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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bloxwap/hyperliquid",
"version": "0.1.4",
"version": "0.1.5",
"description": "Blazing fast TypeScript Hyperliquid SDK.",
"license": "MIT",
"type": "module",
Expand Down
77 changes: 63 additions & 14 deletions src/signing/_abstractWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ interface Signer {
signTypedData(args: TypedDataArgs): Promise<Signature>;
/**
* Sign a raw 32-byte digest directly, skipping EIP-712 encoding entirely. Present only when the
* wallet can do so locally (viem local accounts expose `sign`); JSON-RPC wallets never have it,
* so their behavior is unchanged. Takes the digest as bytes so the L1 path avoids a hex round
* trip per signature; adapters whose wallet speaks hex (`sign({ hash })`) convert here.
* wallet can do so locally: a viem local account exposes `sign`, and a JSON-RPC-shaped wallet
* has it too when it wraps one (see {@linkcode embeddedLocalAccount}). A wallet that signs only
* through a remote endpoint never has it. Takes the digest as bytes so the L1 path avoids a hex
* round trip per signature; adapters whose wallet speaks hex (`sign({ hash })`) convert here.
*/
signDigest?(digest: Uint8Array): Promise<Signature>;
/** Lowercase wallet address. */
Expand Down Expand Up @@ -148,9 +149,65 @@ function isViemJsonRpc(wallet: AbstractWallet): wallet is AbstractViemJsonRpcAcc
);
}

/**
* A viem `WalletClient` configured with a local account, e.g. `createWalletClient({ account:
* privateKeyToAccount(key), … })` — the shape wagmi and viem hand around when the key lives in
* process.
*
* Such a client satisfies {@linkcode isViemJsonRpc} unconditionally (it always carries
* `signTypedData`, `getAddresses` and `getChainId`), so without this it would be adapted as a
* remote wallet and every L1 action would go through generic typed-data encoding — even though the
* key is right there and can sign the digest directly.
*/
interface LocalAccountCarrier {
account?: { type?: string } & AbstractViemLocalAccount & DigestBytesCapable;
}

/**
* The embedded local account of a JSON-RPC-shaped wallet, when it has one that can sign raw digests.
*
* Deliberately narrow: only viem's own `type: "local"` marker counts. A remote account (`type:
* "json-rpc"`) must keep going through the client, and a wallet whose `account` cannot sign a
* digest has nothing to offer here.
*/
function embeddedLocalAccount(wallet: AbstractWallet): (AbstractViemLocalAccount & DigestBytesCapable) | undefined {
const account = (wallet as LocalAccountCarrier).account;
if (account === undefined || account === null || account.type !== "local") return undefined;
if (typeof account.address !== "string") return undefined;
const canSignDigest = typeof account.sign === "function" || typeof account[SIGN_DIGEST_BYTES] === "function";
return canSignDigest ? account : undefined;
}

/**
* Raw-digest signer built from a viem local account, or `undefined` when it cannot sign digests.
*
* Shared by {@linkcode adaptViemLocal} and the JSON-RPC adapter, so a local account signs L1
* digests the same way whether it was passed directly or wrapped in a `WalletClient`.
*/
function digestSignerFor(
account: AbstractViemLocalAccount & DigestBytesCapable,
): ((digest: Uint8Array) => Promise<Signature>) | undefined {
// A wallet carrying the bytes-level capability (the WASM fast wallet) skips the hex conversion
// the hex-speaking `sign` requires.
const signDigestBytes = account[SIGN_DIGEST_BYTES];
if (typeof signDigestBytes === "function") {
return async (digest: Uint8Array): Promise<Signature> => parseSignature(await signDigestBytes(digest));
}
if (typeof account.sign === "function") {
return async (digest: Uint8Array): Promise<Signature> =>
parseSignature(await account.sign!({ hash: `0x${bytesToHex(digest)}` }));
}
return undefined;
}

function adaptViemJsonRpc(wallet: AbstractViemJsonRpcAccount): Signer {
// When the client wraps an in-process key, L1 actions sign the digest through that account.
// Everything else — typed data, address, chain ID — still goes through the client, so a wallet
// that switches chains or accounts behaves exactly as it did before.
const localAccount = embeddedLocalAccount(wallet);
return {
kind: "viem-jsonrpc",
signDigest: localAccount === undefined ? undefined : digestSignerFor(localAccount),
async signTypedData(args: TypedDataArgs): Promise<Signature> {
const hex = await wallet.signTypedData({
domain: args.domain,
Expand Down Expand Up @@ -218,9 +275,6 @@ function isViemLocal(wallet: AbstractWallet): wallet is AbstractViemLocalAccount
}

function adaptViemLocal(wallet: AbstractViemLocalAccount): Signer {
// A wallet carrying the bytes-level capability (the WASM fast wallet) skips the hex conversion
// the hex-speaking `sign` requires.
const signDigestBytes = (wallet as DigestBytesCapable)[SIGN_DIGEST_BYTES];
return {
kind: "viem-local",
async signTypedData(args: TypedDataArgs): Promise<Signature> {
Expand All @@ -234,13 +288,7 @@ function adaptViemLocal(wallet: AbstractViemLocalAccount): Signer {
},
// A viem local account can sign a raw 32-byte digest locally; wire that up so callers with a
// precomputed digest can skip the typed-data encoding round trip entirely.
signDigest:
typeof signDigestBytes === "function"
? async (digest: Uint8Array): Promise<Signature> => parseSignature(await signDigestBytes(digest))
: typeof wallet.sign === "function"
? async (digest: Uint8Array): Promise<Signature> =>
parseSignature(await wallet.sign!({ hash: `0x${bytesToHex(digest)}` }))
: undefined,
signDigest: digestSignerFor(wallet as AbstractViemLocalAccount & DigestBytesCapable),
getAddress(): Promise<`0x${string}`> {
return Promise.resolve(wallet.address.toLowerCase() as `0x${string}`);
},
Expand Down Expand Up @@ -395,7 +443,8 @@ export async function signTypedData(args: {
/**
* Signs a raw 32-byte digest directly when the wallet supports it (a viem local account exposing
* `sign`), bypassing EIP-712 encoding. Returns `undefined` for wallets without that capability —
* JSON-RPC wallets among them — so the caller can fall back to {@linkcode signTypedData}.
* wallets that can only sign remotely among them — so the caller can fall back to
* {@linkcode signTypedData}.
*
* Internal to the signing module: the caller is responsible for computing a digest that is
* byte-identical to what the typed-data path would have produced.
Expand Down
102 changes: 95 additions & 7 deletions src/signing/_fastWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
* `signTypedData` (user-signed actions, multi-sig wrappers) is not accelerated: it delegates to a
* viem local account created from the same key, imported lazily on first use. A wallet used only
* for L1 actions therefore never loads viem at all.
*
* viem is not a dependency of this package, so both viem-dependent paths reach it through
* `import()`. Environments that cannot service a dynamic import (Jest without
* `--experimental-vm-modules`, some React Native bundlers) supply `options.privateKeyToAccount`
* instead; without it those paths throw an error saying so.
* @module
*/

Expand Down Expand Up @@ -70,6 +75,64 @@ export async function loadTinySecp256k1(
/** The loader the factory uses; replaced only by tests through {@linkcode _setEccLoaderForTests}. */
let eccLoader: () => Promise<TinySecp256k1 | undefined> = loadTinySecp256k1;

/** `privateKeyToAccount` from `viem/accounts`, however the caller obtained it. */
export type PrivateKeyToAccount = (privateKey: `0x${string}`) => AbstractViemLocalAccount;

/**
* Resolves `privateKeyToAccount`, preferring one the caller supplied.
*
* `viem` is not a dependency of this package — not even an optional one — so it can only be
* reached through `import()`, and the specifier has to stay dynamic or every consumer who does not
* use viem would fail to resolve this module at load time. Some environments cannot service a
* dynamic import at all: Jest without `--experimental-vm-modules` rejects it with "A dynamic
* import callback was invoked without --experimental-vm-modules", and bundlers targeting React
* Native may drop it. Those callers pass `privateKeyToAccount` in and never reach the import.
*
* When neither is possible the failure is reported for what it is, rather than surfacing the
* host's opaque message from somewhere deep in the signing path.
*/
async function resolvePrivateKeyToAccount(
provided: PrivateKeyToAccount | undefined,
need: "the tiny-secp256k1 fallback" | "signTypedData",
): Promise<PrivateKeyToAccount> {
if (provided !== undefined) return provided;
try {
const { privateKeyToAccount } = await import("viem/accounts");
return privateKeyToAccount as PrivateKeyToAccount;
} catch (cause) {
// Two very different situations land here and the remedy differs, so name both rather than
// asserting one: viem may be absent (it is not a dependency, so this is the common case), or
// present but unreachable because the host cannot service a dynamic import.
throw new AbstractWalletError(
`createFastLocalWallet: ${need} needs \`viem/accounts\`, which could not be loaded. ` +
"Either viem is not installed (it is not a dependency of this package — install it), or " +
"this environment cannot service the dynamic import used to reach it (Jest without " +
"`--experimental-vm-modules` is the usual cause), in which case import " +
"`privateKeyToAccount` from `viem/accounts` yourself and pass it as " +
"`options.privateKeyToAccount`. See the underlying error on `cause`.",
{ cause },
);
}
}

/**
* Guards against a supplied factory that ignores the key it is handed.
*
* The natural mistake in exactly the hosts `options.privateKeyToAccount` exists for is passing a
* bound or curried helper closed over a different key — `() => myAccount` type-checks. On the WASM
* path the wallet's `address` and raw-digest signer come from `tiny-secp256k1` over `privateKey`
* while `signTypedData` delegates to this factory, so a mismatch would sign L1 actions and
* user-signed actions with two different keys and report only one of them.
*/
function assertDelegateMatches(delegate: AbstractViemLocalAccount, expected: `0x${string}`): void {
if (delegate.address?.toLowerCase() !== expected.toLowerCase()) {
throw new AbstractWalletError(
`createFastLocalWallet: options.privateKeyToAccount returned an account for ${delegate.address}, ` +
`but this wallet signs as ${expected}. It must derive the account from the private key it is given.`,
);
}
}

/**
* Internal test hook: swaps the `tiny-secp256k1` loader (pass `undefined` to restore the real one)
* and resets the one-time warning latch. Module mocking cannot reliably simulate a missing
Expand Down Expand Up @@ -116,15 +179,23 @@ function toChecksumAddress(address: `0x${string}`): `0x${string}` {
*
* Fallbacks, in order:
* - `tiny-secp256k1` missing or broken → a one-time `console.warn`, and the factory returns the
* plain viem local account (the noble path) — never a hard failure.
* plain viem local account (the noble path).
* - `signTypedData` → always delegated to viem (imported lazily on first use), since the WASM
* module accelerates raw digests only.
*
* Both fallbacks need `viem/accounts`, which this package can only reach through a dynamic import
* (viem is not a dependency of it). Environments that cannot service one — Jest without
* `--experimental-vm-modules`, some React Native bundlers — must pass `options.privateKeyToAccount`;
* otherwise those two paths throw with an explanation. With `tiny-secp256k1` present and only L1
* actions signed, viem is never needed at all.
*
* @param privateKey The 32-byte private key as a hex string.
* @param options Set `wasm: false` to skip the WASM accelerator and use the viem/noble path directly.
* @param options Set `wasm: false` to skip the WASM accelerator and use the viem/noble path directly,
* and `privateKeyToAccount` to supply viem's factory where a dynamic import is unavailable.
* @return The wallet, WASM-accelerated when available.
*
* @throws {AbstractWalletError} If the private key is not a valid 32-byte secp256k1 scalar.
* @throws {AbstractWalletError} If the private key is not a valid 32-byte secp256k1 scalar, or if
* viem is needed but can neither be imported nor was supplied.
*
* @example
* ```ts
Expand All @@ -141,6 +212,13 @@ export async function createFastLocalWallet(
options?: {
/** Set `false` to skip the WASM accelerator and use the viem/noble path. Default: `true`. */
wasm?: boolean;
/**
* `privateKeyToAccount` from `viem/accounts`, for environments where this package cannot
* `import()` it (Jest without `--experimental-vm-modules`, some React Native bundlers). When
* supplied it is used instead of the dynamic import on both viem-dependent paths: the
* `tiny-secp256k1` fallback and `signTypedData`.
*/
privateKeyToAccount?: PrivateKeyToAccount;
},
): Promise<AbstractViemLocalAccount> {
if (!/^0x[0-9a-fA-F]{64}$/.test(privateKey)) {
Expand All @@ -157,20 +235,30 @@ export async function createFastLocalWallet(
"falling back to the viem/noble signing path. Install `tiny-secp256k1` to enable WASM acceleration.",
);
}
const { privateKeyToAccount } = await import("viem/accounts");
const privateKeyToAccount = await resolvePrivateKeyToAccount(
options?.privateKeyToAccount,
"the tiny-secp256k1 fallback",
);
return privateKeyToAccount(privateKey);
}

if (!ecc.isPrivate(privateKeyBytes)) {
throw new AbstractWalletError("Private key is outside the secp256k1 scalar range");
}

// Derived once, and also the identity any `signTypedData` delegate must agree with.
const address = deriveAddress(ecc, privateKeyBytes);

// viem is needed only for `signTypedData` (user-signed actions); a pure-L1 wallet never pays for it.
let delegate: AbstractViemLocalAccount | undefined;
const viemDelegate = async (): Promise<AbstractViemLocalAccount> => {
if (delegate === undefined) {
const { privateKeyToAccount } = await import("viem/accounts");
delegate = privateKeyToAccount(privateKey);
const privateKeyToAccount = await resolvePrivateKeyToAccount(options?.privateKeyToAccount, "signTypedData");
const resolved = privateKeyToAccount(privateKey);
// The WASM path derives `address` and the digest signer itself, so a delegate for a
// different key would split this wallet's identity in two. Checked before it is memoized.
assertDelegateMatches(resolved, address);
delegate = resolved;
}
return delegate;
};
Expand All @@ -185,7 +273,7 @@ export async function createFastLocalWallet(
};

const account: AbstractViemLocalAccount & DigestBytesCapable = {
address: deriveAddress(ecc, privateKeyBytes),
address,
async sign({ hash }: { hash: `0x${string}` }): Promise<`0x${string}`> {
return signDigestBytes(hexToBytes(hash.slice(2)));
},
Expand Down
2 changes: 1 addition & 1 deletion src/signing/_l1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ async function signL1ActionHash(args: {
// Fast path: a wallet that can sign a raw digest (viem local accounts expose `sign`) signs the
// hand-rolled Agent digest directly and skips viem's whole EIP-712 encoding. The digest is
// byte-identical — the differential test pins it against viem's `hashTypedData`. The thunk keeps
// it lazy: wallets without the capability (every JSON-RPC wallet) fall through to the unchanged
// it lazy: wallets without the capability (those that can only sign remotely) fall through to the unchanged
// typed-data path without paying for a digest they would discard.
const fast = await signRawDigestBytes({
wallet,
Expand Down
2 changes: 1 addition & 1 deletion src/signing/_multiSig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async function signMultiSigOuter(args: {
// Fast path: a wallet that can sign a raw digest (viem local accounts expose `sign`) signs the
// hand-rolled SendMultiSig digest directly and skips viem's whole EIP-712 encoding. The digest
// is byte-identical — the differential test pins it against viem's `hashTypedData`. The thunk
// keeps it lazy: wallets without the capability (every JSON-RPC wallet) fall through to the
// keeps it lazy: wallets without the capability (those that can only sign remotely) fall through to the
// unchanged typed-data path without paying for a digest they would discard. A chain ID that did
// not parse to a uint256 word falls through too: viem rejects it, while the hand-rolled digest
// would silently sign under the wrong domain.
Expand Down
2 changes: 1 addition & 1 deletion src/signing/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export {
type Signature,
} from "./_abstractWallet.ts";
export { canonicalize, CanonicalizeError } from "./_canonicalize.ts";
export { createFastLocalWallet } from "./_fastWallet.ts";
export { createFastLocalWallet, type PrivateKeyToAccount } from "./_fastWallet.ts";
export { createL1ActionHash, signL1Action } from "./_l1.ts";
export { signUserSignedAction } from "./_userSigned.ts";
export { signMultiSigL1, signMultiSigUserSigned } from "./_multiSig.ts";
Loading
Loading