Skip to content
Open
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
16 changes: 16 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ fmt.Println(order.Status) // "filled" or "resting"
fmt.Println(order.OID) // Order ID
```

### Advanced: external signing (KMS/HSM/remote signer)

Keep the private key out of the SDK process: supply a signing callback instead of a private key and sign the build hash wherever the key lives.

```go
sdk, _ := hyperliquid.New(endpoint,
hyperliquid.WithSigner(func(ctx context.Context, hashHex string) (*hyperliquid.Signature, error) {
// sign hashHex with your KMS/HSM/remote signer, return r, s, v (v in {27, 28}).
// ctx is bounded by the SDK Timeout; honour it for cancellation.
}),
hyperliquid.WithSignerAddress("0xYOUR_AGENT_ADDRESS"), // acting agent address
)
```

Builder-fee auto-approval is skipped with a signer, so call `sdk.ApproveBuilderFee("1%")` once per agent if you need it. Callback failures surface as a `*Error` with code `SIGNER_FAILED` (detect via `hyperliquid.IsSignerError(err)`).

---

## Data APIs
Expand Down
170 changes: 170 additions & 0 deletions go/hyperliquid/buildsignsend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package hyperliquid

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)

// newFakeExchange returns an httptest server that mimics the build→send worker:
// a request without a "signature" field is treated as the build step (returns a
// hash + nonce); one with a "signature" is the send step (returns ok). onSign,
// if non-nil, is invoked with the decoded send payload.
func newFakeExchange(t *testing.T, hash string, nonce int64) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]any
_ = json.Unmarshal(body, &req)

w.Header().Set("Content-Type", "application/json")
if _, isSend := req["signature"]; isSend {
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"hash": hash, "nonce": nonce})
}))
}

func newTestSDK(exchangeURL string, signer Signer) *SDK {
return &SDK{
config: &Config{Timeout: 30 * time.Second},
http: NewHTTPClient(30 * time.Second),
exchangeURL: exchangeURL,
signer: signer,
}
}

func TestBuildSignSend_SignerReceivesDeadlineContext(t *testing.T) {
srv := newFakeExchange(t, "0xabc123", 42)
defer srv.Close()

var gotDeadline bool
var gotHash string
signer := func(ctx context.Context, hashHex string) (*Signature, error) {
_, gotDeadline = ctx.Deadline()
gotHash = hashHex
return &Signature{R: "0x1", S: "0x2", V: 27}, nil
}

s := newTestSDK(srv.URL, signer)
if _, err := s.buildSignSend(map[string]any{"type": "order"}, nil); err != nil {
t.Fatalf("buildSignSend returned error: %v", err)
}
if !gotDeadline {
t.Fatal("signer received a context without a deadline; expected one bounded by Timeout")
}
if gotHash != "0xabc123" {
t.Fatalf("signer received hash %q, want %q", gotHash, "0xabc123")
}
}

func TestBuildSignSend_SignerErrorIsSignerError(t *testing.T) {
srv := newFakeExchange(t, "0xabc123", 42)
defer srv.Close()

sentinel := errors.New("kms unavailable")
signer := func(context.Context, string) (*Signature, error) {
return nil, sentinel
}

s := newTestSDK(srv.URL, signer)
_, err := s.buildSignSend(map[string]any{"type": "order"}, nil)
if err == nil {
t.Fatal("expected an error when the signer fails")
}
if !IsSignerError(err) {
t.Fatalf("IsSignerError = false, want true (err: %v)", err)
}
if !IsErrorCode(err, ErrorCodeSignerFailed) {
t.Fatalf("error code = %v, want %v", err, ErrorCodeSignerFailed)
}
if IsErrorCode(err, ErrorCodeSignatureInvalid) {
t.Fatal("signer failure must not be reported as SIGNATURE_INVALID (venue rejection)")
}
if !errors.Is(err, sentinel) {
t.Fatalf("errors.Is did not find the wrapped cause; err: %v", err)
}
}

func TestBuildSignSend_NilSignatureIsSignerError(t *testing.T) {
srv := newFakeExchange(t, "0xabc123", 42)
defer srv.Close()

// A signer that returns (nil, nil) — no error, but no signature either.
signer := func(context.Context, string) (*Signature, error) {
return nil, nil
}

s := newTestSDK(srv.URL, signer)
_, err := s.buildSignSend(map[string]any{"type": "order"}, nil)
if err == nil {
t.Fatal("expected an error when the signer returns a nil signature")
}
if !IsSignerError(err) {
t.Fatalf("IsSignerError = false, want true (err: %v)", err)
}
}

func TestBuildSignSend_MalformedSignatureIsSignerError(t *testing.T) {
srv := newFakeExchange(t, "0xabc123", 42)
defer srv.Close()

cases := map[string]*Signature{
"empty r/s": {R: "", S: "", V: 27},
"bad v (0/1)": {R: "0x1", S: "0x2", V: 0},
}
for name, sig := range cases {
t.Run(name, func(t *testing.T) {
s := newTestSDK(srv.URL, func(context.Context, string) (*Signature, error) {
return sig, nil
})
_, err := s.buildSignSend(map[string]any{"type": "order"}, nil)
if !IsSignerError(err) {
t.Fatalf("IsSignerError = false, want true (err: %v)", err)
}
})
}
}

func TestValidateSignerSignature(t *testing.T) {
if err := validateSignerSignature(&Signature{R: "0x1", S: "0x2", V: 27}); err != nil {
t.Fatalf("valid signature rejected: %v", err)
}
if err := validateSignerSignature(&Signature{R: "0x1", S: "0x2", V: 28}); err != nil {
t.Fatalf("valid signature (v=28) rejected: %v", err)
}
if validateSignerSignature(nil) == nil {
t.Fatal("nil signature accepted")
}
if validateSignerSignature(&Signature{R: "", S: "0x2", V: 27}) == nil {
t.Fatal("empty r accepted")
}
if validateSignerSignature(&Signature{R: "0x1", S: "0x2", V: 1}) == nil {
t.Fatal("invalid v accepted")
}
}

func TestSignerErrorUnwrap(t *testing.T) {
sentinel := errors.New("boom")
err := SignerError(sentinel)

if !errors.Is(err, sentinel) {
t.Fatal("SignerError should unwrap to its cause")
}
if err.Code != ErrorCodeSignerFailed {
t.Fatalf("code = %q, want %q", err.Code, ErrorCodeSignerFailed)
}

// An error constructed without a cause must still unwrap to nil
// (backward-compatible root-error behaviour).
plain := NewError(ErrorCodeBuildError, "no cause")
if plain.Unwrap() != nil {
t.Fatalf("Unwrap() on a causeless Error = %v, want nil", plain.Unwrap())
}
}
34 changes: 32 additions & 2 deletions go/hyperliquid/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
ErrorCodeInvalidPriceTick ErrorCode = "INVALID_PRICE_TICK"
ErrorCodeInvalidSize ErrorCode = "INVALID_SIZE"
ErrorCodeSignatureInvalid ErrorCode = "SIGNATURE_INVALID"
ErrorCodeSignerFailed ErrorCode = "SIGNER_FAILED"
ErrorCodeNoPosition ErrorCode = "NO_POSITION"
ErrorCodeOrderNotFound ErrorCode = "ORDER_NOT_FOUND"
ErrorCodeGeoBlocked ErrorCode = "GEO_BLOCKED"
Expand All @@ -45,6 +46,10 @@ type Error struct {
Message string `json:"message"`
Guidance string `json:"guidance,omitempty"`
Raw map[string]any `json:"raw,omitempty"`

// cause is an optional wrapped error, exposed via Unwrap so callers can use
// errors.Is/As (e.g. to inspect an external signer's underlying failure).
cause error
}

// Error implements the error interface.
Expand All @@ -60,9 +65,11 @@ func (e *Error) Error() string {
return strings.Join(parts, " ")
}

// Unwrap returns nil (Error is the root error type).
// Unwrap returns the wrapped cause if one was set via WithCause, otherwise nil.
// This lets errors.Is/As traverse into an underlying error (e.g. a signer's
// failure) while leaving errors without a cause as standalone root errors.
func (e *Error) Unwrap() error {
return nil
return e.cause
}

// NewError creates a new Error.
Expand All @@ -85,6 +92,12 @@ func (e *Error) WithRaw(raw map[string]any) *Error {
return e
}

// WithCause attaches an underlying error, exposed via Unwrap for errors.Is/As.
func (e *Error) WithCause(cause error) *Error {
e.cause = cause
return e
}

// BuildError creates a build phase error.
func BuildError(message string) *Error {
return NewError(ErrorCodeBuildError, message)
Expand Down Expand Up @@ -196,6 +209,17 @@ func SignatureError(message string) *Error {
WithGuidance("Signature verification failed. This is usually an SDK bug — please report it.")
}

// SignerError creates an external-signer failure error, wrapping the cause so
// callers can use errors.Is/As. It is distinct from SignatureError (a venue
// rejection or in-process wallet fault): SIGNER_FAILED means the external
// Signer callback — your KMS/HSM/remote signing service — failed, timed out,
// or returned an invalid result before the request was sent to the venue.
func SignerError(cause error) *Error {
return NewError(ErrorCodeSignerFailed, fmt.Sprintf("external signer failed: %v", cause)).
WithGuidance("The WithSigner callback returned an error (KMS/HSM/remote signing service). This is not a venue rejection; check your signer.").
WithCause(cause)
}

// ParseAPIError parses an API error response into an appropriate Error.
func ParseAPIError(data map[string]any, statusCode int) *Error {
errVal := data["error"]
Expand Down Expand Up @@ -321,6 +345,12 @@ func IsApprovalError(err error) bool {
return IsErrorCode(err, ErrorCodeNotApproved) || IsErrorCode(err, ErrorCodeFeeExceedsApproved)
}

// IsSignerError checks if the error is an external-signer failure (the
// WithSigner callback failed), as opposed to a venue-side signature rejection.
func IsSignerError(err error) bool {
return IsErrorCode(err, ErrorCodeSignerFailed)
}

// IsValidationError checks if the error is a validation error.
func IsValidationError(err error) bool {
return IsErrorCode(err, ErrorCodeInvalidParams) ||
Expand Down
19 changes: 19 additions & 0 deletions go/hyperliquid/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type OrderBuilder struct {
notional float64
cloid string
priorityFee *uint64
slippage *float64
}

// Order creates a new order builder.
Expand Down Expand Up @@ -133,6 +134,16 @@ func (o *OrderBuilder) PriorityFee(priorityFee uint64) *OrderBuilder {
return o
}

// Slippage overrides the default market-order slippage for this order,
// expressed as a fraction (e.g. 0.05 = 5%). Only applies to market orders;
// ignored for limit orders. When unset, the SDK's configured default applies.
// This is the OrderBuilder equivalent of the WithOrderSlippage OrderOption,
// letting PlaceOrder honour a per-order slippage bound.
func (o *OrderBuilder) Slippage(slippage float64) *OrderBuilder {
o.slippage = &slippage
return o
}

// Asset returns the order's asset.
func (o *OrderBuilder) Asset() string {
return o.asset
Expand Down Expand Up @@ -173,6 +184,11 @@ func (o *OrderBuilder) GetPriorityFee() *uint64 {
return o.priorityFee
}

// GetSlippage returns the per-order slippage fraction if set, else nil.
func (o *OrderBuilder) GetSlippage() *float64 {
return o.slippage
}

// SetSize sets the computed size (used internally for notional orders).
func (o *OrderBuilder) SetSize(size string) {
o.size = size
Expand Down Expand Up @@ -267,5 +283,8 @@ func (o *OrderBuilder) String() string {
if o.priorityFee != nil {
s += fmt.Sprintf(".PriorityFee(%d)", *o.priorityFee)
}
if o.slippage != nil {
s += fmt.Sprintf(".Slippage(%g)", *o.slippage)
}
return s
}
Loading