Skip to content
Signing and keys

Signing and keys

Every action sent through an ExchangeClient is signed by a Signer. Which key may sign depends on the kind of action, so it is worth getting the key layout right before writing any trading code.

Signers

Signer is a two-method interface:

type Signer interface {
	// Address returns the address whose key produces the signatures.
	Address() Address
	// SignHash signs a 32-byte EIP-712 digest and returns a recoverable
	// secp256k1 signature.
	SignHash(ctx context.Context, hash [32]byte) (Signature, error)
}

NewPrivateKeySigner covers keys held in memory. It takes a 64-character hex key, with or without the 0x prefix:

signer, err := hyperliquid.NewPrivateKeySigner(os.Getenv("HL_AGENT_KEY"))
if err != nil {
	log.Fatal(err)
}
fmt.Println("signing as", signer.Address())

ex := hyperliquid.NewExchangeClient(hyperliquid.Mainnet, signer)

The client computes the EIP-712 digest itself; a signer only ever sees a 32-byte hash. To keep keys in a KMS or HSM, implement Signer yourself (see below).

Networks

Mainnet and Testnet are Network values holding the API, WebSocket and explorer URLs of each network. Network.Mainnet also selects the signature domain: a signature made for one network is rejected by the other, so an order signed for Testnet can never execute on Mainnet.

Declare your own Network for other deployments, such as a gateway in front of the public API. Keep Mainnet set to the network the gateway forwards to:

gateway := hyperliquid.Mainnet
gateway.APIURL = "https://hl-gateway.internal.example.com"
gateway.WebSocketURL = "wss://hl-gateway.internal.example.com/ws"

info := hyperliquid.NewInfoClient(gateway)
ex := hyperliquid.NewExchangeClient(gateway, signer)

RPCURL is used only by the explorer requests (InfoClient.BlockDetails, TxDetails, UserDetails) and DialExplorerWebSocket.

L1 actions and user-signed actions

Hyperliquid signs actions in one of two ways, and the SDK picks the right one for each action type:

  • L1 actions are hashed together with the nonce, the vault address and the expiry, and the hash is signed through an EIP-712 “agent” message. Orders, cancels, modifies, leverage changes, TWAPs, vault and sub-account operations and the deployer actions are L1 actions. They may be signed by an API wallet, and they honor WithVault and WithExpiresAfter.
  • User-signed actions are EIP-712 typed messages that a wallet can display field by field. They move funds or change who controls the account, so they must be signed by the account’s own key. They always act for the signer: vault and expiry settings are not sent with them.

The user-signed actions are:

GroupMethods
TransfersUSDSend, SpotSend, Withdraw, USDClassTransfer, SendAsset, SendToEVMWithData
ApprovalsApproveAgent, ApproveBuilderFee
StakingCDeposit, CWithdraw, TokenDelegate, LinkStakingUser, StakingLinkDisableTradingUser
AccountConvertToMultiSigUser, UserSetAbstraction, UserPortfolioMargin

AgentSendAsset and AgentSetAbstraction are L1 counterparts of SendAsset and UserSetAbstraction that an API wallet may sign.

API wallets

An API wallet (also called an agent wallet) is a separate key that an account authorizes to sign L1 actions for it. It can trade, but it cannot sign user-signed actions, so it cannot withdraw or transfer funds. Run bots with an API wallet and keep the account key offline.

You can create one in the Hyperliquid app under More → API, or from code with the account key:

// The account's own key approves the agent; it is needed only for this step.
owner, err := hyperliquid.NewPrivateKeySigner(os.Getenv("HL_ACCOUNT_KEY"))
if err != nil {
	log.Fatal(err)
}

// Generate a fresh agent key and store it in your secret manager.
var raw [32]byte
rand.Read(raw[:])
agentKey := hex.EncodeToString(raw[:])
agent, err := hyperliquid.NewPrivateKeySigner(agentKey)
if err != nil {
	log.Fatal(err)
}

err = hyperliquid.NewExchangeClient(hyperliquid.Testnet, owner).ApproveAgent(ctx, hyperliquid.ApproveAgentAction{
	AgentAddress: agent.Address(),
	AgentName:    "market-maker-1",
})
if err != nil {
	log.Fatal(err)
}

// From now on, trade with the agent key only.
ex := hyperliquid.NewExchangeClient(hyperliquid.Testnet, agent)

