diff --git a/src/cli/commands.zig b/src/cli/commands.zig index bce4b53..47f78f0 100644 --- a/src/cli/commands.zig +++ b/src/cli/commands.zig @@ -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 @@ -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; @@ -1370,18 +1392,20 @@ 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, @@ -1389,6 +1413,8 @@ pub fn placeOrder(allocator: std.mem.Allocator, w: *Writer, config: Config, a: a 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 { @@ -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; @@ -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; @@ -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(); @@ -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; @@ -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(); diff --git a/src/sdk/client.zig b/src/sdk/client.zig index c5190cd..8b833f3 100644 --- a/src/sdk/client.zig +++ b/src/sdk/client.zig @@ -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| { @@ -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); +} diff --git a/src/sdk/types.zig b/src/sdk/types.zig index 43945bd..df9aaeb 100644 --- a/src/sdk/types.zig +++ b/src/sdk/types.zig @@ -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. @@ -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"); @@ -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. @@ -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"); @@ -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). @@ -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); +} diff --git a/src/terminal/trade.zig b/src/terminal/trade.zig index 9ad1528..f4b2039 100644 --- a/src/terminal/trade.zig +++ b/src/terminal/trade.zig @@ -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; }; @@ -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; };