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
4 changes: 2 additions & 2 deletions actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ package hyperliquid

// CancelOrderWire represents cancel order item wire format
type CancelOrderWire struct {
Asset int `json:"a" msgpack:"a"`
OrderID string `json:"o" msgpack:"o"`
Asset int `json:"a" msgpack:"a"`
OrderID int64 `json:"o" msgpack:"o"`
}

// CancelAction represents the cancel action
Expand Down
25 changes: 25 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,28 @@ func (ma *MixedArray) UnmarshalJSON(data []byte) error {
*ma = rawArr
return nil
}

func (ma MixedArray) FirstError() error {
for _, mv := range ma {
if s, ok := mv.String(); ok {
if s == "success" {
continue
}
// any other string? treat as error text
return fmt.Errorf(s)
}
if obj, ok := mv.Object(); ok {
if v, ok := obj["error"]; ok {
if msg, ok := v.(string); ok && msg != "" {
return fmt.Errorf(msg)
}
// stringify unknown error shapes
b, _ := json.Marshal(v)
return fmt.Errorf(string(b))
}
}
// Unknown shape -> generic failure
return fmt.Errorf("cancel failed")
}
return nil
}
13 changes: 13 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,16 @@ func TestMixedValue_IntegrationWithComplexData(t *testing.T) {

assert.Equal(t, mv.Type(), mv2.Type())
}

func TestMixedArray_FirstError(t *testing.T) {
input := `{"status":"ok","response":{"type":"cancel","data":{"statuses":[{"error":"Order was never placed, already canceled, or filled. asset=173"}]}}}`
res := &APIResponse[CancelOrderResponse]{}

err := json.Unmarshal([]byte(input), res)
require.NoError(t, err)

want := "Order was never placed, already canceled, or filled. asset=173"
got := res.Data.Statuses.FirstError()

require.ErrorContains(t, got, want)
}
16 changes: 14 additions & 2 deletions exchange_orders_cancel.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package hyperliquid

import (
"strconv"
"fmt"

"github.com/sonirico/vago/slices"
)
Expand Down Expand Up @@ -35,7 +35,7 @@ func (e *Exchange) BulkCancel(
cancels := slices.Map(requests, func(req CancelOrderRequest) CancelOrderWire {
return CancelOrderWire{
Asset: e.info.NameToAsset(req.Coin),
OrderID: strconv.FormatInt(req.OrderID, 10),
OrderID: req.OrderID,
}
})

Expand All @@ -47,6 +47,18 @@ func (e *Exchange) BulkCancel(
if err = e.executeAction(action, &res); err != nil {
return
}

if res == nil || !res.Ok || res.Status == "err" {
if res != nil && res.Err != "" {
return res, fmt.Errorf(res.Err)
}
return res, fmt.Errorf("cancel failed")
}

if err := res.Data.Statuses.FirstError(); err != nil {
return res, err
}

return
}

Expand Down
110 changes: 110 additions & 0 deletions exchange_orders_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package hyperliquid

import (
"testing"

"github.com/stretchr/testify/require"
)

var recordForDebug = false

func TestCancel(t *testing.T) {
type tc struct {
name string
cassetteName string
// If placeFirst is true, we first place a resting order and use its OID.
placeFirst bool
order CreateOrderRequest
coin string
oid int64 // used only when placeFirst == false
// If doubleCancel is true, we attempt to cancel the same OID twice to exercise the error path.
doubleCancel bool
wantErr string
record bool
}

cases := []tc{
{
name: "cancel resting order by oid",
cassetteName: "Cancel",
placeFirst: true,
order: CreateOrderRequest{
Coin: "DOGE",
IsBuy: true,
Size: 45,
Price: 0.12330, // low so it stays resting
OrderType: OrderType{
Limit: &LimitOrderType{Tif: TifGtc},
},
},
coin: "DOGE",
record: recordForDebug,
},
{
name: "double cancel returns error on second attempt",
cassetteName: "Cancel",
placeFirst: true,
order: CreateOrderRequest{
Coin: "DOGE",
IsBuy: true,
Size: 45,
Price: 0.12330,
OrderType: OrderType{
Limit: &LimitOrderType{Tif: TifGtc},
},
},
coin: "DOGE",
doubleCancel: true,
wantErr: "already canceled",
record: recordForDebug,
},
{
name: "cancel non-existent oid",
cassetteName: "Cancel",
placeFirst: false,
coin: "DOGE",
oid: 1,
wantErr: "Order was never placed, already canceled, or filled.",
record: recordForDebug,
},
}

for _, tc := range cases {
t.Run(tc.name, func(tt *testing.T) {
initRecorder(tt, tc.record, tc.cassetteName)

exchange, err := newExchange(
"0x38d55ff1195c57b9dbc8a72c93119500f1fcd47a33f98149faa18d2fc37932fa",
TestnetAPIURL)
require.NoError(t, err)

oid := tc.oid
if tc.placeFirst {
placed, err := exchange.Order(tc.order, nil)
require.NoError(tt, err)
require.NotNil(tt, placed.Resting, "expected resting order so it can be canceled")
oid = placed.Resting.Oid
}

// First cancel
resp, err := exchange.Cancel(tc.coin, oid)
if tc.wantErr != "" && !tc.doubleCancel {
require.Error(tt, err)
require.Contains(tt, err.Error(), tc.wantErr)
return
}
require.NoError(tt, err)
tt.Logf("cancel response: %+v", resp)

// Optional second cancel to test error path
if tc.doubleCancel {
resp2, err2 := exchange.Cancel(tc.coin, oid)
require.Error(tt, err2, "expected error on second cancel")
if tc.wantErr != "" {
require.Contains(tt, err2.Error(), tc.wantErr)
}
tt.Logf("second cancel response: %+v, err: %v", resp2, err2)
}
})
}
}
Loading
Loading