AgentName is at most 16 characters and may be followed by " valid_until <ms>" to make the approval expire. An account has one unnamed agent (empty name) and several named ones; approving a name again replaces the agent that held it. InfoClient.ExtraAgents lists the named agents of an account, and InfoClient.UserRole tells whether an address is an agent and for which account.

Query with the account address, not the agent’s. An agent signs for the account that approved it, so ex.Signer().Address() is the agent’s address, which holds no positions or orders. Pass the account (or sub-account, or vault) address to every InfoClient and WebSocket request.

A few rules from the Hyperliquid docs worth knowing:

  • Nonces are tracked per signer, so an agent has its own nonce set. Use one agent per trading process (see Nonces).
  • A replaced or expired agent, or one whose account has no funds left, may be pruned together with its nonce state. Generate a new agent key rather than re-approving an old one.
  • An agent approved by the master account can also sign for its sub-accounts; select one with WithVault.

Signing with a KMS

Implement Signer to sign with a key that never leaves a KMS, HSM or remote signing service. SignHash must return a recoverable secp256k1 signature: R and S plus V set to 27 or 28. Most KMS APIs return a DER-encoded (r, s) pair instead, so the signer has to convert it, normalize S to the lower half of the curve order (the canonical Ethereum form), and find the recovery ID that yields its public key.

The example below uses the secp256k1 and Keccak packages that the SDK already depends on. sign wraps the KMS call; with AWS KMS it is Sign with MessageType: DIGEST and SigningAlgorithm: ECDSA_SHA_256, and the public key comes from GetPublicKey.

import (
	"context"
	"crypto/x509/pkix"
	"encoding/asn1"
	"errors"
	"math/big"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
	"github.com/koative/hyperliquid-go"
	"golang.org/x/crypto/sha3"
)

// KMSSigner signs with a secp256k1 key held in a KMS.
type KMSSigner struct {
	pub  *secp256k1.PublicKey
	addr hyperliquid.Address
	// sign returns the DER-encoded ECDSA signature of a 32-byte digest.
	sign func(ctx context.Context, digest []byte) ([]byte, error)
}

// NewKMSSigner returns a signer for the key whose public key is spki, a
// DER-encoded SubjectPublicKeyInfo such as AWS KMS GetPublicKey returns.
func NewKMSSigner(spki []byte, sign func(ctx context.Context, digest []byte) ([]byte, error)) (*KMSSigner, error) {
	var info struct {
		Algorithm pkix.AlgorithmIdentifier
		PublicKey asn1.BitString
	}
	if _, err := asn1.Unmarshal(spki, &info); err != nil {
		return nil, err
	}
	pub, err := secp256k1.ParsePubKey(info.PublicKey.Bytes)
	if err != nil {
		return nil, err
	}
	// The address is the last 20 bytes of the Keccak-256 hash of the
	// uncompressed public key, without its 0x04 prefix.
	h := sha3.NewLegacyKeccak256()
	h.Write(pub.SerializeUncompressed()[1:])
	s := &KMSSigner{pub: pub, sign: sign}
	copy(s.addr[:], h.Sum(nil)[12:])
	return s, nil
}

func (s *KMSSigner) Address() hyperliquid.Address { return s.addr }

func (s *KMSSigner) SignHash(ctx context.Context, hash [32]byte) (hyperliquid.Signature, error) {
	der, err := s.sign(ctx, hash[:])
	if err != nil {
		return hyperliquid.Signature{}, err
	}
	var rs struct{ R, S *big.Int }
	if _, err := asn1.Unmarshal(der, &rs); err != nil {
		return hyperliquid.Signature{}, err
	}
	// Use the low-S form; a KMS may return either.
	n := secp256k1.S256().Params().N
	if rs.S.Cmp(new(big.Int).Rsh(n, 1)) > 0 {
		rs.S.Sub(n, rs.S)
	}
	var sig hyperliquid.Signature
	rs.R.FillBytes(sig.R[:])
	rs.S.FillBytes(sig.S[:])
	// Pick the recovery ID that recovers our own public key.
	compact := make([]byte, 65)
	copy(compact[1:33], sig.R[:])
	copy(compact[33:], sig.S[:])
	for _, v := range []byte{27, 28} {
		compact[0] = v
		if pub, _, err := ecdsa.RecoverCompact(compact, hash[:]); err == nil && pub.IsEqual(s.pub) {
			sig.V = v
			return sig, nil
		}
	}
	return hyperliquid.Signature{}, errors.New("kms: signature does not match the public key")
}

Pass it to NewExchangeClient like any other signer. SignHash receives the request’s context, so KMS calls are cancelled together with the request.

Last updated on