Skip to content

feat: add WithSigner for external/remote signing (KMS/HSM)#18

Open
developerAkX wants to merge 6 commits into
quiknode-labs:mainfrom
developerAkX:feat/with-signer
Open

feat: add WithSigner for external/remote signing (KMS/HSM)#18
developerAkX wants to merge 6 commits into
quiknode-labs:mainfrom
developerAkX:feat/with-signer

Conversation

@developerAkX
Copy link
Copy Markdown

@developerAkX developerAkX commented May 29, 2026

Summary

Adds an external-signer seam so callers can sign Hyperliquid actions without giving the SDK a raw private key. Backed by a small, additive change to buildSignSend.

Closes #17.

Motivation

Enables KMS/HSM/remote-signer architectures where the private key must never enter the SDK process (e.g. a separate hardened signing service). See #17 for the full rationale.

API

// New external-signing analog of Wallet.SignHash.
type Signer func(hashHex string) (*Signature, error)

sdk, _ := hyperliquid.New(endpoint,
    hyperliquid.WithSigner(func(hashHex string) (*hyperliquid.Signature, error) {
        // forward hashHex to your KMS/HSM/remote signer; return r,s,v (v in {27,28})
    }),
    hyperliquid.WithSignerAddress("0xYourAgentAddress"),
)

Behaviour

  • WithSigner / WithSignerAddress options added next to WithPrivateKey.
  • When a signer is set, New() skips the PRIVATE_KEY env fallback, in-process wallet creation, and builder-fee auto-approve.
  • requireWallet() is satisfied by a wallet or a signer.
  • buildSignSend() signs via the callback when set, else Wallet.SignHash.
  • Address() returns the signer address when there's no wallet.

Backward compatibility

WithPrivateKey is byte-for-byte unchanged when no signer is set. The two s.wallet.* dereferences (Address(), buildSignSend) are guarded, so there's no nil-panic on the signer path.

Testing

  • New hyperliquid/signer_test.go (stdlib testing only, no network).
  • go build ./..., go vet ./..., go test ./hyperliquid/... all pass; the existing suite is unaffected.

Notes

No new dependencies. gofmt clean on changed files.


Note

Medium Risk
Changes the build→sign→send path and trading initialization; mistakes could break signing or mis-attribute errors, but the local private-key path is intended to stay unchanged when no signer is configured.

Overview
Adds external signing across Go, Python, TypeScript, and Rust so trading can run without loading a private key into the SDK. Callers supply a signer callback/trait (plus agent address where there is no local wallet); buildSignSend signs the build hash externally, validates r/s/v, and maps failures to SIGNER_FAILED / SignerError (separate from venue SIGNATURE_INVALID). With a signer configured, the SDK skips PRIVATE_KEY, in-process wallet creation, and builder-fee auto-approve; signing operations accept wallet or signer.

Go also wires per-order slippage from OrderBuilder into PlaceOrder. README sections and tests cover the new paths (including signer timeouts and malformed signature handling).

Reviewed by Cursor Bugbot for commit 068f3f1. Configure here.

Add a Signer callback option so callers can sign Hyperliquid actions
without giving the SDK a raw private key. Adds the `Signer func(hashHex
string) (*Signature, error)` type and `WithSigner` / `WithSignerAddress`
options. When a signer is set, New() skips the PRIVATE_KEY env fallback,
in-process wallet creation, and builder-fee auto-approval; requireWallet()
is satisfied by a wallet OR a signer; buildSignSend() signs via the
callback. The WithPrivateKey path is unchanged when no signer is set.

Enables KMS/HSM/remote-signer setups where the key must never enter the
SDK process. Adds package tests and a README section.
@developerAkX
Copy link
Copy Markdown
Author

For consistency's sake, I can add this feature in other languages too, like TypeScript, Rust, and Python.

Port the Go external-signer seam to Python, Rust, and TypeScript with
identical semantics. Add a dedicated SIGNER_FAILED/SignerError (distinct
from venue SignatureError) with the underlying cause chained, and bound
each signer call with a deadline/cancellation (context / tokio timeout /
AbortSignal). Builder-fee auto-approval is skipped under a signer in all
SDKs. Also add a per-order slippage helper to Go PlaceOrder. Tests and
README docs for each SDK.
@yaanakbr
Copy link
Copy Markdown
Contributor

bugbot run

Comment thread go/hyperliquid/sdk.go Outdated
… HTTP pipeline

The previous commit wrapped the whole build→sign→send pipeline in a single
context.WithTimeout, which unintentionally changed the wallet-only path: the
build and send HTTP calls used to each get a fresh per-request budget
(http.Client.Timeout) and now shared one deadline. Restore context.Background()
for the HTTP calls and scope the Timeout-derived deadline to the external
signer call only. This makes the wallet path byte-for-byte unchanged again and
matches how the Rust (tokio::time::timeout) and TypeScript (AbortSignal) SDKs
bound only the signer call. Update the now-inaccurate doc comments.
@developerAkX
Copy link
Copy Markdown
Author

bugbot run

Comment thread go/hyperliquid/sdk.go
A user-supplied signer can return a nil/None/malformed signature without
erroring; the SDK then embedded it into the send payload, producing a
confusing venue-side rejection instead of a clear SDK-level error. Validate
the returned signature (non-nil, non-empty r/s, v in {27,28}) right after the
signer call in Go, Python, and TypeScript, raising SignerError (SIGNER_FAILED)
on failure so it never reaches the venue. Rust is already safe via its type
system (sign_hash returns Result<Signature> with a concrete Signature). Adds
tests for nil and malformed returns in all three dynamic SDKs.
@developerAkX
Copy link
Copy Markdown
Author

bugbot run

Comment thread rust/src/client.rs Outdated
The Go/Python/TS SDKs validate the signature returned by an external signer
(v in {27,28}, non-empty r/s), but Rust only relied on its type system, which
guarantees non-nil but not a valid value: an external HyperliquidSigner could
return v=0/1 (raw recovery id) or zero r/s and have it sent straight to the
venue as a confusing rejection. Add validate_signer_signature and apply it on
the external-signer path only (LocalSigner always produces a valid signature),
surfacing failures as Error::SignerError (SIGNER_FAILED). Adds a unit test.
@developerAkX
Copy link
Copy Markdown
Author

bugbot run

Comment thread rust/src/client.rs
The signer_deadline timeout was applied to all signers, so a timeout on
the in-process LocalSigner path surfaced as SignerError (SIGNER_FAILED),
misleading for the local key path and diverging from the Go/TS SDKs.

Consolidate every external-signer-only concern (deadline, error
remapping, signature validation) into a single sign_external() helper and
branch on is_external_signer, leaving the LocalSigner path byte-for-byte
unchanged and structurally unable to enter external-only logic. This
matches the Go SDK's signer/wallet branch and removes the recurring class
of bug where external-only behavior leaked into the local key path.

Add regression tests for deadline timeout, deadline pass-through,
returned-signature validation, and non-SignerError remapping.
@developerAkX
Copy link
Copy Markdown
Author

bugbot run

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 068f3f1. Configure here.

@developerAkX
Copy link
Copy Markdown
Author

@yaanakbr all checks are passed. You can review it now bro

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support external/remote signing (KMS/HSM) without an in-process private key

2 participants