hb_client.erl - Remote Node Communication & Data Upload
Overview
Purpose: Remote message resolution and data upload to Arweave bundlers
Module: hb_client
Pattern: HTTP-based remote execution and ANS-104/HTTPSig upload
This module provides client-side functionality for interacting with remote HyperBEAM nodes and uploading data to Arweave bundlers. It handles message pair transformation for remote resolution, routing management, and supports both ANS-104 and HTTPSig commitment formats.
Dependencies
- HyperBEAM:
hb_ao,hb_http,hb_maps,hb_message,hb_util,hb_json,hb_opts - Arweave:
ar_bundles - Erlang/OTP:
httpc - Records:
#tx{}frominclude/hb.hrl
Public Functions Overview
%% Remote Resolution
-spec resolve(Node, Msg1, Msg2, Opts) -> {Status, Result}.
-spec routes(Node, Opts) -> {Status, Routes}.
-spec add_route(Node, Route, Opts) -> {Status, Result}.
%% Arweave Node API
-spec arweave_timestamp() -> {Timestamp, Height, Hash}.
%% Data Upload
-spec upload(Msg, Opts) -> {ok, Results}.
-spec upload(Msg, Opts, Device) -> {ok | error, Result}.Public Functions
1. resolve/4
-spec resolve(Node, Msg1, Msg2, Opts) -> {Status, Result}
when
Node :: binary(),
Msg1 :: map(),
Msg2 :: map(),
Opts :: map(),
Status :: ok | error,
Result :: term().Description: Resolve a message pair on a remote HyperBEAM node. Transforms the message pair into a singleton request by prefixing keys with 1. and 2. and sends via HTTP.
-module(hb_client_test).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
%% Helper to start a mock HTTP server that emulates Router device
start_mock_router(ResponseBody, StatusCode) ->
{ok, ListenSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]),
{ok, Port} = inet:port(ListenSock),
spawn(fun() -> mock_router_loop(ListenSock, ResponseBody, StatusCode) end),
{Port, ListenSock}.
mock_router_loop(ListenSock, ResponseBody, StatusCode) ->
case gen_tcp:accept(ListenSock, 5000) of
{ok, Sock} ->
{ok, _Request} = gen_tcp:recv(Sock, 0, 5000),
ContentLength = integer_to_binary(byte_size(ResponseBody)),
Response = <<"HTTP/1.1 ", (integer_to_binary(StatusCode))/binary, " OK\r\n",
"Content-Type: application/json\r\n",
"Content-Length: ", ContentLength/binary, "\r\n\r\n",
ResponseBody/binary>>,
gen_tcp:send(Sock, Response),
gen_tcp:close(Sock),
mock_router_loop(ListenSock, ResponseBody, StatusCode);
{error, timeout} ->
gen_tcp:close(ListenSock);
{error, _} ->
gen_tcp:close(ListenSock)
end.
resolve_get_test() ->
{Port, Sock} = start_mock_router(<<"{\"routes\":[]}">>, 200),
Node = iolist_to_binary([<<"http://127.0.0.1:">>, integer_to_binary(Port)]),
Msg1 = #{<<"device">> => <<"Router@1.0">>},
Msg2 = #{<<"path">> => <<"routes">>, <<"method">> => <<"GET">>},
{ok, Result} = hb_client:resolve(Node, Msg1, Msg2, #{http_client => httpc}),
?assert(is_map(Result)),
?assertEqual(200, maps:get(<<"status">>, Result)),
gen_tcp:close(Sock).
resolve_post_test() ->
{Port, Sock} = start_mock_router(<<"{\"added\":true}">>, 200),
Node = iolist_to_binary([<<"http://127.0.0.1:">>, integer_to_binary(Port)]),
Msg1 = #{
<<"device">> => <<"Router@1.0">>,
<<"template">> => <<"/test-path">>,
<<"node">> => Node
},
Msg2 = #{<<"path">> => <<"routes">>, <<"method">> => <<"POST">>},
{ok, Result} = hb_client:resolve(Node, Msg1, Msg2, #{http_client => httpc}),
?assert(is_map(Result)),
?assertEqual(200, maps:get(<<"status">>, Result)),
gen_tcp:close(Sock).2. routes/2
-spec routes(Node, Opts) -> {Status, Routes}
when
Node :: binary(),
Opts :: map(),
Status :: ok | error,
Routes :: map() | binary().Description: Get the list of registered routes from a remote node's Router device.
Test Code:routes_test() ->
{Port, Sock} = start_mock_router(<<"{\"routes\":[\"/api\",\"/data\"]}">>, 200),
Node = iolist_to_binary([<<"http://127.0.0.1:">>, integer_to_binary(Port)]),
{ok, Routes} = hb_client:routes(Node, #{http_client => httpc}),
?assert(is_map(Routes)),
?assertEqual(200, maps:get(<<"status">>, Routes)),
gen_tcp:close(Sock).3. add_route/3
-spec add_route(Node, Route, Opts) -> {Status, Result}
when
Node :: binary(),
Route :: map(),
Opts :: map(),
Status :: ok | error,
Result :: term().Description: Add a new route to a remote node's Router device via POST request.
Test Code:add_route_test() ->
{Port, Sock} = start_mock_router(<<"{\"success\":true}">>, 200),
Node = iolist_to_binary([<<"http://127.0.0.1:">>, integer_to_binary(Port)]),
Route = #{
<<"template">> => <<"/test-path">>,
<<"node">> => Node
},
{ok, Result} = hb_client:add_route(Node, Route, #{http_client => httpc}),
?assert(is_map(Result)),
?assertEqual(200, maps:get(<<"status">>, Result)),
gen_tcp:close(Sock).
add_route_response_test() ->
{Port, Sock} = start_mock_router(<<"{\"route_added\":\"/custom\"}">>, 200),
Node = iolist_to_binary([<<"http://127.0.0.1:">>, integer_to_binary(Port)]),
Route = #{
<<"template">> => <<"/custom-path">>,
<<"node">> => Node
},
{ok, Result} = hb_client:add_route(Node, Route, #{http_client => httpc}),
?assert(is_map(Result)),
?assertEqual(200, maps:get(<<"status">>, Result)),
gen_tcp:close(Sock).4. arweave_timestamp/0
-spec arweave_timestamp() -> {Timestamp, Height, Hash}
when
Timestamp :: integer(),
Height :: integer(),
Hash :: binary().Description: Fetch the current block information from the Arweave gateway. Returns timestamp, height, and block hash. In debug mode, returns zeroed values.
Test Code:arweave_timestamp_test() ->
{Timestamp, Height, Hash} = hb_client:arweave_timestamp(),
?assert(is_integer(Timestamp)),
?assert(is_integer(Height)),
?assert(is_binary(Hash)),
?assertEqual(43, byte_size(Hash)).5. upload/2, upload/3
-spec upload(Msg, Opts) -> {ok, Results}
when
Msg :: map() | binary(),
Opts :: map(),
Results :: [Result],
Result :: term().
-spec upload(Msg, Opts, Device) -> {ok | error, Result}
when
Msg :: map() | binary(),
Opts :: map(),
Device :: binary(),
Result :: term().Description: Upload data to Arweave bundlers. The 2-arity version uploads to all commitment devices in the message. The 3-arity version uploads to a specific device (either ans104@1.0 or httpsig@1.0).
ans104@1.0: ANS-104 bundle format (default Arweave bundler)httpsig@1.0: HTTP signature-based authentication
upload_empty_raw_ans104_test() ->
Serialized = ar_bundles:serialize(
ar_bundles:sign_item(#tx{
data = <<"TEST">>
}, hb:wallet())
),
Result = hb_client:upload(Serialized, #{}, <<"ans104@1.0">>),
?assertMatch({ok, _}, Result).
upload_raw_ans104_test() ->
Serialized = ar_bundles:serialize(
ar_bundles:sign_item(#tx{
data = <<"TEST">>,
tags = [{<<"test-tag">>, <<"test-value">>}]
}, hb:wallet())
),
Result = hb_client:upload(Serialized, #{}, <<"ans104@1.0">>),
?assertMatch({ok, _}, Result).
upload_raw_ans104_with_anchor_test() ->
Serialized = ar_bundles:serialize(
ar_bundles:sign_item(#tx{
data = <<"TEST">>,
anchor = crypto:strong_rand_bytes(32),
tags = [{<<"test-tag">>, <<"test-value">>}]
}, hb:wallet())
),
Result = hb_client:upload(Serialized, #{}, <<"ans104@1.0">>),
?assertMatch({ok, _}, Result).
upload_empty_message_test() ->
Msg = #{<<"data">> => <<"TEST">>},
Committed = hb_message:commit(Msg, hb:wallet(), <<"ans104@1.0">>),
Result = hb_client:upload(Committed, #{}, <<"ans104@1.0">>),
?assertMatch({ok, _}, Result).
upload_single_layer_message_test() ->
Msg = #{
<<"data">> => <<"TEST">>,
<<"basic">> => <<"value">>,
<<"integer">> => 1
},
Committed = hb_message:commit(Msg, hb:wallet(), <<"ans104@1.0">>),
Result = hb_client:upload(Committed, #{}, <<"ans104@1.0">>),
?assertMatch({ok, _}, Result).
upload_multiple_devices_test() ->
Msg = #{<<"data">> => <<"TEST">>},
Committed = hb_message:commit(Msg, hb:wallet(), <<"ans104@1.0">>),
{ok, Results} = hb_client:upload(Committed, #{}),
?assert(is_list(Results)),
?assert(length(Results) > 0).Common Patterns
%% Resolve a message on a remote node
Node = <<"http://localhost:8421">>,
BaseMsg = #{
<<"device">> => <<"MyDevice@1.0">>,
<<"data">> => <<"value">>
},
RequestMsg = #{
<<"path">> => <<"/data">>,
<<"method">> => <<"GET">>
},
{ok, Result} = hb_client:resolve(Node, BaseMsg, RequestMsg, #{}).
%% Get routes from remote node
{ok, Routes} = hb_client:routes(<<"http://localhost:8421">>, #{}).
%% Add a new route
Route = #{
<<"template">> => <<"/my-endpoint">>,
<<"node">> => <<"http://localhost:8421">>
},
{ok, _} = hb_client:add_route(<<"http://localhost:8421">>, Route, #{}).
%% Get Arweave block info
{Timestamp, Height, Hash} = hb_client:arweave_timestamp().
%% Upload to ANS-104 bundler (message)
Msg = #{<<"data">> => <<"content">>},
Committed = hb_message:commit(Msg, Wallet, <<"ans104@1.0">>),
{ok, _} = hb_client:upload(Committed, #{}, <<"ans104@1.0">>).
%% Upload to ANS-104 bundler (raw binary)
Item = ar_bundles:sign_item(#tx{data = <<"data">>}, Wallet),
Serialized = ar_bundles:serialize(Item),
{ok, _} = hb_client:upload(Serialized, #{}, <<"ans104@1.0">>).
%% Upload to all commitment devices
Msg = #{
<<"data">> => <<"content">>,
<<"commitments">> => [<<"ans104@1.0">>, <<"httpsig@1.0">>]
},
Committed = hb_message:commit(Msg, Wallet),
{ok, Results} = hb_client:upload(Committed, #{}).Message Transformation
Remote Resolution Transformation
When resolving on a remote node, resolve/4 transforms messages:
%% Input
Msg1 = #{<<"key1">> => <<"val1">>, <<"key2">> => <<"val2">>}
Msg2 = #{<<"path">> => <<"/key1">>, <<"param">> => <<"value">>}
%% Transformed to singleton request
TransformedMsg = #{
<<"1.key1">> => <<"val1">>,
<<"1.key2">> => <<"val2">>,
<<"2.param">> => <<"value">>,
<<"path">> => <<"/key1">>
}This transformation allows the remote node to process both messages as a single request.
Configuration Options
Bundler Configuration
%% ANS-104 bundler
Opts = #{
bundler_ans104 => <<"https://turbo.ardrive.io">>,
bundler_ans104_http_client => httpc % or gun
}.
%% HTTPSig bundler
Opts = #{
bundler_httpsig => <<"https://custom-bundler.com">>
}.
%% Arweave gateway
Opts = #{
gateway => <<"https://arweave.net">>
}.
%% Debug mode (returns mock data)
Opts = #{
mode => debug % or prod
}.Upload Flow
ANS-104 Upload Process
- Message Input: Either map message or binary serialized item
- Conversion: If map, convert to ANS-104 format via
hb_message:convert/3 - Serialization: Serialize to binary via
ar_bundles:serialize/1 - HTTP POST: Send to bundler endpoint
/txwith content-typeapplication/octet-stream - Return Result:
{ok, Response}or{error, Reason}
HTTPSig Upload Process
- Check Config: Verify
bundler_httpsigis configured - HTTP POST: Send message directly to
/txendpoint - Return Result:
{ok, Response}or{error, no_httpsig_bundler}
References
- HyperBEAM HTTP -
hb_http.erl - Message System -
hb_message.erl - ANS-104 Bundles -
ar_bundles.erl - Router Device -
dev_router.erl - Arweave Gateway API - https://docs.arweave.org/developers/server/http-api
Notes
- Remote Resolution: Messages are prefixed with
1.and2.for remote execution - Multiple Uploads:
upload/2uploads to all commitment devices in message - Binary Upload:
upload/3with binary automatically serializes as ANS-104 - Debug Mode: Returns mock timestamps when
mode => debug - Gateway: Defaults to configured gateway or Arweave mainnet
- Bundler Requirement: Must configure bundler URLs in opts
- HTTP Client: Supports both
httpcandgunclients - Commitment Devices: Automatically extracted from message metadata
- Error Handling: Returns
{error, Reason}for configuration or network issues - Router Device: Routes management requires remote node running Router@1.0