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
35 changes: 33 additions & 2 deletions src/cli/commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ fn getWriteAuth(w: *Writer, config: Config) CmdError!WriteAuth {
return auth;
}

/// Check if the user has approved the hlz builder fee. If not, print a one-time hint.
/// Best-effort: network failures are silently ignored (non-blocking).
fn checkBuilderApproval(client: *Client, w: *Writer, user_address: []const u8) void {
const builder_hex = types.addressToHex(types.HLZ_BUILDER_ADDRESS);
var result = client.maxBuilderFee(user_address, &builder_hex) catch return;
defer result.deinit();
// Parse the response — maxBuilderFee returns a number (max approved fee rate).
// If 0 or missing, user hasn't approved.
const body = result.body;
// The response is just a plain number string like "0" or "10"
const trimmed = std.mem.trim(u8, body, " \t\r\n\"");
const approved_fee = std.fmt.parseInt(u64, trimmed, 10) catch 0;
if (approved_fee < types.HLZ_BUILDER_FEE) {
w.styled(Style.muted, "hint: ") catch return;
w.print("hlz charges a {d}.{d}bp builder fee on orders. Approve with:\n", .{
types.HLZ_BUILDER_FEE / 10, types.HLZ_BUILDER_FEE % 10,
}) catch return;
w.print(" hlz approve-builder {s} 0.01%\n\n", .{@as([]const u8, &builder_hex)}) catch return;
}
}

