Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b07c6ed
feat: add admissibility checking for permissionless TX serving
Lucifer0x17 Mar 4, 2026
164f6fb
fix: use content hash for TX admissibility instead of commitment IDs
Lucifer0x17 Mar 5, 2026
7ce2a4f
feat: support commitment-based ID check in is_tx_admissible
Lucifer0x17 Mar 5, 2026
881e3e2
fix: correct way to verify while checking is_admissible
Lucifer0x17 Mar 6, 2026
547acfd
feat: enable hooks so that node can sign when vouching for a tx
Lucifer0x17 Mar 6, 2026
5acfd28
fix: test to use http rather than get_tx
Lucifer0x17 Mar 6, 2026
9226f1c
fix: simplify is_tx_admissible to check TXID membership in commitment…
Lucifer0x17 Mar 6, 2026
bd2df46
feat: add router-perf@1.0 Erlang execution device for performance-bas…
Lucifer0x17 Mar 8, 2026
d899257
feat: add integration test for router-perf@1.0 and clean up device code
Lucifer0x17 Mar 8, 2026
3d6921c
fix: calculating the weights post req
Lucifer0x17 Mar 9, 2026
45bc93f
Revert "fix: calculating the weights post req"
Lucifer0x17 Mar 10, 2026
544eebb
test: added test for load balancing on gateways
Lucifer0x17 Mar 11, 2026
a920927
feat: add monitor-sampler@1.0 device for probabilistic sampling
Lucifer0x17 Mar 15, 2026
37ff199
feat: register monitor-sampler@1.0 in preloaded devices
Lucifer0x17 Mar 15, 2026
41c4c9c
feat: integrate monitor sampling and path sanitization in http_client
Lucifer0x17 Mar 15, 2026
07b72a4
impr: right key for acrion
Lucifer0x17 Mar 15, 2026
00dd981
feat: support nested hook names via deep_get in dev_hook
Lucifer0x17 Mar 18, 2026
fa611fd
feat: add generic chance@1.0 gate device, replace monitor-sampler
Lucifer0x17 Mar 18, 2026
949f29a
feat: replace custom monitor code with hook-based architecture
Lucifer0x17 Mar 18, 2026
abbcc3d
refactor: migrate tests from http_monitor to hook-based config
Lucifer0x17 Mar 18, 2026
9bc4e83
refactor: move tx admissibility logic from generic request/5 into get_tx
Lucifer0x17 Mar 18, 2026
fc0725c
fix: clean up hb_http_client record_duration after rebase
Lucifer0x17 Apr 10, 2026
3b11d4e
feat: gate router-perf register with trusted-peer + is-admissible
Lucifer0x17 Apr 10, 2026
69839dd
fix: handle hook failure return in dev_arweave get_tx
Lucifer0x17 Apr 10, 2026
c187ce9
docs: flag cross-route http_reference collision in update_node_perfor…
Lucifer0x17 Apr 10, 2026
ed13824
fix: validate chance@1.0 rate before parsing
Lucifer0x17 Apr 10, 2026
9a29e1e
feat: pass raw response to http-client/response hook via priv
Lucifer0x17 Apr 11, 2026
e5b81ba
feat: add is_tx_admissible_hook adapter to dev_arweave
Lucifer0x17 Apr 11, 2026
1809db8
fix: restore path fallback in router-perf compute dispatcher
Lucifer0x17 Apr 11, 2026
8b5c56d
refactor: flatten is_tx_admissible_hook using maybe
Lucifer0x17 Apr 11, 2026
d0ebf8b
fix: handle error tuples in response_to_map
Lucifer0x17 Apr 13, 2026
145afb9
fix: handle error tuples in response_to_map
Lucifer0x17 Apr 13, 2026
dfa0af7
Merge remote-tracking branch 'origin/feat/rebased-router-node' into f…
Lucifer0x17 Apr 13, 2026
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
284 changes: 269 additions & 15 deletions src/dev_arweave.erl
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
-export([tx/3, raw/3, chunk/3, block/3, current/3, status/3, price/3, tx_anchor/3]).
-export([pending/3]).
-export([post_tx_header/2, post_tx/3, post_tx/4, post_chunk/2]).
-export([is_tx_admissible_hook/3]).
%%% Helper functions
-export([get_chunk/2, bundle_header/2, bundle_header/3]).
-include("include/hb.hrl").
-include("include/hb_arweave_nodes.hrl").
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

-include_lib("eunit/include/eunit.hrl").

