From 0e83c3edb2aeb4764ec6a8697d6b138bdfa1a56d Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 10 Jul 2026 12:17:58 +0200 Subject: [PATCH 1/4] feat: add token shared utils in hb_util --- src/core/util/hb_util.erl | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/core/util/hb_util.erl b/src/core/util/hb_util.erl index 428b07e2d..d1d9d9b1f 100644 --- a/src/core/util/hb_util.erl +++ b/src/core/util/hb_util.erl @@ -29,10 +29,38 @@ -export([all_atoms/0, binary_is_atom/1]). -export([lower_case_keys/2]). -export([base58_encode/1, atom_to_dashed_binary/1]). +-export([ + validate_address/2, + validate_address/3, + account_key/1 +]). -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). +%% @doc `validate_address/2` built-in reserved keys list +-define(AO_RESERVED_ADDRESS_KEYS, + [ + <<"path">>, + <<"get">>, + <<"set">>, + <<"remove">>, + <<"verify">>, + <<"keys">>, + <<"id">>, + <<"commit">>, + <<"committed">>, + <<"committers">>, + <<"index">>, + <<"info">>, + <<"set_path">>, + <<"reserved_keys">>, + <<"is_reserved_key">>, + <<"dedup">>, + <<"dedup-subject">> + ] +). + %%% Simple type coercion functions, useful for quickly turning inputs from the %%% HTTP API into the correct types for the HyperBEAM runtime, if they are not %%% annotated by the user. @@ -976,6 +1004,57 @@ base58_encode_int(N) -> atom_to_dashed_binary(Key) when is_atom(Key) -> hb_util_string:dash_chars(atom_to_binary(Key)). +%%% token helper functions. + +%% @doc Validate address format for security. the validation +%% allows binary addresses up to 128 bytes and prevent invalid +%% addresses such as trie reserved keys. +validate_address(Address, CustomList) -> + validate_address(Address, CustomList, #{}). +validate_address(Address, CustomList, Opts) when is_binary(Address), is_list(CustomList) -> + ReservedKeys = ?AO_RESERVED_ADDRESS_KEYS ++ CustomList, + AccountKey = account_key(Address), + CanonicalReservedKeys = [account_key(Key) || Key <- ReservedKeys, is_binary(Key)], + case byte_size(Address) of + 0 -> {error, <<"Address cannot be empty.">>}; + N when N > 128 -> {error, <<"Address is too long.">>}; + _ -> + TrieReservedKeys = trie_reserved_keys(Opts), + maybe + true ?= (not is_reserved_trie_key(Address, TrieReservedKeys)) + orelse {error, <<"Address uses a reserved trie internal key.">>}, + true ?= (not is_reserved_trie_key(AccountKey, TrieReservedKeys)) + orelse {error, <<"Address uses a reserved trie internal key.">>}, + true ?= (not is_reserved_custom_key(Address, ReservedKeys)) + orelse {error, <<"Address is a reserved ao/custom key">>}, + true ?= (not is_reserved_custom_key(AccountKey, CanonicalReservedKeys)) + orelse {error, <<"Address is a reserved ao/custom key">>}, + % Check for path separators (security: prevent path traversal) and whitespaces. + case binary:match(Address, [<<"/">>, <<"\\">>, <<" ">>, <<"\n">>, <<"\r">>, <<"\t">>]) of + nomatch -> true; + _ -> {error, <<"Address cannot contain path separators or whitespaces">>} + end + end + end; +validate_address(_, _, _) -> + {error, <<"Address must be a binary.">>}. + +account_key(Address) when is_binary(Address) -> + to_lower(Address). + +trie_reserved_keys(Opts) -> + {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), + maps:get(reserved, Trie:info(), []). + +is_reserved_trie_key(Key, ReservedKeys) -> + lists:member(Key, ReservedKeys). + +%% @doc Check if the given Key exists in the passed List +is_reserved_custom_key(Key, List) when is_binary(Key), is_list(List) -> + lists:member(Key, List); +is_reserved_custom_key(_, _) -> + false. + %% Tests atom_to_dashed_binary_test_parallel() -> From 5100535797bb8c221e609db10ff7bad09308eb17 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Sat, 11 Jul 2026 11:18:06 +0200 Subject: [PATCH 2/4] fix: token preloaded lib --- src/core/util/hb_util.erl | 79 ----------------------------- src/preloaded/token/lib_token.erl | 83 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 79 deletions(-) create mode 100644 src/preloaded/token/lib_token.erl diff --git a/src/core/util/hb_util.erl b/src/core/util/hb_util.erl index d1d9d9b1f..428b07e2d 100644 --- a/src/core/util/hb_util.erl +++ b/src/core/util/hb_util.erl @@ -29,38 +29,10 @@ -export([all_atoms/0, binary_is_atom/1]). -export([lower_case_keys/2]). -export([base58_encode/1, atom_to_dashed_binary/1]). --export([ - validate_address/2, - validate_address/3, - account_key/1 -]). -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). -%% @doc `validate_address/2` built-in reserved keys list --define(AO_RESERVED_ADDRESS_KEYS, - [ - <<"path">>, - <<"get">>, - <<"set">>, - <<"remove">>, - <<"verify">>, - <<"keys">>, - <<"id">>, - <<"commit">>, - <<"committed">>, - <<"committers">>, - <<"index">>, - <<"info">>, - <<"set_path">>, - <<"reserved_keys">>, - <<"is_reserved_key">>, - <<"dedup">>, - <<"dedup-subject">> - ] -). - %%% Simple type coercion functions, useful for quickly turning inputs from the %%% HTTP API into the correct types for the HyperBEAM runtime, if they are not %%% annotated by the user. @@ -1004,57 +976,6 @@ base58_encode_int(N) -> atom_to_dashed_binary(Key) when is_atom(Key) -> hb_util_string:dash_chars(atom_to_binary(Key)). -%%% token helper functions. - -%% @doc Validate address format for security. the validation -%% allows binary addresses up to 128 bytes and prevent invalid -%% addresses such as trie reserved keys. -validate_address(Address, CustomList) -> - validate_address(Address, CustomList, #{}). -validate_address(Address, CustomList, Opts) when is_binary(Address), is_list(CustomList) -> - ReservedKeys = ?AO_RESERVED_ADDRESS_KEYS ++ CustomList, - AccountKey = account_key(Address), - CanonicalReservedKeys = [account_key(Key) || Key <- ReservedKeys, is_binary(Key)], - case byte_size(Address) of - 0 -> {error, <<"Address cannot be empty.">>}; - N when N > 128 -> {error, <<"Address is too long.">>}; - _ -> - TrieReservedKeys = trie_reserved_keys(Opts), - maybe - true ?= (not is_reserved_trie_key(Address, TrieReservedKeys)) - orelse {error, <<"Address uses a reserved trie internal key.">>}, - true ?= (not is_reserved_trie_key(AccountKey, TrieReservedKeys)) - orelse {error, <<"Address uses a reserved trie internal key.">>}, - true ?= (not is_reserved_custom_key(Address, ReservedKeys)) - orelse {error, <<"Address is a reserved ao/custom key">>}, - true ?= (not is_reserved_custom_key(AccountKey, CanonicalReservedKeys)) - orelse {error, <<"Address is a reserved ao/custom key">>}, - % Check for path separators (security: prevent path traversal) and whitespaces. - case binary:match(Address, [<<"/">>, <<"\\">>, <<" ">>, <<"\n">>, <<"\r">>, <<"\t">>]) of - nomatch -> true; - _ -> {error, <<"Address cannot contain path separators or whitespaces">>} - end - end - end; -validate_address(_, _, _) -> - {error, <<"Address must be a binary.">>}. - -account_key(Address) when is_binary(Address) -> - to_lower(Address). - -trie_reserved_keys(Opts) -> - {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), - maps:get(reserved, Trie:info(), []). - -is_reserved_trie_key(Key, ReservedKeys) -> - lists:member(Key, ReservedKeys). - -%% @doc Check if the given Key exists in the passed List -is_reserved_custom_key(Key, List) when is_binary(Key), is_list(List) -> - lists:member(Key, List); -is_reserved_custom_key(_, _) -> - false. - %% Tests atom_to_dashed_binary_test_parallel() -> diff --git a/src/preloaded/token/lib_token.erl b/src/preloaded/token/lib_token.erl new file mode 100644 index 000000000..2d969f434 --- /dev/null +++ b/src/preloaded/token/lib_token.erl @@ -0,0 +1,83 @@ +%%% @doc Shared token account helpers. +%%% These helpers define token balance account semantics: external IDs are +%%% validated as raw binaries, then canonicalized only when mapped onto balance +%%% trie keys. +-module(lib_token). + +-export([validate_address/2, validate_address/3, account_key/1]). + +%% @doc Built-in reserved keys for address validation. +-define(AO_RESERVED_ADDRESS_KEYS, + [ + <<"path">>, + <<"get">>, + <<"set">>, + <<"remove">>, + <<"verify">>, + <<"keys">>, + <<"id">>, + <<"commit">>, + <<"committed">>, + <<"committers">>, + <<"index">>, + <<"info">>, + <<"set_path">>, + <<"reserved_keys">>, + <<"is_reserved_key">>, + <<"dedup">>, + <<"dedup-subject">> + ] +). + +%% @doc Validate an address before it is used as a token balance account. +%% Validation checks the raw external ID and its canonical balance key form so +%% mixed-case input cannot collide with AO/trie control keys after +%% canonicalization. +validate_address(Address, CustomList) -> + validate_address(Address, CustomList, #{}). + +validate_address(Address, CustomList, Opts) when is_binary(Address), is_list(CustomList) -> + ReservedKeys = ?AO_RESERVED_ADDRESS_KEYS ++ CustomList, + AccountKey = account_key(Address), + CanonicalReservedKeys = [account_key(Key) || Key <- ReservedKeys, is_binary(Key)], + case byte_size(Address) of + 0 -> {error, <<"Address cannot be empty.">>}; + N when N > 128 -> {error, <<"Address is too long.">>}; + _ -> + TrieReservedKeys = trie_reserved_keys(Opts), + maybe + true ?= (not is_reserved_trie_key(Address, TrieReservedKeys)) + orelse {error, <<"Address uses a reserved trie internal key.">>}, + true ?= (not is_reserved_trie_key(AccountKey, TrieReservedKeys)) + orelse {error, <<"Address uses a reserved trie internal key.">>}, + true ?= (not is_reserved_custom_key(Address, ReservedKeys)) + orelse {error, <<"Address is a reserved ao/custom key">>}, + true ?= (not is_reserved_custom_key(AccountKey, CanonicalReservedKeys)) + orelse {error, <<"Address is a reserved ao/custom key">>}, + case binary:match( + Address, + [<<"/">>, <<"\\">>, <<" ">>, <<"\n">>, <<"\r">>, <<"\t">>] + ) of + nomatch -> true; + _ -> {error, <<"Address cannot contain path separators or whitespaces">>} + end + end + end; +validate_address(_, _, _) -> + {error, <<"Address must be a binary.">>}. + +%% @doc Canonical token balance trie key for an external account ID. +account_key(Address) when is_binary(Address) -> + hb_util:to_lower(Address). + +trie_reserved_keys(Opts) -> + {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), + maps:get(reserved, Trie:info(), []). + +is_reserved_trie_key(Key, ReservedKeys) -> + lists:member(Key, ReservedKeys). + +is_reserved_custom_key(Key, List) when is_binary(Key), is_list(List) -> + lists:member(Key, List); +is_reserved_custom_key(_, _) -> + false. From 9e8bda174e2900fbd2076847926cc14e40725d4e Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Wed, 15 Jul 2026 00:45:34 +0200 Subject: [PATCH 3/4] impr: switch to whitelist valid_address_chars/1 --- src/preloaded/token/lib_token.erl | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/preloaded/token/lib_token.erl b/src/preloaded/token/lib_token.erl index 2d969f434..c6f9432e9 100644 --- a/src/preloaded/token/lib_token.erl +++ b/src/preloaded/token/lib_token.erl @@ -54,13 +54,8 @@ validate_address(Address, CustomList, Opts) when is_binary(Address), is_list(Cus orelse {error, <<"Address is a reserved ao/custom key">>}, true ?= (not is_reserved_custom_key(AccountKey, CanonicalReservedKeys)) orelse {error, <<"Address is a reserved ao/custom key">>}, - case binary:match( - Address, - [<<"/">>, <<"\\">>, <<" ">>, <<"\n">>, <<"\r">>, <<"\t">>] - ) of - nomatch -> true; - _ -> {error, <<"Address cannot contain path separators or whitespaces">>} - end + true ?= valid_address_chars(Address) + orelse {error, <<"Address contains unsupported characters.">>} end end; validate_address(_, _, _) -> @@ -81,3 +76,16 @@ is_reserved_custom_key(Key, List) when is_binary(Key), is_list(List) -> lists:member(Key, List); is_reserved_custom_key(_, _) -> false. + +%% @doc Return true when every byte is in the supported account alphabet. +valid_address_chars(<<>>) -> + true; +valid_address_chars(<>) when + Char >= $A, Char =< $Z; + Char >= $a, Char =< $z; + Char >= $0, Char =< $9; + Char =:= $_; + Char =:= $- -> + valid_address_chars(Rest); +valid_address_chars(_) -> + false. \ No newline at end of file From 10c0b765cc235d52086a6bd30d2cd9d698d9deca Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 17 Jul 2026 10:47:49 +0200 Subject: [PATCH 4/4] chore: address comments --- src/preloaded/token/lib_token.erl | 61 +++++++++++++------------------ 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/src/preloaded/token/lib_token.erl b/src/preloaded/token/lib_token.erl index c6f9432e9..66ef7af30 100644 --- a/src/preloaded/token/lib_token.erl +++ b/src/preloaded/token/lib_token.erl @@ -4,56 +4,33 @@ %%% trie keys. -module(lib_token). --export([validate_address/2, validate_address/3, account_key/1]). - -%% @doc Built-in reserved keys for address validation. --define(AO_RESERVED_ADDRESS_KEYS, - [ - <<"path">>, - <<"get">>, - <<"set">>, - <<"remove">>, - <<"verify">>, - <<"keys">>, - <<"id">>, - <<"commit">>, - <<"committed">>, - <<"committers">>, - <<"index">>, - <<"info">>, - <<"set_path">>, - <<"reserved_keys">>, - <<"is_reserved_key">>, - <<"dedup">>, - <<"dedup-subject">> - ] -). +-export([validate_address/3, account_key/1]). %% @doc Validate an address before it is used as a token balance account. %% Validation checks the raw external ID and its canonical balance key form so %% mixed-case input cannot collide with AO/trie control keys after %% canonicalization. -validate_address(Address, CustomList) -> - validate_address(Address, CustomList, #{}). - validate_address(Address, CustomList, Opts) when is_binary(Address), is_list(CustomList) -> - ReservedKeys = ?AO_RESERVED_ADDRESS_KEYS ++ CustomList, AccountKey = account_key(Address), - CanonicalReservedKeys = [account_key(Key) || Key <- ReservedKeys, is_binary(Key)], + CanonicalCustomKeys = [account_key(Key) || Key <- CustomList, is_binary(Key)], case byte_size(Address) of 0 -> {error, <<"Address cannot be empty.">>}; N when N > 128 -> {error, <<"Address is too long.">>}; _ -> TrieReservedKeys = trie_reserved_keys(Opts), maybe + true ?= (AccountKey =/= <<"path">>) + orelse {error, <<"Address uses the reserved path key.">>}, + true ?= (not is_device_key(AccountKey, Opts)) + orelse {error, <<"Address uses a reserved device key.">>}, true ?= (not is_reserved_trie_key(Address, TrieReservedKeys)) orelse {error, <<"Address uses a reserved trie internal key.">>}, true ?= (not is_reserved_trie_key(AccountKey, TrieReservedKeys)) orelse {error, <<"Address uses a reserved trie internal key.">>}, - true ?= (not is_reserved_custom_key(Address, ReservedKeys)) - orelse {error, <<"Address is a reserved ao/custom key">>}, - true ?= (not is_reserved_custom_key(AccountKey, CanonicalReservedKeys)) - orelse {error, <<"Address is a reserved ao/custom key">>}, + true ?= (not is_reserved_custom_key(Address, CustomList)) + orelse {error, <<"Address is a reserved custom key.">>}, + true ?= (not is_reserved_custom_key(AccountKey, CanonicalCustomKeys)) + orelse {error, <<"Address is a reserved custom key.">>}, true ?= valid_address_chars(Address) orelse {error, <<"Address contains unsupported characters.">>} end @@ -62,9 +39,23 @@ validate_address(_, _, _) -> {error, <<"Address must be a binary.">>}. %% @doc Canonical token balance trie key for an external account ID. -account_key(Address) when is_binary(Address) -> - hb_util:to_lower(Address). +account_key(Address) -> + hb_util:to_lower(hb_ao:normalize_key(Address)). + +is_device_key(Key, Opts) -> + lists:any( + fun(Device) -> + case hb_device:message_to_fun( + #{<<"device">> => Device}, Key, Opts + ) of + {ok, _, _} -> true; + {add_key, _, _} -> false + end + end, + [<<"message@1.0">>, <<"trie@1.0">>] + ). +%% Trie metadata keys are not exposed as device operations. trie_reserved_keys(Opts) -> {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), maps:get(reserved, Trie:info(), []).