pub fn keys(allocator: std.mem.Allocator, w: *Writer, a: args_mod.KeysArgs) !void {
const password = a.password orelse std.posix.getenv("HL_PASSWORD") orelse {
// For ls, no password needed
Expand Down Expand Up @@ -1206,6 +1227,7 @@ pub fn placeOrder(allocator: std.mem.Allocator, w: *Writer, config: Config, a: a
defer client.deinit();

const auth = try getWriteAuth(w, config);
if (w.format != .json) checkBuilderApproval(&client, w, auth.address());

const resolved = try resolveAsset(&client, a.coin);
const asset = resolved.index;
Expand Down Expand Up @@ -1370,25 +1392,29 @@ pub fn placeOrder(allocator: std.mem.Allocator, w: *Writer, config: Config, a: a
const batch = types.BatchOrder{
.orders = bracket_orders[0..bracket_count],
.grouping = grouping,
.builder = types.HLZ_BUILDER,
};

// --dry-run: preview without sending
if (a.dry_run) {
const builder_addr_hex = types.addressToHex(types.HLZ_BUILDER_ADDRESS);
if (w.format == .json) {
var db: [512]u8 = undefined;
var db: [1024]u8 = undefined;
var lp_buf: [32]u8 = undefined;
var sz_dbuf: [32]u8 = undefined;
const lp_s = limit_px.normalize().toString(&lp_buf) catch "?";
const sz_s = sz.normalize().toString(&sz_dbuf) catch "?";
const dr = std.fmt.bufPrint(&db,
\\{{"status":"dry_run","side":"{s}","coin":"{s}","size":"{s}","price":"{s}","reduce_only":{s},"bracket":{d}}}
\\{{"status":"dry_run","side":"{s}","coin":"{s}","size":"{s}","price":"{s}","reduce_only":{s},"bracket":{d},"builder":{{"b":"{s}","f":{d}}}}}
, .{
if (is_buy) "buy" else "sell",
a.coin,
sz_s,
lp_s,
if (a.reduce_only) "true" else "false",
bracket_count - 1,
@as([]const u8, &builder_addr_hex),
types.HLZ_BUILDER_FEE,
}) catch "{}";
try w.jsonRaw(dr);
} else {
Expand All @@ -1399,6 +1425,7 @@ pub fn placeOrder(allocator: std.mem.Allocator, w: *Writer, config: Config, a: a
try w.print(" {s} @ {s}", .{ sz.normalize().toString(&sz_dbuf) catch "?", limit_px.normalize().toString(&lp_buf) catch "?" });
if (a.reduce_only) try w.print(" reduce-only", .{});
if (bracket_count > 1) try w.print(" +{d} bracket", .{bracket_count - 1});
try w.print(" builder={s} fee={d}.{d}bp", .{ @as([]const u8, &builder_addr_hex), types.HLZ_BUILDER_FEE / 10, types.HLZ_BUILDER_FEE % 10 });
try w.nl();
}
return;
Expand Down Expand Up @@ -3566,6 +3593,7 @@ pub fn twap(allocator: std.mem.Allocator, w: *Writer, config: Config, a: args_mo
defer client.deinit();

const auth = try getWriteAuth(w, config);
if (w.format != .json) checkBuilderApproval(&client, w, auth.address());
const resolved = try resolveAsset(&client, a.coin);
const asset = resolved.index;
const total_sz = std.fmt.parseFloat(f64, a.size) catch return error.Overflow;
Expand Down Expand Up @@ -3642,6 +3670,7 @@ pub fn twap(allocator: std.mem.Allocator, w: *Writer, config: Config, a: args_mo
const batch_order = types.BatchOrder{
.orders = &[_]types.OrderRequest{order},
.grouping = .na,
.builder = types.HLZ_BUILDER,
};

var nonce_handler = response.NonceHandler.init();
Expand Down Expand Up @@ -3803,6 +3832,7 @@ pub fn batchCmd(allocator: std.mem.Allocator, w: *Writer, config: Config, a: arg
var client = makeClient(allocator, config);
defer client.deinit();
const auth = try getWriteAuth(w, config);
if (w.format != .json) checkBuilderApproval(&client, w, auth.address());

// Parse each order string into OrderRequest
var batch_items: [16]types.OrderRequest = undefined;
Expand Down Expand Up @@ -3901,6 +3931,7 @@ pub fn batchCmd(allocator: std.mem.Allocator, w: *Writer, config: Config, a: arg
const batch_order = types.BatchOrder{
.orders = batch_items[0..order_count],
.grouping = .na,
.builder = types.HLZ_BUILDER,
};

var nonce_handler = response.NonceHandler.init();
Expand Down
66 changes: 65 additions & 1 deletion src/sdk/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1829,7 +1829,14 @@ fn writeActionJson(writer: anytype, action_data: anytype) !void {
if (i > 0) try writer.writeAll(",");
try writeOrderJson(writer, order);
}
try std.fmt.format(writer, "],\"grouping\":\"{s}\"}}", .{@tagName(action_data.grouping)});
try std.fmt.format(writer, "],\"grouping\":\"{s}\"", .{@tagName(action_data.grouping)});
if (action_data.builder) |builder| {
try writer.writeAll(",\"builder\":{\"b\":\"");
const addr_hex = types.addressToHex(builder.address);
try writer.writeAll(&addr_hex);
try std.fmt.format(writer, "\",\"f\":{d}}}", .{builder.fee});
}
try writer.writeAll("}");
} else if (T == types.BatchCancel) {
try writer.writeAll("{\"type\":\"cancel\",\"cancels\":[");
for (action_data.cancels, 0..) |c, i| {
Expand Down Expand Up @@ -2085,3 +2092,60 @@ test "writeOrderJson" {
try std.testing.expect(std.mem.indexOf(u8, json, "\"s\":\"0.1\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"tif\":\"Gtc\"") != null);
}

test "writeActionJson: BatchOrder with builder" {
const order = types.OrderRequest{
.asset = 0,
.is_buy = true,
.limit_px = Decimal.fromString("50000") catch unreachable,
.sz = Decimal.fromString("0.1") catch unreachable,
.reduce_only = false,
.order_type = .{ .limit = .{ .tif = .Gtc } },
.cloid = types.ZERO_CLOID,
};

const batch = types.BatchOrder{
.orders = &[_]types.OrderRequest{order},
.grouping = .na,
.builder = types.HLZ_BUILDER,
};

var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
try writeActionJson(fbs.writer(), batch);
const json = fbs.getWritten();

// Verify action structure
try std.testing.expect(std.mem.indexOf(u8, json, "\"type\":\"order\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"grouping\":\"na\"") != null);
// Verify builder field
try std.testing.expect(std.mem.indexOf(u8, json, "\"builder\":{\"b\":\"0x0000000000000000000000000000000000000000\",\"f\":5}") != null);
}

test "writeActionJson: BatchOrder without builder" {
const order = types.OrderRequest{
.asset = 0,
.is_buy = true,
.limit_px = Decimal.fromString("50000") catch unreachable,
.sz = Decimal.fromString("0.1") catch unreachable,
.reduce_only = false,
.order_type = .{ .limit = .{ .tif = .Gtc } },
.cloid = types.ZERO_CLOID,
};

const batch = types.BatchOrder{
.orders = &[_]types.OrderRequest{order},
.grouping = .na,
};

var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
try writeActionJson(fbs.writer(), batch);
const json = fbs.getWritten();

// Verify action structure
try std.testing.expect(std.mem.indexOf(u8, json, "\"type\":\"order\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"grouping\":\"na\"") != null);
// Verify no builder field
try std.testing.expect(std.mem.indexOf(u8, json, "builder") == null);
}
147 changes: 144 additions & 3 deletions src/sdk/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,25 @@ pub const OrderRequest = struct {
cloid: Cloid,
};

/// Builder fee info attached to order actions.
/// `address` is the builder's 20-byte address, `fee` is in tenths of basis points (5 = 0.5bp).
pub const Builder = struct {
address: [20]u8,
fee: u16,
};

/// hlz builder address (placeholder — replace with real address).
pub const HLZ_BUILDER_ADDRESS: [20]u8 = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
/// hlz builder fee: 5 = 0.5bp (tenths of basis points).
pub const HLZ_BUILDER_FEE: u16 = 5;
/// Default builder for hlz orders.
pub const HLZ_BUILDER: Builder = .{ .address = HLZ_BUILDER_ADDRESS, .fee = HLZ_BUILDER_FEE };

/// Batch of orders to place.
pub const BatchOrder = struct {
orders: []const OrderRequest,
grouping: OrderGrouping,
builder: ?Builder = null,
};

/// Cancel a single order by exchange-assigned ID.
Expand Down Expand Up @@ -229,7 +244,8 @@ pub fn packOrderRequest(p: *msgpack.Packer, order: OrderRequest) msgpack.PackErr

/// Pack a BatchOrder to msgpack.
pub fn packBatchOrder(p: *msgpack.Packer, batch: BatchOrder) msgpack.PackError!void {
try p.packMapHeader(2);
const map_size: u32 = if (batch.builder != null) 3 else 2;
try p.packMapHeader(map_size);

// orders: array of OrderRequest
try p.packStr("orders");
Expand All @@ -241,6 +257,12 @@ pub fn packBatchOrder(p: *msgpack.Packer, batch: BatchOrder) msgpack.PackError!v
// grouping: enum string
try p.packStr("grouping");
try p.packStr(@tagName(batch.grouping));

// builder: optional
if (batch.builder) |builder| {
try p.packStr("builder");
try packBuilder(p, builder);
}
}

/// Pack a Cancel to msgpack.
Expand Down Expand Up @@ -297,10 +319,34 @@ pub const ActionTag = enum {
agentSetAbstraction,
};

/// Format a 20-byte address as "0x" + 40 lowercase hex chars.
pub fn addressToHex(addr: [20]u8) [42]u8 {
const charset = "0123456789abcdef";
var buf: [42]u8 = undefined;
buf[0] = '0';
buf[1] = 'x';
for (addr, 0..) |byte, i| {
buf[2 + i * 2] = charset[byte >> 4];
buf[2 + i * 2 + 1] = charset[byte & 0x0f];
}
return buf;
}

/// Pack a Builder to msgpack: {"b": "0x...", "f": N}
fn packBuilder(p: *msgpack.Packer, builder: Builder) msgpack.PackError!void {
try p.packMapHeader(2);
try p.packStr("b");
const addr_hex = addressToHex(builder.address);
try p.packStr(&addr_hex);
try p.packStr("f");
try p.packUint(@intCast(builder.fee));
}

/// Pack an Action::Order to msgpack (with serde tag = "type").
/// Output: {"type": "order", "orders": [...], "grouping": "na"}
/// Output: {"type": "order", "orders": [...], "grouping": "na"} or with builder: {"type": "order", "orders": [...], "grouping": "na", "builder": {"b": "0x...", "f": N}}
pub fn packActionOrder(p: *msgpack.Packer, batch: BatchOrder) msgpack.PackError!void {
try p.packMapHeader(3); // type + orders + grouping
const map_size: u32 = if (batch.builder != null) 4 else 3;
try p.packMapHeader(map_size); // type + orders + grouping [+ builder]

// type: "order"
try p.packStr("type");
Expand All @@ -316,6 +362,12 @@ pub fn packActionOrder(p: *msgpack.Packer, batch: BatchOrder) msgpack.PackError!
// grouping: enum string
try p.packStr("grouping");
try p.packStr(@tagName(batch.grouping));

// builder: optional
if (batch.builder) |builder| {
try p.packStr("builder");
try packBuilder(p, builder);
}
}

/// Pack an Action::Cancel to msgpack (with serde tag).
Expand Down Expand Up @@ -1105,3 +1157,92 @@ test "packBatchOrder: matches Rust msgpack vector" {

try std.testing.expectEqualSlices(u8, &expected, p.written());
}

test "packActionOrder: with builder field" {
const order = OrderRequest{
.asset = 0,
.is_buy = true,
.limit_px = Decimal.fromString("50000") catch unreachable,
.sz = Decimal.fromString("0.1") catch unreachable,
.reduce_only = false,
.order_type = .{ .limit = .{ .tif = .Gtc } },
.cloid = ZERO_CLOID,
};

const builder = Builder{
.address = HLZ_BUILDER_ADDRESS,
.fee = 5,
};

const batch = BatchOrder{
.orders = &[_]OrderRequest{order},
.grouping = .na,
.builder = builder,
};

var buf: [512]u8 = undefined;
var p = msgpack.Packer.init(&buf);
try packActionOrder(&p, batch);

const written = p.written();

// Map of 4 (type + orders + grouping + builder)
try std.testing.expectEqual(@as(u8, 0x84), written[0]);

// Verify builder field is present in the output
try std.testing.expect(std.mem.indexOf(u8, written, "builder") != null);

// Verify "b" key and address string are present
try std.testing.expect(std.mem.indexOf(u8, written, "0x0000000000000000000000000000000000000000") != null);

// Verify the original content (type, orders, grouping) is still correct
try std.testing.expect(std.mem.indexOf(u8, written, "order") != null);
try std.testing.expect(std.mem.indexOf(u8, written, "grouping") != null);
}

test "packActionOrder: without builder matches original" {
// Without builder, output must be identical to the original test vector
const expected_hex = "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61";

var expected: [74]u8 = undefined;
for (0..74) |i| {
expected[i] = std.fmt.parseInt(u8, expected_hex[i * 2 ..][0..2], 16) catch unreachable;
}

const order = OrderRequest{
.asset = 0,
.is_buy = true,
.limit_px = Decimal.fromString("50000") catch unreachable,
.sz = Decimal.fromString("0.1") catch unreachable,
.reduce_only = false,
.order_type = .{ .limit = .{ .tif = .Gtc } },
.cloid = ZERO_CLOID,
};

const batch = BatchOrder{
.orders = &[_]OrderRequest{order},
.grouping = .na,
// builder defaults to null
};

var buf: [256]u8 = undefined;
var p = msgpack.Packer.init(&buf);
try packActionOrder(&p, batch);

// Must be byte-exact with the original vector (no builder = map of 3)
try std.testing.expectEqualSlices(u8, &expected, p.written());
}

test "addressToHex: zero address" {
const addr = [_]u8{0} ** 20;
const hex = addressToHex(addr);
try std.testing.expectEqualStrings("0x0000000000000000000000000000000000000000", &hex);
}

test "addressToHex: non-zero address" {
var addr = [_]u8{0} ** 20;
addr[0] = 0xab;
addr[19] = 0xcd;
const hex = addressToHex(addr);
try std.testing.expectEqualStrings("0xab000000000000000000000000000000000000cd", &hex);
}
4 changes: 2 additions & 2 deletions src/terminal/trade.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,7 @@ const Orders = struct {

const ord = types.OrderRequest{ .asset = resolved.index, .is_buy = order.side == .buy, .limit_px = px_dec, .sz = sz_dec, .reduce_only = false, .order_type = .{ .limit = .{ .tif = tif } }, .cloid = makeCloid(nonce) };
const arr = [1]types.OrderRequest{ord};
var result = client.place(signer, .{ .orders = &arr, .grouping = .na }, nonce, null, null) catch {
var result = client.place(signer, .{ .orders = &arr, .grouping = .na, .builder = types.HLZ_BUILDER }, nonce, null, null) catch {
order.setStatus("Order failed (network)", true);
return;
};
Expand Down Expand Up @@ -1863,7 +1863,7 @@ const Orders = struct {
const nonce = @as(u64, @intCast(std.time.milliTimestamp()));
const ord = types.OrderRequest{ .asset = resolved.index, .is_buy = !is_long, .limit_px = px_dec, .sz = sz_dec, .reduce_only = true, .order_type = .{ .limit = .{ .tif = .FrontendMarket } }, .cloid = makeCloid(nonce) };
const arr = [1]types.OrderRequest{ord};
var result = client.place(signer, .{ .orders = &arr, .grouping = .na }, nonce, null, null) catch {
var result = client.place(signer, .{ .orders = &arr, .grouping = .na, .builder = types.HLZ_BUILDER }, nonce, null, null) catch {
ui.order.setStatus("Close failed (network)", true);
return;
};
Expand Down
Loading