-define(IS_BLOCK_ID(X), (is_binary(X) andalso byte_size(X) == 64)).
Expand Down Expand Up @@ -127,23 +129,77 @@ get_tx(Base, Request, Opts) ->
case find_key(<<"tx">>, Base, Request, Opts) of
not_found -> {error, not_found};
TXID ->
request(
<<"GET">>,
<<"/tx/", TXID/binary>>,
Opts#{
exclude_data =>
hb_util:bool(
find_key(
<<"exclude-data">>,
Base,
Request,
Opts
)
)
}
)
TxOpts = Opts#{
exclude_data =>
hb_util:bool(
find_key(<<"exclude-data">>, Base, Request, Opts)
)
},
case request(<<"GET">>, <<"/tx/", TXID/binary>>, TxOpts) of
{ok, Msg} ->
Admissible = #{
<<"device">> => <<"arweave@2.9">>,
<<"path">> => <<"is-tx-admissible">>,
<<"tx">> => TXID
},
case is_tx_admissible(Admissible, Msg, TxOpts) of
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using a newly constructed message with a path as a base message?

true ->
case dev_hook:on(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want to ignore the result. It shouldn't affect the user getting their reply, unless I misunderstood?

Also, if we have a function called is_tx_admissible, why is our tx-admissible hook outside of it?

<<"tx-admissible">>,
#{ <<"body">> => Msg },
TxOpts
) of
{ok, #{ <<"body">> := ResultMsg }} ->
{ok, ResultMsg};
{error, Reason} -> {error, Reason};
{failure, Reason} -> {error, Reason}
end;
false -> {error, not_admissible}
end;
Error -> Error
end
end.

%% @doc Check whether a response to a `GET /tx/ID' request is valid.
%% Verifies that the requested TXID exists as a commitment ID in the
%% response message, and that all commitments are cryptographically valid.
is_tx_admissible(Base, Request, Opts) ->
maybe
{ok, TXID} ?= hb_maps:find(<<"tx">>, Base, Opts),
CommIDs = maps:keys(maps:get(<<"commitments">>, Request, #{})),
true ?=
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is unnecessary

lists:member(TXID, CommIDs) andalso
hb_message:verify(Request, all, Opts)
else
_ -> false
end.

%% @doc Hook adapter for the `http-client/response' chain. Decodes the raw
%% upstream response via `dev_codec_httpsig:from/3' and runs
%% `is_tx_admissible/3' against the result.
is_tx_admissible_hook(_Base, HookReq, Opts) ->
Body = hb_maps:get(<<"body">>, HookReq, #{}, Opts),
Path = hb_maps:get(<<"request-path">>, Body, <<>>, Opts),
Priv = hb_maps:get(<<"priv">>, HookReq, #{}, Opts),
Response = hb_maps:get(<<"response">>, Priv, #{}, Opts),
maybe
{ok, TXID} ?= extract_txid_from_path(Path),
{ok, Decoded} ?= dev_codec_httpsig:from(Response, #{}, Opts),
true ?= is_tx_admissible(#{<<"tx">> => TXID}, Decoded, Opts),
{ok, HookReq}
else
error -> {ok, HookReq};
false -> {error, not_admissible};
_ -> {error, decode_failed}
end.

extract_txid_from_path(<<"/", TXID:43/binary>>) ->
{ok, TXID};
extract_txid_from_path(<<"/", TXID:43/binary, "/", _/binary>>) ->
{ok, TXID};
extract_txid_from_path(_) ->
error.

%% @doc A router for range requests by method. Both `HEAD` and `GET` requests
%% are supported.
raw(Base, Request, Opts) ->
Expand Down Expand Up @@ -2156,3 +2212,201 @@ assert_chunk_range(Type, ID, StartOffset, ExpectedLength, ExpectedHash, Opts) ->
?event(debug_test, {data, {explicit, hb_util:encode(crypto:hash(sha256, Data))}}),
?assertEqual(ExpectedHash, hb_util:encode(crypto:hash(sha256, Data))),
ok.

is_admissible_routed_test() ->
AliceWallet = ar_wallet:new(),
BobWallet = ar_wallet:new(),
AliceNode = hb_http_server:start_node(#{ priv_wallet => AliceWallet }),
BobNode = hb_http_server:start_node(#{ priv_wallet => BobWallet }),
AliceNodeOpts = hb_http_server:get_opts(#{
http_server => hb_util:human_id(ar_wallet:to_address(AliceWallet))
}),
BobNodeOpts = hb_http_server:get_opts(#{
http_server => hb_util:human_id(ar_wallet:to_address(BobWallet))
}),
AliceMsg =
hb_message:commit(#{
<<"a">> => 1 },
AliceNodeOpts,
<<"ans104@1.0">>
),
{ok, AliceMsgRawID} = hb_cache:write(AliceMsg, AliceNodeOpts),
AliceMsgID = hb_util:human_id(AliceMsgRawID),
BobMsg =
hb_message:commit(#{
<<"b">> => 1 },
BobNodeOpts,
<<"ans104@1.0">>
),
{ok, BobMsgRawID} = hb_cache:write(BobMsg, BobNodeOpts),
BobMsgID = hb_util:human_id(BobMsgRawID),
%% Start RoutingNode with routes to both AliceNode and BobNode.
RoutingNode = hb_http_server:start_node(#{
priv_wallet => ar_wallet:new(),
routes => [
#{
<<"template">> => <<"^/arweave/tx">>,
<<"strategy">> => <<"Random">>,
<<"choose">> => 2,
<<"parallel">> => true,
<<"nodes">> =>
[
#{
<<"match">> => <<"/arweave/tx/">>,
<<"with">> => AliceNode
},
#{
<<"match">> => <<"/arweave/tx/">>,
<<"with">> => BobNode
}
]
}
]
}),
%% Fetch Alice's and Bob's messages via RoutingNode with admissibility check.
{ok, AliceRes} =
hb_http:get(RoutingNode, <<"~arweave@2.9/tx=", AliceMsgID/binary>>, #{}),
?assertMatch(#{ <<"a">> := 1 }, AliceRes),
{ok, BobRes} =
hb_http:get(RoutingNode, <<"~arweave@2.9/tx=", BobMsgID/binary>>, #{}),
?assertMatch(#{ <<"b">> := 1 }, BobRes),
ok.

is_admissible_hook_routed_test_() ->
{timeout, 60, fun() ->
application:ensure_all_started(hb),
TXID = <<"ptBC0UwDmrUTBQX3MqZ1lB57ex20ygwzkjjCrQjIx3o">>,
PerfProcess = <<"/perf-router~node-process@1.0">>,
SchedulePath = <<PerfProcess/binary, "/schedule">>,
RoutesPath = <<PerfProcess/binary, "/now/routes">>,
NodeWallet = ar_wallet:new(),
NodeAddr = hb_util:human_id(NodeWallet),
Opts = #{
store => hb_test_utils:test_store(),
priv_wallet => NodeWallet,
on => #{
<<"http-client">> => #{
<<"response">> => [
#{
<<"device">> => <<"relay@1.0">>,
<<"path">> => <<"call">>,
<<"method">> => <<"POST">>,
<<"relay-path">> => SchedulePath,
<<"hook/result">> => <<"ignore">>
}
]
}
},
router_opts => #{ <<"provider">> => #{ <<"path">> => RoutesPath } },
node_processes => #{
<<"perf-router">> => #{
<<"device">> => <<"process@1.0">>,
<<"execution-device">> => <<"router-perf@1.0">>,
<<"scheduler-device">> => <<"scheduler@1.0">>,
<<"performance-period">> => 2,
<<"initial-performance">> => 1000
}
},
routes => [
#{
<<"template">> => <<"^/arweave">>,
<<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES,
<<"parallel">> => true,
<<"admissible-status">> => 200
}
]
},
Node = hb_http_server:start_node(Opts),
%% Register gateways with the perf-router process.
RouteConfig = #{
<<"template">> => <<"^/arweave">>,
<<"parallel">> => true,
<<"strategy">> => <<"Random">>,
<<"choose">> => 10,
<<"admissible-status">> => 200
},
lists:foreach(
fun(GatewayNode) ->
Body =
hb_message:commit(
#{
<<"action">> => <<"register">>,
<<"route">> => maps:merge(GatewayNode, RouteConfig)
},
Opts
),
{ok, _} =
hb_http:post(
Node,
#{
<<"path">> => SchedulePath,
<<"method">> => <<"POST">>,
<<"body">> => Body
},
Opts
)
end,
?ARWEAVE_BOOTSTRAP_DATA_NODES
),
%% Trigger compute to process register messages.
{ok, _} = hb_http:get(Node, RoutesPath, Opts),
%% Verify initial performance.
PerfPath = <<PerfProcess/binary, "/now/routes/1/nodes/1/performance">>,
{ok, InitPerf} = hb_http:get(Node, PerfPath, Opts),
?assertEqual(1000.0, dev_router_perf:to_float(InitPerf)),
%% Fetch TX through the full stack.
{ok, Res} =
hb_http:get(
Node,
<<"~arweave@2.9/tx=", TXID/binary, "&exclude-data=true">>,
Opts
),
?assertMatch(#{ <<"reward">> := <<"482143296">> }, Res),
?assert(hb_message:verify(Res, all, #{})),
?assertNot(lists:member(NodeAddr, hb_message:signers(Res, #{}))),
%% Wait for async monitor duration posts, then recompute.
timer:sleep(1000),
{ok, _} = hb_http:get(Node, RoutesPath, Opts),
%% Verify performance score changed from initial value.
{ok, UpdatedPerf} = hb_http:get(Node, PerfPath, Opts),
?assertNotEqual(1000.0, dev_router_perf:to_float(UpdatedPerf)),
ok
end}.

is_admissible_real_gateway_test_() ->
{timeout, 30, fun() ->
application:ensure_all_started(hb),
TXID = <<"ptBC0UwDmrUTBQX3MqZ1lB57ex20ygwzkjjCrQjIx3o">>,
Node = hb_http_server:start_node(#{
priv_wallet => ar_wallet:new(),
routes => [
#{
<<"template">> => <<"^/arweave/tx">>,
<<"strategy">> => <<"Random">>,
<<"choose">> => 10,
<<"parallel">> => true,
<<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES
},
#{
<<"template">> => <<"^/arweave">>,
<<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES,
<<"parallel">> => true,
<<"admissible-status">> => 200
}
]
}),
{ok, Res} = hb_http:get(
Node,
<<"~arweave@2.9/tx=", TXID/binary, "&exclude-data=true">>,
#{}
),
?assertMatch(#{ <<"reward">> := <<"482143296">> }, Res),
?assertMatch(
#{ <<"anchor">> :=
<<"XTzaU2_m_hRYDLiXkcleOC4zf5MVTXIeFWBOsJSRrtEZ8kM6Oz7EKLhZY7fTAvKq">>
},
Res
),
?assertMatch(#{ <<"content-type">> := <<"application/json">> }, Res),
ok
end}.
30 changes: 30 additions & 0 deletions src/dev_chance.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
%%% @doc A generic probabilistic gate device. When accessed as
%%% `~chance@1.0/N', the key `N' is parsed as an integer rate.
%%% A 1-in-N random check is performed:
%%%
%%% {ok, Req} -- check passed, hook chain continues
%%% {error, _} -- check failed, hook chain halts
%%%
%%% This device uses the 4-arity default handler pattern so that the
%%% rate is supplied via the URL path rather than a config key.
-module(dev_chance).
-export([info/1]).
-include("include/hb.hrl").
-include_lib("eunit/include/eunit.hrl").

info(_) -> #{ default => fun default/4 }.

default(Key, _Base, Req, _Opts) ->
try binary_to_integer(Key) of
Rate when Rate > 0 ->
?event({request, {rate, Rate}}),
case rand:uniform(Rate) =:= 1 of
true -> {ok, Req};
false -> {error, <<"Filtered by chance gate.">>}
end;
_ ->
{error, <<"chance@1.0 rate must be a positive integer.">>}
catch
error:badarg ->
{error, <<"chance@1.0 rate must be a valid integer.">>}
end.
28 changes: 25 additions & 3 deletions src/dev_hook.erl
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ find(HookName, Opts) ->
find(#{}, #{ <<"target">> => <<"body">>, <<"body">> => HookName }, Opts).
find(_Base, Req, Opts) ->
HookName = maps:get(maps:get(<<"target">>, Req, <<"body">>), Req),
case maps:get(HookName, hb_opts:get(on, #{}, Opts), []) of
case hb_util:deep_get(HookName, hb_opts:get(on, #{}, Opts), [], Opts) of
Handler when is_map(Handler) ->
case hb_util:is_ordered_list(Handler, Opts) of
true ->
Expand Down Expand Up @@ -169,7 +169,7 @@ execute_handler(HookName, Handler, Req, Opts) ->
<<"method">> =>
hb_maps:get(<<"method">>, Handler, <<"GET">>, Opts)
},
CommitReqBin =
CommitReqBin =
hb_util:bin(
hb_util:deep_get(
<<"hook/commit-request">>,
Expand Down Expand Up @@ -316,4 +316,26 @@ halt_on_error_test() ->
Req = #{ <<"test">> => <<"value">> },
Opts = #{ on => #{ <<"test-hook">> => [Handler1, Handler2, Handler3] }},
{error, Result} = on(<<"test-hook">>, Req, Opts),
?assertEqual(<<"Error in handler2">>, Result).
?assertEqual(<<"Error in handler2">>, Result).

%% @doc Test that nested hook names (slash-separated) resolve correctly
nested_hook_name_test() ->
Handler = #{
<<"device">> => #{
nested_key =>
fun(_, Req, _) ->
{ok, Req#{ <<"nested_executed">> => true }}
end
},
<<"path">> => <<"nested-key">>
},
Req = #{ <<"test">> => <<"value">> },
Opts = #{
on => #{
<<"http-client">> => #{
<<"response">> => Handler
}
}
},
{ok, Result} = on(<<"http-client/response">>, Req, Opts),
?assertEqual(true, maps:get(<<"nested_executed">>, Result)).
Loading