From 08a9f24141da77e3347931cc7998616e04d66c00 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 10 Jul 2026 13:17:06 +0200 Subject: [PATCH 01/23] feat: wire mint-authority@1.0 and upgrade to latest hb --- .gitignore | 2 +- README.md | 8 +- config.json | 1 + rebar.config | 11 ++- src/dev_token.erl | 93 ++------------------ src/dev_token_lib.erl | 2 +- src/preloaded/test/hb_token_props.erl | 15 +--- src/preloaded/test/hb_token_test_vectors.erl | 37 ++++++-- 8 files changed, 58 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index ca96268..20688bc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ _build/ _checkouts/ ebin/ cache-*/ -scripts/ +scripts/* debug/ logs .eunit/ diff --git a/README.md b/README.md index 6ab20ab..d5537d5 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,19 @@ signer=vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0 HB_CONFIG=config.json rebar3 device local ``` -`config.json` pins the published `process-outbox@1.0` and `security@1.0` implementations through -HyperBEAM's `trusted-devices` runtime map. +`config.json` pins the published `mint-authority@1.0`, `process-outbox@1.0`, +and `security@1.0` implementations through HyperBEAM's `trusted-devices` runtime map. src: * https://github.com/permaweb/process-outbox-1.0 * https://github.com/permaweb/security-1.0 +* https://github.com/permaweb/mint-authority ```json { "trusted-devices": { + "mint-authority@1.0": "CMZEmkcHengcoDdM_eK2inBmXHpBkm2iPIiw-r6XQPs", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" } @@ -78,4 +80,4 @@ rebar3 device publish --key wallet.json ``` ## License -this package is licensed under the [MIT License](./LICENSE) \ No newline at end of file +this package is licensed under the [MIT License](./LICENSE) diff --git a/config.json b/config.json index 48fa1bb..7fd482c 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,6 @@ { "trusted-devices": { + "mint-authority@1.0": "CMZEmkcHengcoDdM_eK2inBmXHpBkm2iPIiw-r6XQPs", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" } diff --git a/rebar.config b/rebar.config index 6066598..4e7cf14 100644 --- a/rebar.config +++ b/rebar.config @@ -1,8 +1,10 @@ {erl_opts, [debug_info]}. {deps, [ + {elmdb, {git, "https://github.com/permaweb/elmdb-rs.git", + {ref, "faa762323be6bad2db26abfa1d4243863a877f0f"}}}, {hb, {git, "https://github.com/permaweb/HyperBEAM.git", - {ref, "a0bb76ccda9fdbbc5e82008e6794e588c28d2c18"}}} + {ref, "0e83c3edb2aeb4764ec6a8697d6b138bdfa1a56d"}}} ]}. {overrides, [ @@ -13,7 +15,7 @@ {plugins, [ {plugin, {git_subdir, "https://github.com/permaweb/HyperBEAM.git", - {ref, "a0bb76ccda9fdbbc5e82008e6794e588c28d2c18"}, + {ref, "0e83c3edb2aeb4764ec6a8697d6b138bdfa1a56d"}, "src/forge"}}, {rebar3_eunit_start, {git, "https://github.com/permaweb/rebar3_eunit_start.git", @@ -31,6 +33,11 @@ [ {device, "test --with-core"} ] + }, + {'mint-authority-test', + [ + {device, "test --device-src=src,../mint-authority/src,../outbox-1.0/src,../security/src --timeout 30"} + ] } ]}. diff --git a/src/dev_token.erl b/src/dev_token.erl index fe7979d..10a185c 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -6,8 +6,6 @@ %%% as publicly callable device keys, either by having arity > 3, or by gating %%% the public surface in `info/0`. -export([handle_action/4]). -%%% Public helpers. --export([validate_address/2]). -include_lib("hb/include/hb.hrl"). -implements(<<"token@1.0">>). @@ -48,29 +46,6 @@ <<"hashpath">> ] ). -%% @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">> - ] -). - %% @doc Return the configured `set` field whitelist. Defaults to open policy %% via wildcard unless `whitelisted-fields` is explicitly restricted. whitelisted_auth_fields(Base, Opts) -> @@ -137,7 +112,7 @@ canonicalize_balances(Base, Balances, Opts) -> {Changed, FlatBalances} = lists:foldl( fun(Key, {ChangedAcc, BalancesAcc}) -> - Account = account_key(Key), + Account = hb_util:account_key(Key), {ok, Amount} = hb_ao:resolve(Balances, Key, Opts), { ChangedAcc @@ -236,8 +211,8 @@ handle_action(Action, Base, Req, Opts) -> balance(Base, Req, Opts) -> maybe {ok, Account0} ?= hb_ao:resolve(Req, <<"balance">>, Opts), - true ?= validate_address(Account0, [], Opts), - Account = account_key(Account0), + true ?= hb_util:validate_address(Account0, [], Opts), + Account = hb_util:account_key(Account0), ?event( debug_token, {balance_request, @@ -285,10 +260,10 @@ transfer(Base, Assignment, Opts) -> {ok, Recipient0} ?= hb_ao:resolve(Req, <<"recipient">>, Opts), {ok, Quantity} ?= hb_ao:resolve(Req, <<"quantity">>, Opts), % validate From/Recipient sanity - true ?= validate_address(From0, [], Opts), - true ?= validate_address(Recipient0, [], Opts), - From = account_key(From0), - Recipient = account_key(Recipient0), + true ?= hb_util:validate_address(From0, [], Opts), + true ?= hb_util:validate_address(Recipient0, [], Opts), + From = hb_util:account_key(From0), + Recipient = hb_util:account_key(Recipient0), % Normalize the base's minting state for the sender. {ok, NormBase} ?= normalize_mint( @@ -393,12 +368,12 @@ mint(Base, Assignment, Opts) -> as_mint_device(<<"mint">>, Base, Assignment, Opts); {ok, Subject} -> maybe - true ?= validate_address(Subject, [], Opts), + true ?= hb_util:validate_address(Subject, [], Opts), MintReq1 = hb_ao:set( Assignment, <<"subject">>, - account_key(Subject), + hb_util:account_key(Subject), Opts ), as_mint_device(<<"mint">>, Base, MintReq1, Opts) @@ -525,63 +500,13 @@ enforce_set_authority(Base, Req, Opts) -> %%% 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) -> - hb_util:to_lower(Address). - trie_keys(Balances, Opts) -> {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), Trie:keys(Balances, Opts). -is_reserved_trie_key(Key, ReservedKeys) -> - lists:member(Key, ReservedKeys). - -trie_reserved_keys(Opts) -> - {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), - maps:get(reserved, Trie:info(), []). - add_balance(Account, Amount, Balances) -> Balances#{ Account => maps:get(Account, Balances, 0) + Amount }. -%% @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. - outbox_send(Messages, Base, Opts) -> maybe {ok, Outbox} ?= process_outbox(Opts), diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index c264fd7..f999961 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -489,7 +489,7 @@ normalize_without_root(RootProc, Procs) -> maps:without([hb_message:id(RootProc, all)], normalize_env(Procs)). account_key(Account) when is_binary(Account) -> - hb_util:to_lower(Account). + hb_util:account_key(Account). canonical_balances(Balances) -> lists:foldl( diff --git a/src/preloaded/test/hb_token_props.erl b/src/preloaded/test/hb_token_props.erl index da47e3f..a41d79f 100644 --- a/src/preloaded/test/hb_token_props.erl +++ b/src/preloaded/test/hb_token_props.erl @@ -10,25 +10,14 @@ -define(MAX_TRANSFER_AMOUNT, 1_000_000_000_000_000_000 div 5). -define(NODE_WALLET_CACHE_KEY, {?MODULE, node_wallet}). -define(IDENTITIES_CACHE_KEY, {?MODULE, identities}). --define(PROCESS_OUTBOX_DEVICE, <<"process-outbox@1.0">>). --define(PROCESS_OUTBOX_IMPL, <<"HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo">>). --define(SECURITY_DEVICE, <<"security@1.0">>). --define(SECURITY_IMPL, <<"ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674">>). opts() -> hb:init(), #{ <<"load-remote-devices">> => false, - <<"trusted-devices">> => #{ - ?PROCESS_OUTBOX_DEVICE => ?PROCESS_OUTBOX_IMPL, - ?SECURITY_DEVICE => ?SECURITY_IMPL - }, - <<"store">> => [hb_test_utils:test_store() | default_stores()] + <<"store">> => [hb_test_utils:test_store()] }. -default_stores() -> - hb_opts:get(store, [], hb_opts:default_message()). - simulate_native_token_test_() -> {timeout, 120, fun simulate_native_token/0}. @@ -394,4 +383,4 @@ canonical_balances(Balances) -> ). account_key(Account) when is_binary(Account) -> - hb_util:to_lower(Account). + hb_util:account_key(Account). diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index e4c25eb..a22d4ef 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -7,15 +7,11 @@ opts() -> hb:init(), - {ok, Config} = hb_opts:load("config.json", #{}), - Config#{ + #{ <<"priv-wallet">> => ar_wallet:new(), - <<"store">> => [hb_test_utils:test_store() | default_stores()] + <<"store">> => [hb_test_utils:test_store()] }. -default_stores() -> - hb_opts:get(store, [], hb_opts:default_message()). - id(Bin) when is_binary(Bin) -> BitSize = byte_size(Bin) * 8, Suffix = <<0:(256 - BitSize)>>, @@ -24,7 +20,7 @@ id(Other) -> hb_util:human_id(Other). account_key(Account) -> - hb_util:to_lower(Account). + hb_util:account_key(Account). canonical_balances(Balances) -> maps:fold( @@ -494,6 +490,33 @@ fixed_supply_without_mint_device_cannot_mint_test() -> ?assertEqual(0, balance(MintRejected, Minter, Opts)), ?assertEqual(1, hb_ao:get(<<"total-supply">>, MintRejected, Opts)). +mint_authority_mint_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + } + }, + Opts + ), + {ok, Minted} = mint(Base, Authority, Recipient, 7, Opts), + [Notice] = outbox(Minted, Opts), + ?assertEqual(1, balance(Minted, Authority, Opts)), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, Minted, Opts)), + ?assertEqual(<<"Mint-Notice">>, hb_ao:get(<<"action">>, Notice, Opts)), + ?assertEqual(Recipient, hb_ao:get(<<"target">>, Notice, Opts)), + ?assertEqual(Recipient, hb_ao:get(<<"recipient">>, Notice, Opts)), + ?assertEqual(7, hb_ao:get(<<"quantity">>, Notice, Opts)). + fixed_supply_name_token_flow_test() -> Opts = opts(), Owner = id(<<"owner">>), From c3e587ec4ff51208587f3ec2f4548110f8aa6132 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 10 Jul 2026 17:41:05 +0200 Subject: [PATCH 02/23] chore: update mint-authority@1.0 device id --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 7fd482c..0496347 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "trusted-devices": { - "mint-authority@1.0": "CMZEmkcHengcoDdM_eK2inBmXHpBkm2iPIiw-r6XQPs", + "mint-authority@1.0": "XA1wCvULJ4v70tfP9Jd79bTENhyFfsK8isGSaTHc9qA", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" } From b8b9deb62a3e06540fd0a97cf4ce7224f929c9a0 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 10 Jul 2026 18:52:24 +0200 Subject: [PATCH 03/23] feat: transfer-enabled opts --- src/dev_token.erl | 9 +++ src/preloaded/test/hb_token_test_vectors.erl | 82 ++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/dev_token.erl b/src/dev_token.erl index 10a185c..e4e66ff 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -259,6 +259,7 @@ transfer(Base, Assignment, Opts) -> {ok, From0} ?= hb_ao:resolve(Req, <<"from">>, Opts), {ok, Recipient0} ?= hb_ao:resolve(Req, <<"recipient">>, Opts), {ok, Quantity} ?= hb_ao:resolve(Req, <<"quantity">>, Opts), + true ?= transfer_enabled(Base, Opts), % validate From/Recipient sanity true ?= hb_util:validate_address(From0, [], Opts), true ?= hb_util:validate_address(Recipient0, [], Opts), @@ -332,6 +333,14 @@ transfer(Base, Assignment, Opts) -> send_error(Base, Assignment, Reason, Opts) end. +transfer_enabled(Base, Opts) -> + Default = hb_opts:get(<<"transfer-enabled">>, true, Opts), + case hb_ao:get(<<"transfer-enabled">>, Base, Default, Opts) of + true -> true; + false -> {error, <<"Transfers are disabled.">>}; + _ -> {error, <<"Invalid `transfer-enabled` type.">>} + end. + transfer_notices(From, Recipient, Quantity, Req, Opts) -> % Extract forwarded keys (X- prefixed fields from request) ForwardedKeys = forwarded_keys(Req, Opts), diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index a22d4ef..19450eb 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -299,6 +299,88 @@ basic_transfer_updates_balances_test() -> lists:sort([hb_ao:get(<<"action">>, Notice, Opts) || Notice <- Notices]) ). +transfer_enabled_defaults_to_opts_test() -> + Opts = (opts())#{ <<"transfer-enabled">> => false }, + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ initial_balances => #{ Alice => 5 } }, + Opts + ), + {ok, Updated} = transfer(Base, Alice, Bob, 1, Opts), + ?assertEqual(5, balance(Updated, Alice, Opts)), + ?assertEqual(0, balance(Updated, Bob, Opts)), + [Notice] = outbox(Updated, Opts), + ?assertEqual(Alice, hb_ao:get(<<"target">>, Notice, Opts)), + ?assertEqual(<<"Transfers are disabled.">>, hb_ao:get(<<"reason">>, Notice, Opts)). + +transfer_enabled_state_overrides_opts_test() -> + Opts = (opts())#{ <<"transfer-enabled">> => false }, + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 5 }, + extra => #{ <<"transfer-enabled">> => true } + }, + Opts + ), + {ok, Updated} = transfer(Base, Alice, Bob, 1, Opts), + ?assertEqual(4, balance(Updated, Alice, Opts)), + ?assertEqual(1, balance(Updated, Bob, Opts)). + +transfer_disabled_state_rejects_transfer_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 5 }, + extra => #{ <<"transfer-enabled">> => false } + }, + Opts + ), + {ok, Updated} = transfer(Base, Alice, Bob, 1, Opts), + ?assertEqual(5, balance(Updated, Alice, Opts)), + ?assertEqual(0, balance(Updated, Bob, Opts)), + [Notice] = outbox(Updated, Opts), + ?assertEqual(Alice, hb_ao:get(<<"target">>, Notice, Opts)), + ?assertEqual(<<"Transfers are disabled.">>, hb_ao:get(<<"reason">>, Notice, Opts)). + +set_authority_can_toggle_transfer_enabled_test() -> + Opts = opts(), + Setter = id(<<"setter">>), + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 5 }, + extra => + #{ + <<"set-authority">> => Setter, + <<"transfer-enabled">> => false, + <<"whitelisted-fields">> => [<<"transfer-enabled">>] + } + }, + Opts + ), + {ok, StillDisabled} = transfer(Base, Alice, Bob, 1, Opts), + ?assertEqual(5, balance(StillDisabled, Alice, Opts)), + {ok, Enabled} = + set_field(Base, Setter, #{ <<"transfer-enabled">> => true }, Opts), + {ok, Transferred} = transfer(Enabled, Alice, Bob, 1, Opts), + ?assertEqual(4, balance(Transferred, Alice, Opts)), + ?assertEqual(1, balance(Transferred, Bob, Opts)), + {ok, DisabledAgain} = + set_field(Transferred, Setter, #{ <<"transfer-enabled">> => false }, Opts), + {ok, Rejected} = transfer(DisabledAgain, Alice, Bob, 1, Opts), + ?assertEqual(4, balance(Rejected, Alice, Opts)), + ?assertEqual(1, balance(Rejected, Bob, Opts)). + mixed_case_transfer_updates_canonical_balances_test() -> Opts = opts(), Alice = id(<<"Alice">>), From 5fb38606e6fde204335975b7ca7575ec85c5f18a Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Sat, 11 Jul 2026 12:36:35 +0200 Subject: [PATCH 04/23] feat: adapt to hb token lib --- README.md | 6 +- config.json | 4 +- rebar.config | 6 +- src/dev_token.erl | 19 ++--- src/dev_token_lib.erl | 2 +- src/preloaded/test/hb_token_props.erl | 42 ++++++++++- src/preloaded/test/hb_token_test_vectors.erl | 73 +++++++++++++++++++- 7 files changed, 132 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d5537d5..925d797 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ ```sh rebar3 compile -rebar3 device verify -rebar3 device package +rebar3 device verify --device-src=src,_build/default/lib/hb/src/preloaded/token +rebar3 device package --device-src=src,_build/default/lib/hb/src/preloaded/token ``` ## test @@ -76,7 +76,7 @@ calls the token device against the raw published item and bypasses ## publish ```sh -rebar3 device publish --key wallet.json +rebar3 device publish --device-src=src,_build/default/lib/hb/src/preloaded/token --key wallet.json ``` ## License diff --git a/config.json b/config.json index 0496347..2d529a0 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "trusted-devices": { - "mint-authority@1.0": "XA1wCvULJ4v70tfP9Jd79bTENhyFfsK8isGSaTHc9qA", + "mint-authority@1.0": "kR-AuG3o2-o10VF-QfTgMLGsP1uPo5ezE9VeE4EjbyU", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" + "security@1.0": "b7Vxz1lKhQgMQYXPizAc3N3bFMU8h8U5meoECG265qc" } } diff --git a/rebar.config b/rebar.config index 4e7cf14..02af3f4 100644 --- a/rebar.config +++ b/rebar.config @@ -4,7 +4,7 @@ {elmdb, {git, "https://github.com/permaweb/elmdb-rs.git", {ref, "faa762323be6bad2db26abfa1d4243863a877f0f"}}}, {hb, {git, "https://github.com/permaweb/HyperBEAM.git", - {ref, "0e83c3edb2aeb4764ec6a8697d6b138bdfa1a56d"}}} + {ref, "5100535797bb8c221e609db10ff7bad09308eb17"}}} ]}. {overrides, [ @@ -15,7 +15,7 @@ {plugins, [ {plugin, {git_subdir, "https://github.com/permaweb/HyperBEAM.git", - {ref, "0e83c3edb2aeb4764ec6a8697d6b138bdfa1a56d"}, + {ref, "5100535797bb8c221e609db10ff7bad09308eb17"}, "src/forge"}}, {rebar3_eunit_start, {git, "https://github.com/permaweb/rebar3_eunit_start.git", @@ -36,7 +36,7 @@ }, {'mint-authority-test', [ - {device, "test --device-src=src,../mint-authority/src,../outbox-1.0/src,../security/src --timeout 30"} + {device, "test --device-src=src,_build/default/lib/hb/src/preloaded/token,../mint-authority/src,../outbox-1.0/src,../security/src --timeout 30"} ] } ]}. diff --git a/src/dev_token.erl b/src/dev_token.erl index e4e66ff..afe9fcc 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -9,6 +9,7 @@ -include_lib("hb/include/hb.hrl"). -implements(<<"token@1.0">>). +-device_libraries([lib_token]). -define(PROCESS_OUTBOX_DEVICE, <<"process-outbox@1.0">>). @@ -112,7 +113,7 @@ canonicalize_balances(Base, Balances, Opts) -> {Changed, FlatBalances} = lists:foldl( fun(Key, {ChangedAcc, BalancesAcc}) -> - Account = hb_util:account_key(Key), + Account = lib_token:account_key(Key), {ok, Amount} = hb_ao:resolve(Balances, Key, Opts), { ChangedAcc @@ -211,8 +212,8 @@ handle_action(Action, Base, Req, Opts) -> balance(Base, Req, Opts) -> maybe {ok, Account0} ?= hb_ao:resolve(Req, <<"balance">>, Opts), - true ?= hb_util:validate_address(Account0, [], Opts), - Account = hb_util:account_key(Account0), + true ?= lib_token:validate_address(Account0, [], Opts), + Account = lib_token:account_key(Account0), ?event( debug_token, {balance_request, @@ -261,10 +262,10 @@ transfer(Base, Assignment, Opts) -> {ok, Quantity} ?= hb_ao:resolve(Req, <<"quantity">>, Opts), true ?= transfer_enabled(Base, Opts), % validate From/Recipient sanity - true ?= hb_util:validate_address(From0, [], Opts), - true ?= hb_util:validate_address(Recipient0, [], Opts), - From = hb_util:account_key(From0), - Recipient = hb_util:account_key(Recipient0), + true ?= lib_token:validate_address(From0, [], Opts), + true ?= lib_token:validate_address(Recipient0, [], Opts), + From = lib_token:account_key(From0), + Recipient = lib_token:account_key(Recipient0), % Normalize the base's minting state for the sender. {ok, NormBase} ?= normalize_mint( @@ -377,12 +378,12 @@ mint(Base, Assignment, Opts) -> as_mint_device(<<"mint">>, Base, Assignment, Opts); {ok, Subject} -> maybe - true ?= hb_util:validate_address(Subject, [], Opts), + true ?= lib_token:validate_address(Subject, [], Opts), MintReq1 = hb_ao:set( Assignment, <<"subject">>, - hb_util:account_key(Subject), + lib_token:account_key(Subject), Opts ), as_mint_device(<<"mint">>, Base, MintReq1, Opts) diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index f999961..b45dc37 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -489,7 +489,7 @@ normalize_without_root(RootProc, Procs) -> maps:without([hb_message:id(RootProc, all)], normalize_env(Procs)). account_key(Account) when is_binary(Account) -> - hb_util:account_key(Account). + lib_token:account_key(Account). canonical_balances(Balances) -> lists:foldl( diff --git a/src/preloaded/test/hb_token_props.erl b/src/preloaded/test/hb_token_props.erl index a41d79f..e3f17b1 100644 --- a/src/preloaded/test/hb_token_props.erl +++ b/src/preloaded/test/hb_token_props.erl @@ -12,12 +12,52 @@ -define(IDENTITIES_CACHE_KEY, {?MODULE, identities}). opts() -> + ensure_lib_token(), hb:init(), #{ <<"load-remote-devices">> => false, <<"store">> => [hb_test_utils:test_store()] }. +ensure_lib_token() -> + case code:ensure_loaded(lib_token) of + {module, lib_token} -> + ok; + {error, _} -> + Source = lib_token_source(), + case compile:file( + Source, + [ + debug_info, + binary, + {i, "src"}, + {i, "_build/default/lib/hb/src"}, + {i, "_build/default/lib/hb/include"} + ] + ) of + {ok, lib_token, Beam} -> + case code:load_binary(lib_token, Source, Beam) of + {module, lib_token} -> ok; + {error, already_loaded} -> ok; + Other -> erlang:error({lib_token_load_failed, Other}) + end; + Other -> + erlang:error({lib_token_compile_failed, Other}) + end + end. + +lib_token_source() -> + Candidates = + [filename:join(["_build/default/lib/hb", "src", "preloaded", "token", "lib_token.erl"])] ++ + case code:lib_dir(hb) of + {error, _} -> []; + HBDir -> [filename:join([HBDir, "src", "preloaded", "token", "lib_token.erl"])] + end, + case lists:dropwhile(fun(Path) -> not filelib:is_regular(Path) end, Candidates) of + [Path | _] -> Path; + [] -> filename:join(["_build/default/lib/hb", "src", "preloaded", "token", "lib_token.erl"]) + end. + simulate_native_token_test_() -> {timeout, 120, fun simulate_native_token/0}. @@ -383,4 +423,4 @@ canonical_balances(Balances) -> ). account_key(Account) when is_binary(Account) -> - hb_util:account_key(Account). + hb_util:to_lower(Account). diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 19450eb..a9f4fc2 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -6,6 +6,7 @@ -define(PROCESS_OUTBOX_DEVICE, <<"process-outbox@1.0">>). opts() -> + ensure_lib_token(), hb:init(), #{ <<"priv-wallet">> => ar_wallet:new(), @@ -20,7 +21,46 @@ id(Other) -> hb_util:human_id(Other). account_key(Account) -> - hb_util:account_key(Account). + hb_util:to_lower(Account). + +ensure_lib_token() -> + case code:ensure_loaded(lib_token) of + {module, lib_token} -> + ok; + {error, _} -> + Source = lib_token_source(), + case compile:file( + Source, + [ + debug_info, + binary, + {i, "src"}, + {i, "_build/default/lib/hb/src"}, + {i, "_build/default/lib/hb/include"} + ] + ) of + {ok, lib_token, Beam} -> + case code:load_binary(lib_token, Source, Beam) of + {module, lib_token} -> ok; + {error, already_loaded} -> ok; + Other -> erlang:error({lib_token_load_failed, Other}) + end; + Other -> + erlang:error({lib_token_compile_failed, Other}) + end + end. + +lib_token_source() -> + Candidates = + [filename:join(["_build/default/lib/hb", "src", "preloaded", "token", "lib_token.erl"])] ++ + case code:lib_dir(hb) of + {error, _} -> []; + HBDir -> [filename:join([HBDir, "src", "preloaded", "token", "lib_token.erl"])] + end, + case lists:dropwhile(fun(Path) -> not filelib:is_regular(Path) end, Candidates) of + [Path | _] -> Path; + [] -> filename:join(["_build/default/lib/hb", "src", "preloaded", "token", "lib_token.erl"]) + end. canonical_balances(Balances) -> maps:fold( @@ -381,6 +421,37 @@ set_authority_can_toggle_transfer_enabled_test() -> ?assertEqual(4, balance(Rejected, Alice, Opts)), ?assertEqual(1, balance(Rejected, Bob, Opts)). +set_authority_m_of_n_can_toggle_transfer_enabled_test() -> + Opts = opts(), + AdminA = id(<<"admin-a">>), + AdminB = id(<<"admin-b">>), + AdminC = id(<<"admin-c">>), + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 5 }, + extra => + #{ + <<"set-authority">> => [AdminA, AdminB, AdminC], + <<"set-authority-match">> => 2, + <<"transfer-enabled">> => false, + <<"whitelisted-fields">> => [<<"transfer-enabled">>] + } + }, + Opts + ), + ?assertEqual( + {error, <<"Too few acceptable committers present.">>}, + set_field(Base, AdminA, #{ <<"transfer-enabled">> => true }, Opts) + ), + {ok, Enabled} = + set_field(Base, [AdminA, AdminB], #{ <<"transfer-enabled">> => true }, Opts), + {ok, Transferred} = transfer(Enabled, Alice, Bob, 1, Opts), + ?assertEqual(4, balance(Transferred, Alice, Opts)), + ?assertEqual(1, balance(Transferred, Bob, Opts)). + mixed_case_transfer_updates_canonical_balances_test() -> Opts = opts(), Alice = id(<<"Alice">>), From 53dcca9b3159227c9118bd82062349ff52126058 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Sat, 11 Jul 2026 18:49:29 +0200 Subject: [PATCH 05/23] fix: ignore non-balance trie keys during balance canonicalization --- src/dev_token.erl | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index afe9fcc..c6d7932 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -113,14 +113,23 @@ canonicalize_balances(Base, Balances, Opts) -> {Changed, FlatBalances} = lists:foldl( fun(Key, {ChangedAcc, BalancesAcc}) -> - Account = lib_token:account_key(Key), - {ok, Amount} = hb_ao:resolve(Balances, Key, Opts), - { - ChangedAcc - orelse (Account =/= Key) - orelse maps:is_key(Account, BalancesAcc), - add_balance(Account, Amount, BalancesAcc) - } + case lib_token:validate_address(Key, [], Opts) of + true -> + Account = lib_token:account_key(Key), + case hb_ao:resolve(Balances, Key, Opts) of + {ok, Amount} when is_integer(Amount) -> + { + ChangedAcc + orelse (Account =/= Key) + orelse maps:is_key(Account, BalancesAcc), + add_balance(Account, Amount, BalancesAcc) + }; + _ -> + {ChangedAcc, BalancesAcc} + end; + {error, _} -> + {ChangedAcc, BalancesAcc} + end end, {false, #{}}, trie_keys(Balances, Opts) From 8256435da5cc79cc0032292205c2b0a79c686c5a Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Sun, 12 Jul 2026 11:35:23 +0200 Subject: [PATCH 06/23] chore: update published token IDs --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 925d797..74a08b5 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ rebar3 eunit-all ## published package ```bash -device publish: token@1.0 +Published device: token@1.0; -spec=ibZf_Tqk4omTejmBIgCI6HCvp5HWYlgKhjkDMZPh4UY +Specification ID: 1L0E5HKuNtcju4Qg8A8l8MsSPOTInLfK4G9w-oLjIRY; -impl=y8vkutjPnDdfOLP2ISSnj1eOBw9x0jTddgY4dhVpWrQ +Implementation ID: CBJEqrdujJE-aNOLjHeYpEQW5Po26ZQqnTHFln4ovIY; -signer=vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0 +Signer: vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0; ``` ## local node From ffbabdc5115fd437fa98c56029cf8d6a9e4f1bb0 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Mon, 13 Jul 2026 12:48:28 +0200 Subject: [PATCH 07/23] impr(security): path=compute enforcement route + authority-actions --- src/dev_token.erl | 80 +++- src/dev_token_lib.erl | 3 +- ...b_token_process_scheduler_test_vectors.erl | 394 ++++++++++++++++++ src/preloaded/test/hb_token_test_vectors.erl | 84 ++++ 4 files changed, 544 insertions(+), 17 deletions(-) create mode 100644 src/preloaded/test/hb_token_process_scheduler_test_vectors.erl diff --git a/src/dev_token.erl b/src/dev_token.erl index c6d7932..a4838f3 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -1,10 +1,17 @@ %%% @doc A fast, simple implementation of AO token specification. %%% Specification: https://cookbook_ao.arweave.net/references/api/token.html -module(dev_token). --export([info/0, compute/3, init/3, normalize/3, snapshot/3, balance/3, mint/3]). -%%% Non-public device API functions. Note: Ensure that these are not exported -%%% as publicly callable device keys, either by having arity > 3, or by gating -%%% the public surface in `info/0`. +-export([ + info/0, + route/4, + compute/3, + init/3, + normalize/3, + snapshot/3, + balance/3, + mint/3 +]). +%%% Non-public device API functions. Note: Ensure that these are not exported. -export([handle_action/4]). -include_lib("hb/include/hb.hrl"). @@ -71,19 +78,60 @@ end. %%% `~process@1.0' interface implementation. -%% @doc Return the public token device API. +%% @doc Return the public token device API. All resolutions pass through +%% `route/4' so scheduled assignments cannot use the message-device fallback +%% to execute a state path directly. info() -> - #{ - exports => - [ - <<"compute">>, - <<"init">>, - <<"normalize">>, - <<"snapshot">>, - <<"balance">>, - <<"mint">> - ] - }. + #{ handler => fun route/4 }. + +%% @doc Route token calls. Scheduler assignments are state transitions and +%% must enter through exactly `/compute'; lifecycle calls and ordinary reads +%% retain the existing public device behavior. +route(Key, Base, Req, Opts) -> + case is_assignment(Req, Opts) andalso not is_compute_path(Req, Opts) of + true -> + ?event( + token_short, + {ignoring_non_compute_assignment, + {path, hb_path:from_message(request, Req, Opts)}}, + Opts + ), + {ok, Base}; + false -> + NormKey = hb_util:to_lower(hb_ao:normalize_key(Key)), + route_allowed(NormKey, Key, Base, Req, Opts) + end. + +route_allowed(<<"compute">>, _Key, Base, Req, Opts) -> compute(Base, Req, Opts); +route_allowed(<<"init">>, _Key, Base, Req, Opts) -> init(Base, Req, Opts); +route_allowed(<<"normalize">>, _Key, Base, Req, Opts) -> normalize(Base, Req, Opts); +route_allowed(<<"snapshot">>, _Key, Base, Req, Opts) -> snapshot(Base, Req, Opts); +route_allowed(<<"balance">>, _Key, Base, Req, Opts) -> balance(Base, Req, Opts); +route_allowed(<<"mint">>, _Key, Base, Req, Opts) -> mint(Base, Req, Opts); +route_allowed(_NormKey, Key, Base, Req, Opts) -> + hb_ao:raw(<<"message@1.0">>, Key, Base, Req, Opts). + +is_assignment(Req, Opts) -> + case hb_maps:get(<<"type">>, Req, undefined, Opts) of + undefined -> + is_assignment_envelope(Req); + Type -> + hb_path:matches(Type, <<"assignment">>) orelse + is_assignment_envelope(Req) + end. + +is_assignment_envelope(Req) when is_map(Req) -> + maps:is_key(<<"slot">>, Req) andalso + maps:is_key(<<"process">>, Req) andalso + maps:is_key(<<"body">>, Req); +is_assignment_envelope(_Req) -> + false. + +is_compute_path(Req, Opts) -> + case hb_path:from_message(request, Req, Opts) of + [Path] -> hb_path:matches(Path, <<"compute">>); + _ -> false + end. %% @doc Canonicalize account keys in the initial balance trie. init(Base, _Req, Opts) -> diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index b45dc37..5df22fc 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -48,7 +48,8 @@ ledger(Extra, Opts) -> <<"type">> => <<"Process">>, <<"scheduler-device">> => <<"scheduler@1.0">>, <<"scheduler">> => hb_util:human_id(HostWallet), - <<"authority">> => hb_util:human_id(HostWallet) + <<"authority">> => hb_util:human_id(HostWallet), + <<"authority-actions">> => [<<"Transfer">>] }, ModExtra ), diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl new file mode 100644 index 0000000..1deb03b --- /dev/null +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -0,0 +1,394 @@ +%%% @doc End-to-end token vectors through process scheduling and computation. +-module(hb_token_process_scheduler_test_vectors). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("hb/include/hb.hrl"). + +opts() -> + ensure_lib_token(), + hb:init(), + #{ + <<"load-remote-devices">> => false, + <<"priv-wallet">> => ar_wallet:new(), + dev_security_mode => prod, + <<"store">> => [hb_test_utils:test_store()] + }. + +ensure_lib_token() -> + case code:ensure_loaded(lib_token) of + {module, lib_token} -> + ok; + {error, _} -> + Source = lib_token_source(), + case compile:file( + Source, + [ + debug_info, + binary, + {i, "src"}, + {i, "_build/default/lib/hb/src"}, + {i, "_build/default/lib/hb/include"} + ] + ) of + {ok, lib_token, Beam} -> + case code:load_binary(lib_token, Source, Beam) of + {module, lib_token} -> ok; + {error, already_loaded} -> ok; + Other -> erlang:error({lib_token_load_failed, Other}) + end; + Other -> + erlang:error({lib_token_compile_failed, Other}) + end + end. + +lib_token_source() -> + Candidates = + [ + filename:join( + [ + "_build/default/lib/hb", + "src", + "preloaded", + "token", + "lib_token.erl" + ] + ) + ] ++ + case code:lib_dir(hb) of + {error, _} -> []; + HBDir -> + [ + filename:join( + [HBDir, "src", "preloaded", "token", "lib_token.erl"] + ) + ] + end, + case lists:dropwhile(fun(Path) -> not filelib:is_regular(Path) end, Candidates) of + [Path | _] -> Path; + [] -> + filename:join( + [ + "_build/default/lib/hb", + "src", + "preloaded", + "token", + "lib_token.erl" + ] + ) + end. + +signer() -> + Wallet = ar_wallet:new(), + {hb_util:human_id(ar_wallet:to_address(Wallet)), Wallet}. + +process_id(Process, Opts) -> + ProcMsg = + case hb_ao:get( + <<"process">>, + Process, + not_found, + Opts#{ <<"hashpath">> => ignore } + ) of + not_found -> + {ok, Committed} = hb_message:with_only_committed(Process, Opts), + Committed; + Committed -> + Committed + end, + true = hb_message:verify(ProcMsg, all, Opts), + [_ | _] = hb_message:signers(ProcMsg, Opts), + hb_message:id(ProcMsg, signed, Opts). + +sign_body(Process, Body, RawWallets, Opts) -> + Wallets = case RawWallets of + List when is_list(List) -> List; + Wallet -> [Wallet] + end, + Message = + maps:merge( + #{ + <<"type">> => <<"Message">>, + <<"target">> => process_id(Process, Opts) + }, + Body + ), + Signed = + lists:foldl( + fun(Wallet, Msg) -> + hb_message:commit(Msg, Opts#{ <<"priv-wallet">> => Wallet }) + end, + Message, + Wallets + ), + true = hb_message:verify(Signed, signers, Opts), + {ok, _} = hb_cache:write(Signed, Opts), + Signed. + +schedule(Process, SignedBody, RequestWallet, Opts) -> + UserOpts = Opts#{ <<"priv-wallet">> => RequestWallet }, + SignedReq0 = + hb_message:commit( + #{ + <<"path">> => <<"schedule">>, + <<"method">> => <<"POST">>, + <<"body">> => SignedBody + }, + UserOpts + ), + % Restore nested commitment metadata in the same form loaded from cache by + % the scheduler. This does not alter the outer request's signed content. + SignedReq = SignedReq0#{ <<"body">> => SignedBody }, + true = hb_message:verify(SignedReq, signers, Opts), + {ok, Scheduled} = hb_ao:resolve(Process, SignedReq, Opts), + {hb_util:int(hb_ao:get(<<"slot">>, Scheduled, Opts)), Scheduled}. + +schedule_and_compute(Process, Body, BodyWallets, RequestWallet, Opts) -> + SignedBody = sign_body(Process, Body, BodyWallets, Opts), + {Slot, _Scheduled} = schedule(Process, SignedBody, RequestWallet, Opts), + {ok, Computed} = + hb_ao:resolve( + Process, + #{ <<"path">> => <<"compute">>, <<"slot">> => Slot }, + Opts + ), + {Slot, Computed}. + +balance(Process, Account, Opts) -> + dev_token_lib:balance(Process, Account, Opts). + +state_field(Process, Key, Default, Opts) -> + hb_ao:get(<<"now/", Key/binary>>, Process, Default, Opts). + +scheduled_paths_are_noops_and_process_continues_test_() -> + {timeout, 120, fun scheduled_paths_are_noops_and_process_continues/0}. + +scheduled_paths_are_noops_and_process_continues() -> + Opts = opts(), + {Sender, SenderWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + {UnauthorizedCaller, UnauthorizedWallet} = signer(), + {MintAuthority, _MintAuthorityWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"balances">> => #{ Sender => 10 }, + <<"total-supply">> => 10 + }, + Opts + ), + RejectedRequests = + [ + #{ + <<"path">> => <<"mint">>, + <<"from">> => MintAuthority, + <<"mode">> => <<"single">>, + <<"recipient">> => UnauthorizedCaller, + <<"quantity">> => 100 + }, + #{ <<"path">> => <<"balances">> }, + #{ <<"path">> => <<"compute/balances">> } + ], + {Process3, 3} = + lists:foldl( + fun(Request, {Process, ExpectedSlot}) -> + {ExpectedSlot, Computed} = + schedule_and_compute( + Process, + Request, + UnauthorizedWallet, + UnauthorizedWallet, + Opts + ), + ?assertEqual(ExpectedSlot, hb_ao:get(<<"at-slot">>, Computed, Opts)), + ?assertEqual(10, balance(Computed, Sender, Opts)), + ?assertEqual(0, balance(Computed, UnauthorizedCaller, Opts)), + ?assertEqual(10, state_field(Computed, <<"total-supply">>, 0, Opts)), + {Computed, ExpectedSlot + 1} + end, + {Process0, 0}, + RejectedRequests + ), + {3, Process4} = + schedule_and_compute( + Process3, + #{ + <<"action">> => <<"Transfer">>, + <<"recipient">> => Recipient, + <<"quantity">> => 1 + }, + SenderWallet, + SenderWallet, + Opts + ), + ?assertEqual(3, hb_ao:get(<<"at-slot">>, Process4, Opts)), + ?assertEqual(9, balance(Process4, Sender, Opts)), + ?assertEqual(1, balance(Process4, Recipient, Opts)), + ?assertEqual(0, balance(Process4, UnauthorizedCaller, Opts)), + ?assertEqual(10, state_field(Process4, <<"total-supply">>, 0, Opts)). + +two_of_three_set_succeeds_through_scheduler_test_() -> + {timeout, 120, fun two_of_three_set_succeeds_through_scheduler/0}. + +two_of_three_set_succeeds_through_scheduler() -> + Opts = opts(), + {AdminA, AdminAWallet} = signer(), + {AdminB, AdminBWallet} = signer(), + {AdminC, _AdminCWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"set-authority">> => [AdminA, AdminB, AdminC], + <<"set-authority-match">> => 2, + <<"whitelisted-fields">> => + [<<"transfer-enabled">>, <<"authority">>, <<"authority-actions">>], + <<"authority-actions">> => [<<"Transfer">>], + <<"transfer-enabled">> => false, + <<"balances">> => #{ AdminA => 1 }, + <<"total-supply">> => 1 + }, + Opts + ), + {0, Updated} = + schedule_and_compute( + Process0, + #{ + <<"action">> => <<"Set">>, + <<"transfer-enabled">> => true, + <<"authority">> => [AdminC], + <<"authority-actions">> => [<<"Transfer">>, <<"Subscribe">>] + }, + [AdminAWallet, AdminBWallet], + AdminAWallet, + Opts + ), + ?assertEqual(0, hb_ao:get(<<"at-slot">>, Updated, Opts)), + ?assertEqual( + true, + state_field(Updated, <<"transfer-enabled">>, not_found, Opts) + ), + ?assertEqual( + [<<"Transfer">>, <<"Subscribe">>], + state_field(Updated, <<"authority-actions">>, not_found, Opts) + ), + ?assertEqual( + [AdminC], + state_field(Updated, <<"authority">>, not_found, Opts) + ). + +wallet_only_mint_authority_through_scheduler_test_() -> + {timeout, 120, fun wallet_only_mint_authority_through_scheduler/0}. + +wallet_only_mint_authority_through_scheduler() -> + Opts = opts(), + {MintAuthority, MintAuthorityWallet} = signer(), + {Recipient, NonAuthorityWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"balances">> => #{ MintAuthority => 1 }, + <<"total-supply">> => 1 + }, + Opts + ), + {0, Rejected} = + schedule_and_compute( + Process0, + #{ + <<"action">> => <<"Mint">>, + <<"from-process">> => MintAuthority, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 100 + }, + NonAuthorityWallet, + NonAuthorityWallet, + Opts + ), + ?assertEqual(0, balance(Rejected, Recipient, Opts)), + ?assertEqual(1, state_field(Rejected, <<"total-supply">>, 0, Opts)), + {1, Minted} = + schedule_and_compute( + Rejected, + #{ + <<"action">> => <<"Mint">>, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 7 + }, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, state_field(Minted, <<"total-supply">>, 0, Opts)). + +delegated_action_allowlist_through_scheduler_test_() -> + {timeout, 120, fun delegated_action_allowlist_through_scheduler/0}. + +delegated_action_allowlist_through_scheduler() -> + Opts = opts(), + SchedulerWallet = maps:get(<<"priv-wallet">>, Opts), + Scheduler = hb_util:human_id(ar_wallet:to_address(SchedulerWallet)), + {Dex, _DexWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + {MintAuthority, _MintAuthorityWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [Scheduler], + <<"authority-match">> => 1, + <<"authority-actions">> => [<<"Transfer">>], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"balances">> => #{ Dex => 10 }, + <<"total-supply">> => 10 + }, + Opts + ), + {0, Transferred} = + schedule_and_compute( + Process0, + #{ + <<"action">> => <<"Transfer">>, + <<"from-process">> => Dex, + <<"recipient">> => Recipient, + <<"quantity">> => 3 + }, + SchedulerWallet, + SchedulerWallet, + Opts + ), + ?assertEqual(7, balance(Transferred, Dex, Opts)), + ?assertEqual(3, balance(Transferred, Recipient, Opts)), + ?assertEqual(10, state_field(Transferred, <<"total-supply">>, 0, Opts)), + {1, RejectedMint} = + schedule_and_compute( + Transferred, + #{ + <<"action">> => <<"Mint">>, + <<"from-process">> => MintAuthority, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 100 + }, + SchedulerWallet, + SchedulerWallet, + Opts + ), + ?assertEqual(7, balance(RejectedMint, Dex, Opts)), + ?assertEqual(3, balance(RejectedMint, Recipient, Opts)), + ?assertEqual(10, state_field(RejectedMint, <<"total-supply">>, 0, Opts)). diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index a9f4fc2..3a524a1 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -203,6 +203,7 @@ mint(State, From, Recipient, Quantity, Opts) -> #{ <<"body">> => #{ + <<"action">> => <<"Mint">>, <<"from">> => From, <<"recipient">> => Recipient, <<"quantity">> => Quantity @@ -242,6 +243,50 @@ balance_existing_account_test() -> ), ?assertEqual({ok, 7}, public_balance(Base, Alice, Opts)). +scheduled_non_compute_paths_rejected_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Base = token_state(#{ initial_balances => #{ Alice => 7 } }, Opts), + lists:foreach( + fun(Path) -> + {ok, Ignored} = + hb_ao:resolve( + Base, + #{ + <<"path">> => Path, + <<"type">> => <<"Assignment">>, + <<"slot">> => 0, + <<"body">> => #{} + }, + Opts + ), + ?assertEqual(7, balance(Ignored, Alice, Opts)), + ?assertEqual(7, hb_ao:get(<<"total-supply">>, Ignored, Opts)) + end, + [<<"mint">>, <<"balances">>, <<"compute/balances">>] + ), + {ok, IgnoredEnvelope} = + hb_ao:resolve( + Base, + #{ + <<"path">> => <<"mint">>, + <<"slot">> => 0, + <<"process">> => id(<<"process">>), + <<"body">> => #{} + }, + Opts + ), + ?assertEqual(7, balance(IgnoredEnvelope, Alice, Opts)), + ?assertMatch({ok, _}, hb_ao:resolve(Base, <<"balances">>, Opts)), + ?assertEqual( + {ok, 7}, + hb_ao:resolve( + Base, + #{ <<"path">> => <<"balance">>, <<"balance">> => Alice }, + Opts + ) + ). + mixed_case_initial_balance_uses_canonical_account_test() -> Opts = opts(), Alice = id(<<"Alice">>), @@ -670,6 +715,45 @@ mint_authority_mint_test() -> ?assertEqual(Recipient, hb_ao:get(<<"recipient">>, Notice, Opts)), ?assertEqual(7, hb_ao:get(<<"quantity">>, Notice, Opts)). +mint_authority_requires_mint_body_action_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + } + }, + Opts + ), + Body = + #{ + <<"from">> => Authority, + <<"recipient">> => Recipient, + <<"quantity">> => 7 + }, + ?assertEqual( + {error, <<"Invalid mint action.">>}, + dev_token:handle_action(<<"mint">>, Base, #{ <<"body">> => Body }, Opts) + ), + ?assertEqual( + {error, <<"Invalid mint action.">>}, + dev_token:handle_action( + <<"mint">>, + Base, + #{ <<"body">> => Body#{ <<"action">> => <<"Transfer">> } }, + Opts + ) + ), + ?assertEqual(0, balance(Base, Recipient, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)). + fixed_supply_name_token_flow_test() -> Opts = opts(), Owner = id(<<"owner">>), From 2d82530e0da91cdde4c5a73d22a731d6fb91c8ed Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Mon, 13 Jul 2026 15:01:21 +0200 Subject: [PATCH 08/23] impr(security): mint-enabled Set-council toggle --- src/dev_token.erl | 14 +++ ...b_token_process_scheduler_test_vectors.erl | 106 ++++++++++++++++ src/preloaded/test/hb_token_test_vectors.erl | 114 ++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/src/dev_token.erl b/src/dev_token.erl index a4838f3..7a40c08 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -426,6 +426,12 @@ transfer_notices(From, Recipient, Quantity, Req, Opts) -> %% `body.subject`, hoist it to the top-level request shape expected by the mint %% device before dispatch. mint(Base, Assignment, Opts) -> + case mint_enabled(Base, Opts) of + true -> mint_request(Base, Assignment, Opts); + Error -> Error + end. + +mint_request(Base, Assignment, Opts) -> case hb_ao:resolve(Assignment, <<"body">>, Opts) of {error, _} -> as_mint_device(<<"mint">>, Base, Assignment, Opts); @@ -448,6 +454,14 @@ mint(Base, Assignment, Opts) -> end end. +mint_enabled(Base, Opts) -> + Default = hb_opts:get(<<"mint-enabled">>, true, Opts), + case hb_ao:get(<<"mint-enabled">>, Base, Default, Opts) of + true -> true; + false -> {error, <<"Minting is disabled.">>}; + _ -> {error, <<"Invalid `mint-enabled` type.">>} + end. + %% @doc Execute the mint device's main key, but return the state in its %% unmodified form if the execution returns an error. normalize_mint(Base, Assignment, Opts) -> diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 1deb03b..448bb0f 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -334,6 +334,112 @@ wallet_only_mint_authority_through_scheduler() -> ?assertEqual(7, balance(Minted, Recipient, Opts)), ?assertEqual(8, state_field(Minted, <<"total-supply">>, 0, Opts)). +mint_enabled_council_control_through_scheduler_test_() -> + {timeout, 120, fun mint_enabled_council_control_through_scheduler/0}. + +mint_enabled_council_control_through_scheduler() -> + Opts = opts(), + {AdminA, AdminAWallet} = signer(), + {AdminB, AdminBWallet} = signer(), + {AdminC, _AdminCWallet} = signer(), + {MintAuthority, MintAuthorityWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"mint-enabled">> => false, + <<"set-authority">> => [AdminA, AdminB, AdminC], + <<"set-authority-match">> => 2, + <<"whitelisted-fields">> => [<<"mint-enabled">>], + <<"balances">> => #{ MintAuthority => 1 }, + <<"total-supply">> => 1 + }, + Opts + ), + MintRequest = + #{ + <<"action">> => <<"Mint">>, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 7 + }, + {0, DisabledMint} = + schedule_and_compute( + Process0, + MintRequest, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(0, balance(DisabledMint, Recipient, Opts)), + ?assertEqual(1, state_field(DisabledMint, <<"total-supply">>, 0, Opts)), + {1, SingleSignerSet} = + schedule_and_compute( + DisabledMint, + #{ + <<"action">> => <<"Set">>, + <<"mint-enabled">> => true, + <<"timestamp">> => 1 + }, + AdminAWallet, + AdminAWallet, + Opts + ), + ?assertEqual( + false, + state_field(SingleSignerSet, <<"mint-enabled">>, not_found, Opts) + ), + {2, Enabled} = + schedule_and_compute( + SingleSignerSet, + #{ + <<"action">> => <<"Set">>, + <<"mint-enabled">> => true, + <<"timestamp">> => 2 + }, + [AdminAWallet, AdminBWallet], + AdminAWallet, + Opts + ), + ?assertEqual(true, state_field(Enabled, <<"mint-enabled">>, not_found, Opts)), + {3, Minted} = + schedule_and_compute( + Enabled, + MintRequest, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, state_field(Minted, <<"total-supply">>, 0, Opts)), + {4, DisabledAgain} = + schedule_and_compute( + Minted, + #{ + <<"action">> => <<"Set">>, + <<"mint-enabled">> => false, + <<"timestamp">> => 3 + }, + [AdminAWallet, AdminBWallet], + AdminAWallet, + Opts + ), + {5, RejectedAgain} = + schedule_and_compute( + DisabledAgain, + MintRequest#{ <<"quantity">> => 3 }, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(7, balance(RejectedAgain, Recipient, Opts)), + ?assertEqual(8, state_field(RejectedAgain, <<"total-supply">>, 0, Opts)). + delegated_action_allowlist_through_scheduler_test_() -> {timeout, 120, fun delegated_action_allowlist_through_scheduler/0}. diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 3a524a1..dc1dcf4 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -715,6 +715,120 @@ mint_authority_mint_test() -> ?assertEqual(Recipient, hb_ao:get(<<"recipient">>, Notice, Opts)), ?assertEqual(7, hb_ao:get(<<"quantity">>, Notice, Opts)). +mint_enabled_defaults_to_opts_test() -> + Opts = (opts())#{ <<"mint-enabled">> => false }, + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + } + }, + Opts + ), + ?assertEqual( + {error, <<"Minting is disabled.">>}, + mint(Base, Authority, Recipient, 7, Opts) + ), + ?assertEqual(0, balance(Base, Recipient, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)). + +mint_enabled_state_overrides_opts_test() -> + Opts = (opts())#{ <<"mint-enabled">> => false }, + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"mint-enabled">> => true + } + }, + Opts + ), + {ok, Minted} = mint(Base, Authority, Recipient, 7, Opts), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, Minted, Opts)). + +set_authority_can_toggle_mint_enabled_test() -> + Opts = opts(), + Setter = id(<<"setter">>), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"mint-enabled">> => false, + <<"set-authority">> => Setter, + <<"whitelisted-fields">> => [<<"mint-enabled">>] + } + }, + Opts + ), + ?assertEqual( + {error, <<"Minting is disabled.">>}, + mint(Base, Authority, Recipient, 7, Opts) + ), + ?assertEqual( + {error, <<"Too few acceptable committers present.">>}, + set_field(Base, Authority, #{ <<"mint-enabled">> => true }, Opts) + ), + {ok, Enabled} = + set_field(Base, Setter, #{ <<"mint-enabled">> => true }, Opts), + {ok, Minted} = mint(Enabled, Authority, Recipient, 7, Opts), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, Minted, Opts)), + {ok, Disabled} = + set_field(Minted, Setter, #{ <<"mint-enabled">> => false }, Opts), + ?assertEqual( + {error, <<"Minting is disabled.">>}, + mint(Disabled, Authority, Recipient, 3, Opts) + ), + ?assertEqual(7, balance(Disabled, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, Disabled, Opts)). + +invalid_mint_enabled_type_fails_closed_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"mint-enabled">> => <<"true">> + } + }, + Opts + ), + ?assertEqual( + {error, <<"Invalid `mint-enabled` type.">>}, + mint(Base, Authority, Recipient, 7, Opts) + ), + ?assertEqual(0, balance(Base, Recipient, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)). + mint_authority_requires_mint_body_action_test() -> Opts = opts(), Authority = id(<<"authority">>), From b5417e6fcec829b6dd390d9324a1e9871c570767 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 10:01:14 +0200 Subject: [PATCH 09/23] impr(security): Set-council authoritative max-supply check test vectors --- ...b_token_process_scheduler_test_vectors.erl | 88 +++++++++ src/preloaded/test/hb_token_test_vectors.erl | 173 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 448bb0f..910910a 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -440,6 +440,94 @@ mint_enabled_council_control_through_scheduler() -> ?assertEqual(7, balance(RejectedAgain, Recipient, Opts)), ?assertEqual(8, state_field(RejectedAgain, <<"total-supply">>, 0, Opts)). +max_supply_council_control_through_scheduler_test_() -> + {timeout, 120, fun max_supply_council_control_through_scheduler/0}. + +max_supply_council_control_through_scheduler() -> + Opts = opts(), + {AdminA, AdminAWallet} = signer(), + {AdminB, AdminBWallet} = signer(), + {AdminC, _AdminCWallet} = signer(), + {MintAuthority, MintAuthorityWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"max-supply">> => 5, + <<"max-supply-enabled">> => true, + <<"set-authority">> => [AdminA, AdminB, AdminC], + <<"set-authority-match">> => 2, + <<"whitelisted-fields">> => [<<"max-supply-enabled">>], + <<"balances">> => #{ MintAuthority => 1 }, + <<"total-supply">> => 1 + }, + Opts + ), + MintRequest = + #{ + <<"action">> => <<"Mint">>, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 5 + }, + {0, Capped} = + schedule_and_compute( + Process0, + MintRequest, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(0, balance(Capped, Recipient, Opts)), + ?assertEqual(1, state_field(Capped, <<"total-supply">>, 0, Opts)), + {1, SingleSignerSet} = + schedule_and_compute( + Capped, + #{ + <<"action">> => <<"Set">>, + <<"max-supply-enabled">> => false, + <<"timestamp">> => 1 + }, + AdminAWallet, + AdminAWallet, + Opts + ), + ?assertEqual( + true, + state_field(SingleSignerSet, <<"max-supply-enabled">>, not_found, Opts) + ), + {2, Disabled} = + schedule_and_compute( + SingleSignerSet, + #{ + <<"action">> => <<"Set">>, + <<"max-supply-enabled">> => false, + <<"timestamp">> => 2 + }, + [AdminAWallet, AdminBWallet], + AdminAWallet, + Opts + ), + ?assertEqual( + false, + state_field(Disabled, <<"max-supply-enabled">>, not_found, Opts) + ), + {3, Minted} = + schedule_and_compute( + Disabled, + MintRequest, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(5, balance(Minted, Recipient, Opts)), + ?assertEqual(6, state_field(Minted, <<"total-supply">>, 0, Opts)). + delegated_action_allowlist_through_scheduler_test_() -> {timeout, 120, fun delegated_action_allowlist_through_scheduler/0}. diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index dc1dcf4..de4f772 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -212,6 +212,22 @@ mint(State, From, Recipient, Quantity, Opts) -> Opts ). +mint_batch(State, From, Quantities, Opts) -> + dev_token:handle_action( + <<"mint">>, + State, + #{ + <<"body">> => + #{ + <<"action">> => <<"Mint">>, + <<"from">> => From, + <<"mode">> => <<"batch">>, + <<"quantities">> => Quantities + } + }, + Opts + ). + set_field(State, From, Fields, Opts) -> dev_token:handle_action( <<"set">>, @@ -715,6 +731,163 @@ mint_authority_mint_test() -> ?assertEqual(Recipient, hb_ao:get(<<"recipient">>, Notice, Opts)), ?assertEqual(7, hb_ao:get(<<"quantity">>, Notice, Opts)). +max_supply_is_enforced_by_default_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"max-supply">> => 8 + } + }, + Opts + ), + {ok, AtLimit} = mint(Base, Authority, Recipient, 7, Opts), + ?assertEqual(7, balance(AtLimit, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, AtLimit, Opts)), + ?assertEqual( + {error, <<"Max supply exceeded.">>}, + mint(AtLimit, Authority, Recipient, 1, Opts) + ), + ?assertEqual(7, balance(AtLimit, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, AtLimit, Opts)). + +max_supply_applies_to_batch_total_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + RecipientA = id(<<"recipient-a">>), + RecipientB = id(<<"recipient-b">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"max-supply">> => 10 + } + }, + Opts + ), + ?assertEqual( + {error, <<"Max supply exceeded.">>}, + mint_batch(Base, Authority, #{ RecipientA => 4, RecipientB => 6 }, Opts) + ), + ?assertEqual(0, balance(Base, RecipientA, Opts)), + ?assertEqual(0, balance(Base, RecipientB, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)), + {ok, AtLimit} = + mint_batch( + Base, + Authority, + #{ RecipientA => 4, RecipientB => 5 }, + Opts + ), + ?assertEqual(4, balance(AtLimit, RecipientA, Opts)), + ?assertEqual(5, balance(AtLimit, RecipientB, Opts)), + ?assertEqual(10, hb_ao:get(<<"total-supply">>, AtLimit, Opts)). + +set_authority_can_disable_max_supply_test() -> + Opts = opts(), + Setter = id(<<"setter">>), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority, + <<"max-supply">> => 5, + <<"max-supply-enabled">> => true, + <<"set-authority">> => Setter, + <<"whitelisted-fields">> => [<<"max-supply-enabled">>] + } + }, + Opts + ), + ?assertEqual( + {error, <<"Max supply exceeded.">>}, + mint(Base, Authority, Recipient, 5, Opts) + ), + ?assertEqual( + {error, <<"Too few acceptable committers present.">>}, + set_field( + Base, + Authority, + #{ <<"max-supply-enabled">> => false }, + Opts + ) + ), + {ok, Disabled} = + set_field( + Base, + Setter, + #{ <<"max-supply-enabled">> => false }, + Opts + ), + {ok, Minted} = mint(Disabled, Authority, Recipient, 5, Opts), + ?assertEqual(false, hb_ao:get(<<"max-supply-enabled">>, Minted, Opts)), + ?assertEqual(5, balance(Minted, Recipient, Opts)), + ?assertEqual(6, hb_ao:get(<<"total-supply">>, Minted, Opts)). + +invalid_max_supply_policy_fails_closed_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Common = + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + }, + Cases = + [ + { + #{ <<"max-supply-enabled">> => true }, + <<"Max supply must be a non-negative integer.">> + }, + { + #{ <<"max-supply">> => -1 }, + <<"Max supply must be a non-negative integer.">> + }, + { + #{ <<"max-supply">> => 8, <<"max-supply-enabled">> => <<"true">> }, + <<"Invalid `max-supply-enabled` type.">> + } + ], + lists:foreach( + fun({Policy, ExpectedError}) -> + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => maps:merge(Common, Policy) + }, + Opts + ), + ?assertEqual( + {error, ExpectedError}, + mint(Base, Authority, Recipient, 7, Opts) + ), + ?assertEqual(0, balance(Base, Recipient, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)) + end, + Cases + ). + mint_enabled_defaults_to_opts_test() -> Opts = (opts())#{ <<"mint-enabled">> => false }, Authority = id(<<"authority">>), From 1e609dc3670e03eb308b53607df964a3de703ba8 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 11:22:56 +0200 Subject: [PATCH 10/23] impr(security): malformed Action catching + adding Un/Subscribe to authority-actions --- src/dev_token.erl | 23 ++- src/dev_token_lib.erl | 3 +- ...b_token_process_scheduler_test_vectors.erl | 138 +++++++++++++++++- 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index 7a40c08..f5a4774 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -254,15 +254,22 @@ enforce_security(Base, Req, Opts) -> %% @doc Route the request to the appropriate key resolution function, depending %% upon the `action' specified. -handle_action(Action, Base, Req, Opts) -> +handle_action(Action, Base, Req, Opts) when is_binary(Action) -> ?event(token_short, {token_action, Action}, Opts), - case hb_util:to_lower(hb_ao:normalize_key(Action)) of - <<"transfer">> -> transfer(Base, Req, Opts); - <<"set">> -> secure_set(Base, Req, Opts); - <<"subscribe">> -> outbox_subscribe(Base, Req, Opts); - <<"unsubscribe">> -> outbox_unsubscribe(Base, Req, Opts); - MintDevAction -> action_as_mint_device(MintDevAction, Base, Req, Opts) - end. + try + case hb_util:to_lower(Action) of + <<"transfer">> -> transfer(Base, Req, Opts); + <<"set">> -> secure_set(Base, Req, Opts); + <<"subscribe">> -> outbox_subscribe(Base, Req, Opts); + <<"unsubscribe">> -> outbox_unsubscribe(Base, Req, Opts); + MintDevAction -> action_as_mint_device(MintDevAction, Base, Req, Opts) + end + catch + error:Reason -> {error, Reason} + end; + +handle_action(_Action, _Base, _Req, _Opts) -> + {error, <<"Invalid Action format">>}. %% @doc Get the balance for an account. Normalize the minting state for that %% account before returning. diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index 5df22fc..9478e88 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -49,7 +49,8 @@ ledger(Extra, Opts) -> <<"scheduler-device">> => <<"scheduler@1.0">>, <<"scheduler">> => hb_util:human_id(HostWallet), <<"authority">> => hb_util:human_id(HostWallet), - <<"authority-actions">> => [<<"Transfer">>] + <<"authority-actions">> => + [<<"Transfer">>, <<"Subscribe">>, <<"Unsubscribe">>] }, ModExtra ), diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 910910a..369141b 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -230,6 +230,113 @@ scheduled_paths_are_noops_and_process_continues() -> ?assertEqual(0, balance(Process4, UnauthorizedCaller, Opts)), ?assertEqual(10, state_field(Process4, <<"total-supply">>, 0, Opts)). +malformed_actions_do_not_block_process_test_() -> + {timeout, 120, fun malformed_actions_do_not_block_process/0}. + +malformed_actions_do_not_block_process() -> + Opts = opts(), + {Sender, SenderWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + {MintAuthority, MintAuthorityWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"balances">> => #{ Sender => 10, MintAuthority => 1 }, + <<"total-supply">> => 11 + }, + Opts + ), + FailedRequests = + [ + {#{ <<"action">> => #{} }, SenderWallet}, + {#{ <<"action">> => <<255>> }, SenderWallet}, + { + #{ + <<"action">> => <<"Transfer">>, + <<"recipient">> => #{}, + <<"quantity">> => 1 + }, + SenderWallet + }, + { + #{ + <<"action">> => <<"Transfer">>, + <<"recipient">> => Recipient, + <<"quantity">> => #{} + }, + SenderWallet + }, + {#{ <<"action">> => <<"Set">>, <<"transfer-enabled">> => false }, + SenderWallet}, + {#{ <<"action">> => <<"Subscribe">>, <<"subscribe-action">> => #{} }, + SenderWallet}, + { + #{ + <<"action">> => <<"Subscribe">>, + <<"subscribe-action">> => <<"Credit-Notice">>, + <<"subscribe-target">> => #{} + }, + SenderWallet + }, + {#{ <<"action">> => <<"Unsubscribe">>, <<"subscribe-action">> => #{} }, + SenderWallet}, + { + #{ + <<"action">> => <<"Mint">>, + <<"mode">> => <<"single">>, + <<"recipient">> => #{}, + <<"quantity">> => 1 + }, + MintAuthorityWallet + }, + { + #{ + <<"action">> => <<"Mint">>, + <<"mode">> => <<"batch">>, + <<"quantities">> => #{ Recipient => #{} } + }, + MintAuthorityWallet + }, + {#{ <<"action">> => <<"Deposit">> }, MintAuthorityWallet} + ], + {FailedState, NextSlot} = + lists:foldl( + fun({Request, Wallet}, {Process, Slot}) -> + {Slot, Computed} = + schedule_and_compute(Process, Request, Wallet, Wallet, Opts), + ?assertEqual(Slot, hb_ao:get(<<"at-slot">>, Computed, Opts)), + ?assertEqual(10, balance(Computed, Sender, Opts)), + ?assertEqual(0, balance(Computed, Recipient, Opts)), + ?assertEqual(1, balance(Computed, MintAuthority, Opts)), + ?assertEqual(11, state_field(Computed, <<"total-supply">>, 0, Opts)), + {Computed, Slot + 1} + end, + {Process0, 0}, + FailedRequests + ), + {NextSlot, FinalState} = + schedule_and_compute( + FailedState, + #{ + <<"action">> => <<"Transfer">>, + <<"recipient">> => Recipient, + <<"quantity">> => 1 + }, + SenderWallet, + SenderWallet, + Opts + ), + ?assertEqual(NextSlot, hb_ao:get(<<"at-slot">>, FinalState, Opts)), + ?assertEqual(9, balance(FinalState, Sender, Opts)), + ?assertEqual(1, balance(FinalState, Recipient, Opts)), + ?assertEqual(1, balance(FinalState, MintAuthority, Opts)), + ?assertEqual(11, state_field(FinalState, <<"total-supply">>, 0, Opts)). + two_of_three_set_succeeds_through_scheduler_test_() -> {timeout, 120, fun two_of_three_set_succeeds_through_scheduler/0}. @@ -545,7 +652,8 @@ delegated_action_allowlist_through_scheduler() -> <<"security-device">> => <<"security@1.0">>, <<"authority">> => [Scheduler], <<"authority-match">> => 1, - <<"authority-actions">> => [<<"Transfer">>], + <<"authority-actions">> => + [<<"Transfer">>, <<"Subscribe">>, <<"Unsubscribe">>], <<"mint-device">> => <<"mint-authority@1.0">>, <<"mint-authority">> => MintAuthority, <<"balances">> => #{ Dex => 10 }, @@ -569,9 +677,35 @@ delegated_action_allowlist_through_scheduler() -> ?assertEqual(7, balance(Transferred, Dex, Opts)), ?assertEqual(3, balance(Transferred, Recipient, Opts)), ?assertEqual(10, state_field(Transferred, <<"total-supply">>, 0, Opts)), - {1, RejectedMint} = + {1, Subscribed} = schedule_and_compute( Transferred, + #{ + <<"action">> => <<"Subscribe">>, + <<"from-process">> => Dex, + <<"subscribe-action">> => <<"register">> + }, + SchedulerWallet, + SchedulerWallet, + Opts + ), + ?assertEqual([Dex], dev_token_lib:subscribers(Subscribed, <<"register">>, Opts)), + {2, Unsubscribed} = + schedule_and_compute( + Subscribed, + #{ + <<"action">> => <<"Unsubscribe">>, + <<"from-process">> => Dex, + <<"subscribe-action">> => <<"register">> + }, + SchedulerWallet, + SchedulerWallet, + Opts + ), + ?assertEqual([], dev_token_lib:subscribers(Unsubscribed, <<"register">>, Opts)), + {3, RejectedMint} = + schedule_and_compute( + Unsubscribed, #{ <<"action">> => <<"Mint">>, <<"from-process">> => MintAuthority, From 54654a18756f1cbaaae3184a158c79a23592b799 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 13:13:24 +0200 Subject: [PATCH 11/23] chore(security): add delegated authority signers expansion test --- ...b_token_process_scheduler_test_vectors.erl | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 369141b..77c4995 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -720,3 +720,64 @@ delegated_action_allowlist_through_scheduler() -> ?assertEqual(7, balance(RejectedMint, Dex, Opts)), ?assertEqual(3, balance(RejectedMint, Recipient, Opts)), ?assertEqual(10, state_field(RejectedMint, <<"total-supply">>, 0, Opts)). + +delegated_signature_expansion_replay_rejected_test_() -> + {timeout, 120, fun delegated_signature_expansion_replay_rejected/0}. + +delegated_signature_expansion_replay_rejected() -> + Opts = opts(), + SchedulerWallet = maps:get(<<"priv-wallet">>, Opts), + Scheduler = hb_util:human_id(ar_wallet:to_address(SchedulerWallet)), + {Dex, _DexWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + {_External, ExternalWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [Scheduler], + <<"authority-match">> => 1, + <<"authority-actions">> => [<<"Transfer">>], + <<"balances">> => #{Dex => 10}, + <<"total-supply">> => 10 + }, + Opts + ), + SignedBody = + sign_body( + Process0, + #{ + <<"action">> => <<"Transfer">>, + <<"from-process">> => Dex, + <<"recipient">> => Recipient, + <<"quantity">> => 3 + }, + SchedulerWallet, + Opts + ), + {0, _} = schedule(Process0, SignedBody, SchedulerWallet, Opts), + {ok, Process1} = + hb_ao:resolve( + Process0, + #{<<"path">> => <<"compute">>, <<"slot">> => 0}, + Opts + ), + ?assertEqual(7, balance(Process1, Dex, Opts)), + ?assertEqual(3, balance(Process1, Recipient, Opts)), + + ExpandedBody = + hb_message:commit( + SignedBody, + Opts#{<<"priv-wallet">> => ExternalWallet} + ), + ?assertEqual(2, length(lists:uniq(hb_message:signers(ExpandedBody, Opts)))), + {1, _} = schedule(Process1, ExpandedBody, ExternalWallet, Opts), + {ok, Process2} = + hb_ao:resolve( + Process1, + #{<<"path">> => <<"compute">>, <<"slot">> => 1}, + Opts + ), + ?assertEqual(7, balance(Process2, Dex, Opts)), + ?assertEqual(3, balance(Process2, Recipient, Opts)). From bc71205ec7efae804cd846bcf992d00c12ab66c5 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 13:40:42 +0200 Subject: [PATCH 12/23] chore(security): mint-nonce addition tests --- ...b_token_process_scheduler_test_vectors.erl | 80 ++++++++++++++++ src/preloaded/test/hb_token_test_vectors.erl | 93 +++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 77c4995..2a33260 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -288,6 +288,7 @@ malformed_actions_do_not_block_process() -> { #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"mode">> => <<"single">>, <<"recipient">> => #{}, <<"quantity">> => 1 @@ -297,6 +298,7 @@ malformed_actions_do_not_block_process() -> { #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"mode">> => <<"batch">>, <<"quantities">> => #{ Recipient => #{} } }, @@ -414,6 +416,7 @@ wallet_only_mint_authority_through_scheduler() -> Process0, #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"from-process">> => MintAuthority, <<"mode">> => <<"single">>, <<"recipient">> => Recipient, @@ -430,6 +433,7 @@ wallet_only_mint_authority_through_scheduler() -> Rejected, #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"mode">> => <<"single">>, <<"recipient">> => Recipient, <<"quantity">> => 7 @@ -471,6 +475,7 @@ mint_enabled_council_control_through_scheduler() -> MintRequest = #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"mode">> => <<"single">>, <<"recipient">> => Recipient, <<"quantity">> => 7 @@ -578,6 +583,7 @@ max_supply_council_control_through_scheduler() -> MintRequest = #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"mode">> => <<"single">>, <<"recipient">> => Recipient, <<"quantity">> => 5 @@ -708,6 +714,7 @@ delegated_action_allowlist_through_scheduler() -> Unsubscribed, #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, <<"from-process">> => MintAuthority, <<"mode">> => <<"single">>, <<"recipient">> => Recipient, @@ -721,6 +728,79 @@ delegated_action_allowlist_through_scheduler() -> ?assertEqual(3, balance(RejectedMint, Recipient, Opts)), ?assertEqual(10, state_field(RejectedMint, <<"total-supply">>, 0, Opts)). +mint_nonce_replay_rejected_through_scheduler_test_() -> + {timeout, 120, fun mint_nonce_replay_rejected_through_scheduler/0}. + +mint_nonce_replay_rejected_through_scheduler() -> + Opts = opts(), + {MintAuthority, MintAuthorityWallet} = signer(), + {Recipient, _RecipientWallet} = signer(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [], + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => MintAuthority, + <<"balances">> => #{ MintAuthority => 1 }, + <<"total-supply">> => 1 + }, + Opts + ), + MintBody = + #{ + <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 42, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 3 + }, + SignedMint = sign_body(Process0, MintBody, MintAuthorityWallet, Opts), + {0, _} = schedule(Process0, SignedMint, MintAuthorityWallet, Opts), + {ok, Process1} = + hb_ao:resolve( + Process0, + #{ <<"path">> => <<"compute">>, <<"slot">> => 0 }, + Opts + ), + ?assertEqual(3, balance(Process1, Recipient, Opts)), + ?assertEqual(4, state_field(Process1, <<"total-supply">>, 0, Opts)), + ?assertEqual(42, state_field(Process1, <<"mint-nonce">>, -1, Opts)), + + ResignedMint = sign_body(Process1, MintBody, MintAuthorityWallet, Opts), + ?assertEqual( + hb_message:id(SignedMint, none, Opts), + hb_message:id(ResignedMint, none, Opts) + ), + ?assertNotEqual( + hb_message:id(SignedMint, signed, Opts), + hb_message:id(ResignedMint, signed, Opts) + ), + {1, _} = schedule(Process1, ResignedMint, MintAuthorityWallet, Opts), + {ok, Process2} = + hb_ao:resolve( + Process1, + #{ <<"path">> => <<"compute">>, <<"slot">> => 1 }, + Opts + ), + ?assertEqual(1, hb_ao:get(<<"at-slot">>, Process2, Opts)), + ?assertEqual(3, balance(Process2, Recipient, Opts)), + ?assertEqual(4, state_field(Process2, <<"total-supply">>, 0, Opts)), + ?assertEqual(42, state_field(Process2, <<"mint-nonce">>, -1, Opts)), + + {2, Process3} = + schedule_and_compute( + Process2, + MintBody#{ <<"mint-nonce">> => 43 }, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?assertEqual(6, balance(Process3, Recipient, Opts)), + ?assertEqual(7, state_field(Process3, <<"total-supply">>, 0, Opts)), + ?assertEqual(43, state_field(Process3, <<"mint-nonce">>, -1, Opts)). + delegated_signature_expansion_replay_rejected_test_() -> {timeout, 120, fun delegated_signature_expansion_replay_rejected/0}. diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index de4f772..eb40171 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -197,6 +197,9 @@ transfer(State, From, To, Quantity, Opts) -> ). mint(State, From, Recipient, Quantity, Opts) -> + mint(State, From, Recipient, Quantity, next_mint_nonce(State, Opts), Opts). + +mint(State, From, Recipient, Quantity, MintNonce, Opts) -> dev_token:handle_action( <<"mint">>, State, @@ -204,6 +207,7 @@ mint(State, From, Recipient, Quantity, Opts) -> <<"body">> => #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => MintNonce, <<"from">> => From, <<"recipient">> => Recipient, <<"quantity">> => Quantity @@ -213,6 +217,9 @@ mint(State, From, Recipient, Quantity, Opts) -> ). mint_batch(State, From, Quantities, Opts) -> + mint_batch(State, From, Quantities, next_mint_nonce(State, Opts), Opts). + +mint_batch(State, From, Quantities, MintNonce, Opts) -> dev_token:handle_action( <<"mint">>, State, @@ -220,6 +227,7 @@ mint_batch(State, From, Quantities, Opts) -> <<"body">> => #{ <<"action">> => <<"Mint">>, + <<"mint-nonce">> => MintNonce, <<"from">> => From, <<"mode">> => <<"batch">>, <<"quantities">> => Quantities @@ -228,6 +236,9 @@ mint_batch(State, From, Quantities, Opts) -> Opts ). +next_mint_nonce(State, Opts) -> + hb_ao:get(<<"mint-nonce">>, State, -1, Opts) + 1. + set_field(State, From, Fields, Opts) -> dev_token:handle_action( <<"set">>, @@ -726,11 +737,93 @@ mint_authority_mint_test() -> ?assertEqual(1, balance(Minted, Authority, Opts)), ?assertEqual(7, balance(Minted, Recipient, Opts)), ?assertEqual(8, hb_ao:get(<<"total-supply">>, Minted, Opts)), + ?assertEqual(0, hb_ao:get(<<"mint-nonce">>, Minted, Opts)), ?assertEqual(<<"Mint-Notice">>, hb_ao:get(<<"action">>, Notice, Opts)), + ?assertEqual(0, hb_ao:get(<<"mint-nonce">>, Notice, Opts)), ?assertEqual(Recipient, hb_ao:get(<<"target">>, Notice, Opts)), ?assertEqual(Recipient, hb_ao:get(<<"recipient">>, Notice, Opts)), ?assertEqual(7, hb_ao:get(<<"quantity">>, Notice, Opts)). +mint_nonce_must_advance_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + } + }, + Opts + ), + {ok, Minted} = mint(Base, Authority, Recipient, 7, 10, Opts), + ?assertEqual(7, balance(Minted, Recipient, Opts)), + ?assertEqual(8, hb_ao:get(<<"total-supply">>, Minted, Opts)), + ?assertEqual(10, hb_ao:get(<<"mint-nonce">>, Minted, Opts)), + ?assertEqual( + {error, <<"Mint nonce must advance.">>}, + mint(Minted, Authority, Recipient, 7, 10, Opts) + ), + ?assertEqual( + {error, <<"Mint nonce must advance.">>}, + mint(Minted, Authority, Recipient, 7, 9, Opts) + ), + {ok, Advanced} = mint(Minted, Authority, Recipient, 1, 12, Opts), + ?assertEqual(8, balance(Advanced, Recipient, Opts)), + ?assertEqual(9, hb_ao:get(<<"total-supply">>, Advanced, Opts)), + ?assertEqual(12, hb_ao:get(<<"mint-nonce">>, Advanced, Opts)). + +invalid_mint_nonce_fails_closed_test() -> + Opts = opts(), + Authority = id(<<"authority">>), + Recipient = id(<<"recipient">>), + Base = + token_state( + #{ + total_supply => 1, + initial_balances => #{ Authority => 1 }, + extra => + #{ + <<"mint-device">> => <<"mint-authority@1.0">>, + <<"mint-authority">> => Authority + } + }, + Opts + ), + MissingNonceBody = + #{ + <<"action">> => <<"Mint">>, + <<"from">> => Authority, + <<"recipient">> => Recipient, + <<"quantity">> => 7 + }, + ?assertEqual( + {error, <<"Mint nonce must be a non-negative integer.">>}, + dev_token:handle_action( + <<"mint">>, + Base, + #{ <<"body">> => MissingNonceBody }, + Opts + ) + ), + lists:foreach( + fun(Nonce) -> + ?assertEqual( + {error, <<"Mint nonce must be a non-negative integer.">>}, + mint(Base, Authority, Recipient, 7, Nonce, Opts) + ) + end, + [-1, <<"0">>] + ), + ?assertEqual(0, balance(Base, Recipient, Opts)), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Base, Opts)), + ?assertEqual(not_found, hb_ao:get(<<"mint-nonce">>, Base, not_found, Opts)). + max_supply_is_enforced_by_default_test() -> Opts = opts(), Authority = id(<<"authority">>), From c28aec76c8bb835e3cde53e5127b75b5d9326aff Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 15:56:25 +0200 Subject: [PATCH 13/23] fix(security): use RawQuantities for outbox mint notices test --- src/preloaded/test/hb_token_test_vectors.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index eb40171..7c0bbb8 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -718,7 +718,7 @@ fixed_supply_without_mint_device_cannot_mint_test() -> mint_authority_mint_test() -> Opts = opts(), Authority = id(<<"authority">>), - Recipient = id(<<"recipient">>), + Recipient = id(<<"Recipient">>), Base = token_state( #{ From d499dd88ab5a11a88ef0e4a9f757e1a2c6def522 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 14 Jul 2026 17:01:50 +0200 Subject: [PATCH 14/23] impr(security): fail closed ini/3 -> canonicalize_balances/2 --- README.md | 6 ++ src/dev_token.erl | 97 +++++++++++--------- src/preloaded/test/hb_token_props.erl | 19 +++- src/preloaded/test/hb_token_test_vectors.erl | 97 ++++++++++++++++++-- 4 files changed, 161 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 74a08b5..f9de919 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ rebar3 device package --device-src=src,_build/default/lib/hb/src/preloaded/token HB_PORT=0 rebar3 device test rebar3 eunit-all ``` + +## genesis + +Initialization requires valid addresses with non-negative integer balances and a +non-negative `total-supply` equal to their sum. Invalid genesis state fails closed. + ## published package ```bash diff --git a/src/dev_token.erl b/src/dev_token.erl index f5a4774..a92d7b2 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -133,7 +133,7 @@ is_compute_path(Req, Opts) -> _ -> false end. -%% @doc Canonicalize account keys in the initial balance trie. +%% @doc Validate initial supply and canonicalize account keys. init(Base, _Req, Opts) -> canonicalize_balances(Base, Opts). @@ -146,53 +146,60 @@ snapshot(Base, _Req, _Opts) -> {ok, Base}. canonicalize_balances(Base, Opts) -> - case hb_maps:get(<<"balances">>, Base, not_found, Opts) of - not_found -> - {ok, Base}; - Balances0 -> - Balances = hb_cache:ensure_all_loaded(Balances0, Opts), - case is_map(Balances) of - true -> canonicalize_balances(Base, Balances, Opts); - false -> {ok, Base} - end + maybe + Balances0 = hb_maps:get(<<"balances">>, Base, not_found, Opts), + true ?= (Balances0 =/= not_found) orelse + {error, <<"Balances not found.">>}, + Balances = hb_cache:ensure_all_loaded(Balances0, Opts), + true ?= is_map(Balances) orelse + {error, <<"Balances must be a map.">>}, + canonicalize_balances(Base, Balances, Opts) end. canonicalize_balances(Base, Balances, Opts) -> - {Changed, FlatBalances} = - lists:foldl( - fun(Key, {ChangedAcc, BalancesAcc}) -> - case lib_token:validate_address(Key, [], Opts) of - true -> - Account = lib_token:account_key(Key), - case hb_ao:resolve(Balances, Key, Opts) of - {ok, Amount} when is_integer(Amount) -> - { - ChangedAcc - orelse (Account =/= Key) - orelse maps:is_key(Account, BalancesAcc), - add_balance(Account, Amount, BalancesAcc) - }; - _ -> - {ChangedAcc, BalancesAcc} - end; - {error, _} -> - {ChangedAcc, BalancesAcc} - end - end, - {false, #{}}, - trie_keys(Balances, Opts) - ), - case Changed of - false -> - {ok, Base}; - true -> - {ok, NewBalances} = - hb_ao:resolve( - #{<<"device">> => <<"trie@1.0">>}, - FlatBalances#{<<"path">> => <<"set">>}, - Opts - ), - {ok, hb_maps:put(<<"balances">>, NewBalances, Base, Opts)} + maybe + TotalSupply = hb_ao:get(<<"total-supply">>, Base, not_found, Opts), + true ?= (is_integer(TotalSupply) andalso TotalSupply >= 0) orelse + {error, <<"Total supply must be a non-negative integer.">>}, + {ok, Changed, FlatBalances} ?= + lists:foldl( + fun + (_Key, {error, _} = Error) -> + Error; + (Key, {ok, ChangedAcc, BalancesAcc}) -> + maybe + true ?= lib_token:validate_address(Key, [], Opts), + {ok, Amount} ?= hb_ao:resolve(Balances, Key, Opts), + true ?= + (is_integer(Amount) andalso Amount >= 0) orelse + {error, <<"Balance amounts must be non-negative integers.">>}, + Account = lib_token:account_key(Key), + { + ok, + ChangedAcc + orelse (Account =/= Key) + orelse maps:is_key(Account, BalancesAcc), + add_balance(Account, Amount, BalancesAcc) + } + end + end, + {ok, false, #{}}, + trie_keys(Balances, Opts) + ), + true ?= (lists:sum(maps:values(FlatBalances)) =:= TotalSupply) orelse + {error, <<"Total supply does not match balances.">>}, + case Changed of + false -> + {ok, Base}; + true -> + {ok, NewBalances} = + hb_ao:resolve( + #{<<"device">> => <<"trie@1.0">>}, + FlatBalances#{<<"path">> => <<"set">>}, + Opts + ), + {ok, hb_maps:put(<<"balances">>, NewBalances, Base, Opts)} + end end. %% @doc Entrypoint for computations on token processes. Deduplicates by signed diff --git a/src/preloaded/test/hb_token_props.erl b/src/preloaded/test/hb_token_props.erl index e3f17b1..5e70e8c 100644 --- a/src/preloaded/test/hb_token_props.erl +++ b/src/preloaded/test/hb_token_props.erl @@ -199,12 +199,23 @@ generate_identities(Users) -> generate_ledger(Opts) -> Extras = hb_opts:get(<<"spawn-extras">>, #{}, Opts), BalanceKey = balance_key(Extras, Opts), + InitialBalances = generate_initial_balances(Opts), + LedgerFields = Extras#{ + BalanceKey => InitialBalances, + <<"ledger-nonce">> => hb_invariant:int(small) + }, + InitializedFields = + case BalanceKey of + <<"balances">> -> + LedgerFields#{ + <<"total-supply">> => lists:sum(maps:values(InitialBalances)) + }; + _ -> + LedgerFields + end, Ledger = dev_token_lib:ledger( - Extras#{ - BalanceKey => generate_initial_balances(Opts), - <<"ledger-nonce">> => hb_invariant:int(small) - }, + InitializedFields, Opts ), hb_cache:ensure_all_loaded(Ledger, Opts). diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 7c0bbb8..234b716 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -102,6 +102,20 @@ token_state(Params, Opts) -> ), hb_message:commit(Base, Opts). +raw_token_state(Fields, Opts) -> + hb_message:commit( + maps:merge( + #{ + <<"device">> => <<"token@1.0">>, + <<"name">> => <<"Test Token">>, + <<"ticker">> => <<"TEST">>, + <<"denomination">> => 0 + }, + Fields + ), + Opts + ). + balance(State, Account, Opts) -> Balances = hb_ao:get(<<"balances">>, State, Opts), case hb_ao:resolve(Balances, account_key(Account), Opts) of @@ -334,17 +348,13 @@ init_canonicalizes_raw_initial_balances_test() -> {ok, RawBalances} = hb_ao:resolve( #{ <<"device">> => <<"trie@1.0">> }, - #{ Alice => 7, <<"path">> => <<"set">> }, + #{ Alice => 7, account_key(Alice) => 3, <<"path">> => <<"set">> }, Opts ), Base = - hb_message:commit( + raw_token_state( #{ - <<"device">> => <<"token@1.0">>, - <<"name">> => <<"Test Token">>, - <<"ticker">> => <<"TEST">>, - <<"denomination">> => 0, - <<"total-supply">> => 7, + <<"total-supply">> => 10, <<"balances">> => RawBalances }, Opts @@ -352,8 +362,77 @@ init_canonicalizes_raw_initial_balances_test() -> {ok, Initialized} = dev_token:init(Base, #{}, Opts), Balances = hb_ao:get(<<"balances">>, Initialized, Opts), ?assertEqual({error, not_found}, hb_ao:resolve(Balances, Alice, Opts)), - ?assertEqual({ok, 7}, hb_ao:resolve(Balances, account_key(Alice), Opts)), - ?assertEqual({ok, 7}, public_balance(Initialized, Alice, Opts)). + ?assertEqual({ok, 10}, hb_ao:resolve(Balances, account_key(Alice), Opts)), + ?assertEqual({ok, 10}, public_balance(Initialized, Alice, Opts)). + +init_rejects_invalid_initial_balances_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + MixedAlice = id(<<"Alice">>), + Bob = id(<<"bob">>), + Cases = + [ + { + #{ Alice => 100, Bob => -90 }, + 10, + <<"Balance amounts must be non-negative integers.">> + }, + { + #{ MixedAlice => 7, Bob => <<"3">> }, + 10, + <<"Balance amounts must be non-negative integers.">> + }, + { + #{ MixedAlice => 7, <<" invalid">> => 3 }, + 10, + <<"Address cannot contain path separators or whitespaces">> + } + ], + lists:foreach( + fun({Balances, TotalSupply, Error}) -> + Base = + raw_token_state( + #{ + <<"balances">> => Balances, + <<"total-supply">> => TotalSupply + }, + Opts + ), + ?assertEqual({error, Error}, dev_token:init(Base, #{}, Opts)) + end, + Cases + ). + +init_rejects_invalid_total_supply_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Balances = #{ Alice => 7 }, + lists:foreach( + fun(TotalSupply) -> + Base = + raw_token_state( + #{ + <<"balances">> => Balances, + <<"total-supply">> => TotalSupply + }, + Opts + ), + ?assertEqual( + {error, <<"Total supply must be a non-negative integer.">>}, + dev_token:init(Base, #{}, Opts) + ) + end, + [-1, <<"7">>] + ), + Mismatched = + raw_token_state( + #{ <<"balances">> => Balances, <<"total-supply">> => 6 }, + Opts + ), + ?assertEqual( + {error, <<"Total supply does not match balances.">>}, + dev_token:init(Mismatched, #{}, Opts) + ). balance_missing_account_returns_zero_test() -> Opts = opts(), From b2ec7ce25e8b8a7cc7503f8d170a7d9722ed6dc7 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Wed, 15 Jul 2026 18:57:24 +0200 Subject: [PATCH 15/23] impr(security): allowed-subscriptions whitelist --- src/dev_token.erl | 28 +++++++++++ ...b_token_process_scheduler_test_vectors.erl | 44 ++++++++++++++++- src/preloaded/test/hb_token_test_vectors.erl | 48 +++++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index a92d7b2..c8f5dbb 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -614,6 +614,8 @@ outbox_send(Messages, Base, Opts) -> outbox_subscribe(Base, Req, Opts) -> maybe + true ?= subscription_allowed(Req, Base, Opts) orelse + {error, <<"Subscription is not allowed.">>}, {ok, Outbox} ?= process_outbox(Opts), Outbox:subscribe(Base, Req, Opts) end. @@ -624,6 +626,32 @@ outbox_unsubscribe(Base, Req, Opts) -> Outbox:unsubscribe(Base, Req, Opts) end. +subscription_allowed(Req, Base, Opts) -> + try + Body = hb_maps:get(<<"body">>, Req, not_found, Opts), + Action = hb_maps:get(<<"subscribe-action">>, Body, not_found, Opts), + Target = hb_maps:get(<<"subscribe-target">>, Body, <<"broadcast">>, Opts), + Listener = hb_maps:get(<<"from">>, Body, not_found, Opts), + Policy = hb_util:message_to_ordered_list( + hb_maps:get(<<"allowed-subscriptions">>, Base, [], Opts), + Opts + ), + is_binary(Action) andalso is_binary(Target) andalso is_binary(Listener) + andalso lists:any( + fun(Entry) -> + {Action, Target, Listener} =:= + { + hb_maps:get(<<"action">>, Entry, not_found, Opts), + hb_maps:get(<<"target">>, Entry, not_found, Opts), + hb_maps:get(<<"listener">>, Entry, not_found, Opts) + } + end, + Policy + ) + catch + _:_ -> false + end. + process_outbox(Opts) -> case hb_device_load:reference(?PROCESS_OUTBOX_DEVICE, Opts) of {ok, Outbox} -> diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 2a33260..039b014 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -648,6 +648,7 @@ delegated_action_allowlist_through_scheduler() -> Opts = opts(), SchedulerWallet = maps:get(<<"priv-wallet">>, Opts), Scheduler = hb_util:human_id(ar_wallet:to_address(SchedulerWallet)), + {Admin, AdminWallet} = signer(), {Dex, _DexWallet} = signer(), {Recipient, _RecipientWallet} = signer(), {MintAuthority, _MintAuthorityWallet} = signer(), @@ -660,6 +661,16 @@ delegated_action_allowlist_through_scheduler() -> <<"authority-match">> => 1, <<"authority-actions">> => [<<"Transfer">>, <<"Subscribe">>, <<"Unsubscribe">>], + <<"set-authority">> => Admin, + <<"whitelisted-fields">> => [<<"allowed-subscriptions">>], + <<"allowed-subscriptions">> => + [ + #{ + <<"action">> => <<"register">>, + <<"target">> => <<"broadcast">>, + <<"listener">> => Dex + } + ], <<"mint-device">> => <<"mint-authority@1.0">>, <<"mint-authority">> => MintAuthority, <<"balances">> => #{ Dex => 10 }, @@ -689,7 +700,8 @@ delegated_action_allowlist_through_scheduler() -> #{ <<"action">> => <<"Subscribe">>, <<"from-process">> => Dex, - <<"subscribe-action">> => <<"register">> + <<"subscribe-action">> => <<"register">>, + <<"timestamp">> => 1 }, SchedulerWallet, SchedulerWallet, @@ -709,9 +721,37 @@ delegated_action_allowlist_through_scheduler() -> Opts ), ?assertEqual([], dev_token_lib:subscribers(Unsubscribed, <<"register">>, Opts)), - {3, RejectedMint} = + {3, SubscriptionsDisabled} = schedule_and_compute( Unsubscribed, + #{ + <<"action">> => <<"Set">>, + <<"allowed-subscriptions">> => [] + }, + AdminWallet, + AdminWallet, + Opts + ), + {4, RejectedSubscription} = + schedule_and_compute( + SubscriptionsDisabled, + #{ + <<"action">> => <<"Subscribe">>, + <<"from-process">> => Dex, + <<"subscribe-action">> => <<"register">>, + <<"timestamp">> => 2 + }, + SchedulerWallet, + SchedulerWallet, + Opts + ), + ?assertEqual( + [], + dev_token_lib:subscribers(RejectedSubscription, <<"register">>, Opts) + ), + {5, RejectedMint} = + schedule_and_compute( + RejectedSubscription, #{ <<"action">> => <<"Mint">>, <<"mint-nonce">> => 0, diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 234b716..17f1d6e 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -182,6 +182,15 @@ subscription_req(Action, Target, Listener, Slot) -> } }. +subscription_policy(Action, Target, Listener) -> + [ + #{ + <<"action">> => Action, + <<"target">> => Target, + <<"listener">> => Listener + } + ]. + has_message(Pairs, Msgs, Opts) -> lists:any( fun(Msg) -> @@ -756,6 +765,45 @@ outbox_unsubscribe_removes_listener_test() -> ), ?assertEqual(1, length(outbox(Updated, Opts))). +token_subscription_policy_test() -> + Opts = opts(), + Listener = hb_util:human_id(ar_wallet:new()), + Other = hb_util:human_id(ar_wallet:new()), + Policy = subscription_policy(<<"register">>, <<"broadcast">>, Listener), + Base = + token_state( + #{ + extra => #{ + <<"allowed-subscriptions">> => Policy + } + }, + Opts + ), + lists:foreach( + fun(Req) -> + ?assertMatch( + {error, _}, + dev_token:handle_action(<<"subscribe">>, Base, Req, Opts) + ) + end, + [ + subscription_req(<<"register">>, default, Other, 40), + subscription_req(<<"other">>, default, Listener, 40), + subscription_req(<<"register">>, <<"other">>, Listener, 40) + ] + ), + {ok, Subscribed} = + dev_token:handle_action( + <<"subscribe">>, + Base, + subscription_req(<<"register">>, default, Listener, 41), + Opts + ), + ?assertEqual( + [Listener], + outbox_subscribers(Subscribed, <<"register">>, Opts) + ). + fixed_supply_transfer_test() -> Opts = opts(), Alice = id(<<"alice">>), From 7ab27cacda5e74071c5f99bd4c15fb791cceb47a Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Wed, 15 Jul 2026 19:14:58 +0200 Subject: [PATCH 16/23] impr(security): no trie writes for 0-quantity transfers --- src/dev_token.erl | 21 +++++++++------- src/preloaded/test/hb_token_test_vectors.erl | 26 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index c8f5dbb..62192e3 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -332,6 +332,8 @@ transfer(Base, Assignment, Opts) -> {ok, Recipient0} ?= hb_ao:resolve(Req, <<"recipient">>, Opts), {ok, Quantity} ?= hb_ao:resolve(Req, <<"quantity">>, Opts), true ?= transfer_enabled(Base, Opts), + true ?= (is_integer(Quantity) and (Quantity >= 0)) + orelse {error, <<"Quantity must be a non-negative integer.">>}, % validate From/Recipient sanity true ?= lib_token:validate_address(From0, [], Opts), true ?= lib_token:validate_address(Recipient0, [], Opts), @@ -339,11 +341,14 @@ transfer(Base, Assignment, Opts) -> Recipient = lib_token:account_key(Recipient0), % Normalize the base's minting state for the sender. {ok, NormBase} ?= - normalize_mint( - Base, - Assignment#{ <<"subject">> => From }, - Opts - ), + case Quantity of + 0 -> {ok, Base}; + _ -> normalize_mint( + Base, + Assignment#{ <<"subject">> => From }, + Opts + ) + end, % Retrieve balances from the base state. Balances = hb_ao:get(<<"balances">>, NormBase, Opts), ?event(debug_token, {balances_before_transfer, Balances}, Opts), @@ -364,13 +369,11 @@ transfer(Base, Assignment, Opts) -> true ?= (is_integer(SenderBalance) and is_integer(RecipientBalance) and (SenderBalance >= 0) and (RecipientBalance >= 0)) orelse {error, <<"Invalid balance values.">>}, - true ?= (is_integer(Quantity) and (Quantity >= 0)) - orelse {error, <<"Quantity must be a non-negative integer.">>}, true ?= (SenderBalance >= Quantity) orelse {error, <<"Insufficient balance.">>}, - % Handle self-transfer: skip balance updates + % Handle zero and self transfers without balance-trie writes. NewBaseAfterTransfer = - case From =:= Recipient of + case (Quantity =:= 0) orelse (From =:= Recipient) of true -> NormBase; false -> {ok, NewBalances} = diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 17f1d6e..cd5c447 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -499,6 +499,32 @@ basic_transfer_updates_balances_test() -> lists:sort([hb_ao:get(<<"action">>, Notice, Opts) || Notice <- Notices]) ). +zero_transfer_emits_notices_without_balance_writes_test() -> + Opts = opts(), + Treasury = id(<<"treasury">>), + Sender = id(<<"sender">>), + Recipient = id(<<"recipient">>), + Base = token_state(#{ initial_balances => #{ Treasury => 1 } }, Opts), + Balances = hb_ao:get(<<"balances">>, Base, Opts), + {ok, Updated} = transfer(Base, Sender, Recipient, 0, Opts), + UpdatedBalances = hb_ao:get(<<"balances">>, Updated, Opts), + ?assertEqual(Balances, UpdatedBalances), + ?assertEqual( + {error, not_found}, + hb_ao:resolve(UpdatedBalances, account_key(Sender), Opts) + ), + ?assertEqual( + {error, not_found}, + hb_ao:resolve(UpdatedBalances, account_key(Recipient), Opts) + ), + ?assertEqual(1, hb_ao:get(<<"total-supply">>, Updated, Opts)), + Notices = outbox(Updated, Opts), + ?assertEqual(2, length(Notices)), + ?assert(lists:all( + fun(Notice) -> hb_ao:get(<<"quantity">>, Notice, Opts) =:= 0 end, + Notices + )). + transfer_enabled_defaults_to_opts_test() -> Opts = (opts())#{ <<"transfer-enabled">> => false }, Alice = id(<<"alice">>), From 4430386a86f781535a558699f094a8c37235f125 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Thu, 16 Jul 2026 12:21:09 +0200 Subject: [PATCH 17/23] chre(security): update config.json devices impls --- config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.json b/config.json index 2d529a0..ab155f8 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "trusted-devices": { - "mint-authority@1.0": "kR-AuG3o2-o10VF-QfTgMLGsP1uPo5ezE9VeE4EjbyU", + "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "b7Vxz1lKhQgMQYXPizAc3N3bFMU8h8U5meoECG265qc" + "security@1.0": "Tt9h03Q3QMg4F30kVs9sCij8meZR5UIhJPcEq4n6Bx0" } } From 001c5ebf5a5e7ab8bfe20180c9a28adf0c9157f6 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 17 Jul 2026 11:50:17 +0200 Subject: [PATCH 18/23] chore: bump hb dep --- rebar.config | 4 ++-- src/preloaded/test/hb_token_test_vectors.erl | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/rebar.config b/rebar.config index 02af3f4..1d33ba3 100644 --- a/rebar.config +++ b/rebar.config @@ -4,7 +4,7 @@ {elmdb, {git, "https://github.com/permaweb/elmdb-rs.git", {ref, "faa762323be6bad2db26abfa1d4243863a877f0f"}}}, {hb, {git, "https://github.com/permaweb/HyperBEAM.git", - {ref, "5100535797bb8c221e609db10ff7bad09308eb17"}}} + {ref, "10c0b765cc235d52086a6bd30d2cd9d698d9deca"}}} ]}. {overrides, [ @@ -15,7 +15,7 @@ {plugins, [ {plugin, {git_subdir, "https://github.com/permaweb/HyperBEAM.git", - {ref, "5100535797bb8c221e609db10ff7bad09308eb17"}, + {ref, "10c0b765cc235d52086a6bd30d2cd9d698d9deca"}, "src/forge"}}, {rebar3_eunit_start, {git, "https://github.com/permaweb/rebar3_eunit_start.git", diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index cd5c447..1b5f752 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -16,12 +16,12 @@ opts() -> id(Bin) when is_binary(Bin) -> BitSize = byte_size(Bin) * 8, Suffix = <<0:(256 - BitSize)>>, - <>; + hb_util:human_id(<>); id(Other) -> hb_util:human_id(Other). account_key(Account) -> - hb_util:to_lower(Account). + lib_token:account_key(Account). ensure_lib_token() -> case code:ensure_loaded(lib_token) of @@ -394,7 +394,7 @@ init_rejects_invalid_initial_balances_test() -> { #{ MixedAlice => 7, <<" invalid">> => 3 }, 10, - <<"Address cannot contain path separators or whitespaces">> + <<"Address contains unsupported characters.">> } ], lists:foreach( @@ -458,7 +458,7 @@ balance_reserved_account_rejected_test() -> Opts = opts(), Base = token_state(#{}, Opts), ?assertEqual( - {error, <<"Address is a reserved ao/custom key">>}, + {error, <<"Address uses the reserved path key.">>}, public_balance(Base, <<"path">>, Opts) ). @@ -466,7 +466,7 @@ uppercase_reserved_account_rejected_test() -> Opts = opts(), Base = token_state(#{}, Opts), ?assertEqual( - {error, <<"Address is a reserved ao/custom key">>}, + {error, <<"Address uses the reserved path key.">>}, public_balance(Base, <<"PATH">>, Opts) ), ?assertEqual( From d83b1f753ebeacac44ed2929a63f0dd5a5f177b0 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Thu, 6 Aug 2026 14:08:57 +0200 Subject: [PATCH 19/23] refactor: arweave-scheduler@1.0 adapting --- README.md | 8 +- config.json | 2 +- src/dev_token.erl | 140 +++++++++++++++---- src/preloaded/test/hb_token_test_vectors.erl | 55 +++++++- 4 files changed, 170 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index f9de919..fe87a7c 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ non-negative `total-supply` equal to their sum. Invalid genesis state fails clos ```bash Published device: token@1.0; -Specification ID: 1L0E5HKuNtcju4Qg8A8l8MsSPOTInLfK4G9w-oLjIRY; +Specification ID: KHSPOf6n-B6EXKF-9adTYzo6S_Sl3_GY1Qy0f4sL_QQ; -Implementation ID: CBJEqrdujJE-aNOLjHeYpEQW5Po26ZQqnTHFln4ovIY; +Implementation ID: mwLP8r5OobCcudFPJJeRs6XepmkoZy5ZW9R8BXs3bFg; Signer: vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0; ``` @@ -49,9 +49,9 @@ src: ```json { "trusted-devices": { - "mint-authority@1.0": "CMZEmkcHengcoDdM_eK2inBmXHpBkm2iPIiw-r6XQPs", + "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" + "security@1.0": "7jivsYHKbkfca8emXGepD-T0KQ6cI7ehGLeU3Mn8g6M" } } ``` diff --git a/config.json b/config.json index ab155f8..d8ef8fc 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,6 @@ "trusted-devices": { "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "Tt9h03Q3QMg4F30kVs9sCij8meZR5UIhJPcEq4n6Bx0" + "security@1.0": "7jivsYHKbkfca8emXGepD-T0KQ6cI7ehGLeU3Mn8g6M" } } diff --git a/src/dev_token.erl b/src/dev_token.erl index 62192e3..c12c4e9 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -147,7 +147,7 @@ snapshot(Base, _Req, _Opts) -> canonicalize_balances(Base, Opts) -> maybe - Balances0 = hb_maps:get(<<"balances">>, Base, not_found, Opts), + Balances0 = initial_balances(Base, Opts), true ?= (Balances0 =/= not_found) orelse {error, <<"Balances not found.">>}, Balances = hb_cache:ensure_all_loaded(Balances0, Opts), @@ -156,11 +156,25 @@ canonicalize_balances(Base, Opts) -> canonicalize_balances(Base, Balances, Opts) end. +initial_balances(Base, Opts) -> + case hb_maps:get(<<"balances">>, Base, not_found, Opts) of + not_found -> + case hb_maps:get(<<"initial-holder">>, Base, not_found, Opts) of + not_found -> not_found; + Holder -> + #{Holder => hb_maps:get(<<"total-supply">>, Base, not_found, Opts)} + end; + Balances -> + Balances + end. + canonicalize_balances(Base, Balances, Opts) -> maybe - TotalSupply = hb_ao:get(<<"total-supply">>, Base, not_found, Opts), - true ?= (is_integer(TotalSupply) andalso TotalSupply >= 0) orelse - {error, <<"Total supply must be a non-negative integer.">>}, + {ok, TotalSupply} ?= + nonneg_int( + hb_ao:get(<<"total-supply">>, Base, not_found, Opts), + <<"Total supply must be a non-negative integer.">> + ), {ok, Changed, FlatBalances} ?= lists:foldl( fun @@ -169,10 +183,12 @@ canonicalize_balances(Base, Balances, Opts) -> (Key, {ok, ChangedAcc, BalancesAcc}) -> maybe true ?= lib_token:validate_address(Key, [], Opts), - {ok, Amount} ?= hb_ao:resolve(Balances, Key, Opts), - true ?= - (is_integer(Amount) andalso Amount >= 0) orelse - {error, <<"Balance amounts must be non-negative integers.">>}, + {ok, Amount0} ?= hb_ao:resolve(Balances, Key, Opts), + {ok, Amount} ?= + nonneg_int( + Amount0, + <<"Balance amounts must be non-negative integers.">> + ), Account = lib_token:account_key(Key), { ok, @@ -209,27 +225,52 @@ canonicalize_balances(Base, Balances, Opts) -> %% interactions do not result in invalid `~process@1.0' states. compute(Base, Assignment, Opts) -> ?event({token_call, Assignment}), - case deduplicate(Base, Assignment, Opts) of - {skip, DedupedBase} -> - ?event(token_short, {skipping_duplicate_assignment, Assignment}, Opts), - {ok, DedupedBase}; - {ok, DedupedBase} -> - maybe - {ok, SecureReq} ?= enforce_security(DedupedBase, Assignment, Opts), - {ok, Action} ?= hb_ao:resolve(Assignment, <<"body/action">>, Opts), - {ok, Res} ?= handle_action(Action, DedupedBase, SecureReq, Opts), - ?event(debug_token, {route_result, Res}, Opts), - {ok, Res} - else + case assignment_targets_process(Base, Assignment, Opts) of + true -> + case deduplicate(Base, Assignment, Opts) of + {skip, DedupedBase} -> + ?event(token_short, {skipping_duplicate_assignment, Assignment}, Opts), + {ok, DedupedBase}; + {ok, DedupedBase} -> + maybe + {ok, SecureReq} ?= enforce_security(DedupedBase, Assignment, Opts), + {ok, Action} ?= hb_ao:resolve(Assignment, <<"body/action">>, Opts), + {ok, Res} ?= handle_action(Action, DedupedBase, SecureReq, Opts), + ?event(debug_token, {route_result, Res}, Opts), + {ok, Res} + else + {error, Reason} -> + ?event(token_short, {error_during_token_call, Reason}, Opts), + send_error(Base, Assignment, Reason, Opts) + end; {error, Reason} -> - ?event(token_short, {error_during_token_call, Reason}, Opts), + ?event(token_short, {error_during_token_dedup, Reason}, Opts), send_error(Base, Assignment, Reason, Opts) end; - {error, Reason} -> - ?event(token_short, {error_during_token_dedup, Reason}, Opts), - send_error(Base, Assignment, Reason, Opts) + false -> + ?event(token_short, {skipping_non_target_assignment, Assignment}, Opts), + {ok, Base} + end. + +assignment_targets_process(Base, Assignment, Opts) -> + case all_mode_arweave_scheduler(Base, Opts) of + true -> + case hb_ao:resolve(Assignment, <<"body/target">>, Opts) of + {ok, Target} -> Target =:= current_process_id(Base, Opts); + {error, _} -> false + end; + _ -> + true end. +all_mode_arweave_scheduler(Base, Opts) -> + hb_maps:get(<<"scheduler-device">>, Base, not_found, Opts) =:= <<"arweave-scheduler@1.0">> + andalso hb_maps:get(<<"scheduler-mode">>, Base, not_found, Opts) =:= <<"all">>. + +current_process_id(Base, Opts) -> + Msg = hb_ao:get(<<"process">>, ensure_process_key(Base, Opts), Opts), + hb_message:id(Msg, signed, Opts). + %% @doc Deduplicate token computations by the signed assignment body. Replayed %% assignments get fresh slots, so the assignment itself cannot be the subject. deduplicate(Base, Assignment, Opts) -> @@ -330,10 +371,13 @@ transfer(Base, Assignment, Opts) -> {ok, Req} ?= hb_ao:resolve(Assignment, <<"body">>, Opts), {ok, From0} ?= hb_ao:resolve(Req, <<"from">>, Opts), {ok, Recipient0} ?= hb_ao:resolve(Req, <<"recipient">>, Opts), - {ok, Quantity} ?= hb_ao:resolve(Req, <<"quantity">>, Opts), + {ok, Quantity0} ?= action_field(Req, <<"quantity">>, Opts), + {ok, Quantity} ?= + nonneg_int( + Quantity0, + <<"Quantity must be a non-negative integer.">> + ), true ?= transfer_enabled(Base, Opts), - true ?= (is_integer(Quantity) and (Quantity >= 0)) - orelse {error, <<"Quantity must be a non-negative integer.">>}, % validate From/Recipient sanity true ?= lib_token:validate_address(From0, [], Opts), true ?= lib_token:validate_address(Recipient0, [], Opts), @@ -345,7 +389,9 @@ transfer(Base, Assignment, Opts) -> 0 -> {ok, Base}; _ -> normalize_mint( Base, - Assignment#{ <<"subject">> => From }, + Assignment#{ + <<"body">> => hb_ao:set(Req, <<"subject">>, From, Opts) + }, Opts ) end, @@ -765,3 +811,41 @@ send_error(Base, Assignment, Reason, Opts) when is_binary(Reason) -> Opts ) end. + +action_field(Msg, Key, Opts) -> + case tag_field(Msg, Key, Opts) of + not_found -> hb_ao:resolve(Msg, Key, Opts); + Value -> {ok, Value} + end. + +tag_field(Msg, Key, Opts) -> + case hb_message:commitment(#{ <<"commitment-device">> => <<"tx@1.0">> }, Msg, Opts) of + {ok, _ID, Commitment} -> + case hb_maps:get(<<"original-tags">>, Commitment, not_found, Opts) of + Tags when is_map(Tags) -> tag_value(Tags, Key, Opts); + _ -> not_found + end; + _ -> + not_found + end. + +tag_value(Tags, Key, Opts) -> + hb_maps:fold( + fun(_Index, #{ <<"name">> := Name, <<"value">> := Value }, not_found) -> + case hb_util:to_lower(Name) =:= Key of + true -> Value; + false -> not_found + end; + (_Index, _Tag, Acc) -> + Acc + end, + not_found, + Tags, + Opts + ). + +nonneg_int(Value, Error) -> + case hb_util:safe_int(Value) of + {ok, Int} when Int >= 0 -> {ok, Int}; + _ -> {error, Error} + end. diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 1b5f752..429c697 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -337,6 +337,43 @@ scheduled_non_compute_paths_rejected_test() -> ) ). +all_mode_unrelated_target_assignment_is_ignored_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 100 }, + extra => + #{ + <<"scheduler-device">> => <<"arweave-scheduler@1.0">>, + <<"scheduler-mode">> => <<"all">> + } + }, + Opts + ), + Assignment = + #{ + <<"path">> => <<"compute">>, + <<"type">> => <<"Assignment">>, + <<"slot">> => 42, + <<"body">> => + #{ + <<"target">> => id(<<"other-process">>), + <<"action">> => <<"transfer">>, + <<"from">> => Alice, + <<"recipient">> => Bob, + <<"quantity">> => 10 + } + }, + {ok, Ignored} = dev_token:compute(Base, Assignment, Opts), + ?assertEqual(Base, Ignored), + ?assertEqual(100, balance(Ignored, Alice, Opts)), + ?assertEqual(0, balance(Ignored, Bob, Opts)), + ?assertEqual(not_found, hb_ao:get(<<"dedup">>, Ignored, not_found, Opts)), + ?assertEqual(not_found, hb_ao:get(<<"results/outbox">>, Ignored, not_found, Opts)). + mixed_case_initial_balance_uses_canonical_account_test() -> Opts = opts(), Alice = id(<<"Alice">>), @@ -387,7 +424,7 @@ init_rejects_invalid_initial_balances_test() -> <<"Balance amounts must be non-negative integers.">> }, { - #{ MixedAlice => 7, Bob => <<"3">> }, + #{ MixedAlice => 7, Bob => <<"three">> }, 10, <<"Balance amounts must be non-negative integers.">> }, @@ -431,7 +468,7 @@ init_rejects_invalid_total_supply_test() -> dev_token:init(Base, #{}, Opts) ) end, - [-1, <<"7">>] + [-1, <<"seven">>] ), Mismatched = raw_token_state( @@ -443,6 +480,20 @@ init_rejects_invalid_total_supply_test() -> dev_token:init(Mismatched, #{}, Opts) ). +init_seeds_initial_holder_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Base = + raw_token_state( + #{ + <<"initial-holder">> => Alice, + <<"total-supply">> => <<"7">> + }, + Opts + ), + {ok, Initialized} = dev_token:init(Base, #{}, Opts), + ?assertEqual({ok, 7}, public_balance(Initialized, Alice, Opts)). + balance_missing_account_returns_zero_test() -> Opts = opts(), Alice = id(<<"alice">>), From e3f95cc545b961ae3dd043e0bd35e9dda6d9f5fa Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Thu, 6 Aug 2026 16:12:47 +0200 Subject: [PATCH 20/23] chore: new device publish instance --- README.md | 6 +++--- config.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe87a7c..2a05b1a 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ non-negative `total-supply` equal to their sum. Invalid genesis state fails clos ```bash Published device: token@1.0; -Specification ID: KHSPOf6n-B6EXKF-9adTYzo6S_Sl3_GY1Qy0f4sL_QQ; +Specification ID: nUrsJwYB4BEOS0ZKKGtRsQf8kxupjWdYrlYcBZNW3U8; -Implementation ID: mwLP8r5OobCcudFPJJeRs6XepmkoZy5ZW9R8BXs3bFg; +Implementation ID: dYq-4NC3y3XjpMbUG41ROYcCRHdA8wpaXPJ5_FZeQDk; Signer: vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0; ``` @@ -51,7 +51,7 @@ src: "trusted-devices": { "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "7jivsYHKbkfca8emXGepD-T0KQ6cI7ehGLeU3Mn8g6M" + "security@1.0": "RrkCKxGm72vA9tuDAxKSgDzfvdDhwk1dM0g2ZWmtRKI" } } ``` diff --git a/config.json b/config.json index d8ef8fc..8c5da85 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,6 @@ "trusted-devices": { "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "7jivsYHKbkfca8emXGepD-T0KQ6cI7ehGLeU3Mn8g6M" + "security@1.0": "RrkCKxGm72vA9tuDAxKSgDzfvdDhwk1dM0g2ZWmtRKI" } } From 7c018cbc633b6c949d76a2cc61e219c7366028ac Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Fri, 7 Aug 2026 11:48:41 +0200 Subject: [PATCH 21/23] fix token arweave-scheduler all-mode routing --- src/dev_token.erl | 40 ++++++--- src/preloaded/test/hb_token_test_vectors.erl | 88 ++++++++++++++++++++ 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index c12c4e9..3012671 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -255,10 +255,8 @@ compute(Base, Assignment, Opts) -> assignment_targets_process(Base, Assignment, Opts) -> case all_mode_arweave_scheduler(Base, Opts) of true -> - case hb_ao:resolve(Assignment, <<"body/target">>, Opts) of - {ok, Target} -> Target =:= current_process_id(Base, Opts); - {error, _} -> false - end; + Body = hb_maps:get(<<"body">>, Assignment, #{}, Opts), + tx_field(Body, <<"target">>, <<>>, Opts) =:= current_process_id(Base, Opts); _ -> true end. @@ -830,19 +828,35 @@ tag_field(Msg, Key, Opts) -> end. tag_value(Tags, Key, Opts) -> - hb_maps:fold( - fun(_Index, #{ <<"name">> := Name, <<"value">> := Value }, not_found) -> + Matches = + hb_maps:fold( + fun(_Index, #{ <<"name">> := Name, <<"value">> := Value }, Acc) -> case hb_util:to_lower(Name) =:= Key of - true -> Value; - false -> not_found + true -> [Value | Acc]; + false -> Acc end; (_Index, _Tag, Acc) -> Acc - end, - not_found, - Tags, - Opts - ). + end, + [], + Tags, + Opts + ), + case Matches of + [Value] -> Value; + _ -> not_found + end. + +%% @doc Read a value from the real L1 transaction fields recorded in the +%% `tx@1.0' commitment. Top-level keys may come from tags with the same names, +%% so all-mode process routing must not use them. +tx_field(Body, Field, Default, Opts) -> + case hb_message:commitment(#{ <<"commitment-device">> => <<"tx@1.0">> }, Body, Opts) of + {ok, _ID, Commitment} -> + hb_maps:get(<<"field-", Field/binary>>, Commitment, Default, Opts); + _ -> + Default + end. nonneg_int(Value, Error) -> case hb_util:safe_int(Value) of diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 429c697..1e81f2f 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -219,6 +219,14 @@ transfer(State, From, To, Quantity, Opts) -> Opts ). +tag_only_tx(Wallet, Tags) -> + Signed = ar_tx:sign(#tx{ format = 2, reward = 1, tags = Tags }, Wallet), + hb_message:convert(Signed, <<"structured@1.0">>, <<"tx@1.0">>, #{}). + +process_id(Process, Opts) -> + {ok, Committed} = hb_message:with_only_committed(Process, Opts), + hb_message:id(Committed, signed, Opts). + mint(State, From, Recipient, Quantity, Opts) -> mint(State, From, Recipient, Quantity, next_mint_nonce(State, Opts), Opts). @@ -374,6 +382,86 @@ all_mode_unrelated_target_assignment_is_ignored_test() -> ?assertEqual(not_found, hb_ao:get(<<"dedup">>, Ignored, not_found, Opts)), ?assertEqual(not_found, hb_ao:get(<<"results/outbox">>, Ignored, not_found, Opts)). +all_mode_tag_only_target_assignment_is_ignored_test() -> + Opts = opts(), + AliceWallet = ar_wallet:new(), + Alice = hb_util:human_id(ar_wallet:to_address(AliceWallet)), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 100 }, + extra => + #{ + <<"scheduler-device">> => <<"arweave-scheduler@1.0">>, + <<"scheduler-mode">> => <<"all">>, + <<"security-device">> => <<"security@1.0">> + } + }, + Opts + ), + ProcessID = process_id(Base, Opts), + Body = + tag_only_tx( + AliceWallet, + [ + {<<"target">>, ProcessID}, + {<<"action">>, <<"transfer">>}, + {<<"recipient">>, Bob}, + {<<"quantity">>, <<"10">>} + ] + ), + Assignment = + #{ + <<"path">> => <<"compute">>, + <<"type">> => <<"Assignment">>, + <<"slot">> => 43, + <<"process">> => ProcessID, + <<"body">> => Body + }, + ?assertEqual(ProcessID, hb_ao:get(<<"target">>, Body, not_found, Opts)), + {ok, Ignored} = dev_token:compute(Base, Assignment, Opts), + ?assertEqual(Base, Ignored), + ?assertEqual(100, balance(Ignored, Alice, Opts)), + ?assertEqual(0, balance(Ignored, Bob, Opts)), + ?assertEqual(not_found, hb_ao:get(<<"dedup">>, Ignored, not_found, Opts)). + +duplicate_quantity_tags_are_not_first_match_transfer_test() -> + Opts = opts(), + Alice = id(<<"alice">>), + Bob = id(<<"bob">>), + Base = + token_state( + #{ + initial_balances => #{ Alice => 100 } + }, + Opts + ), + Body0 = + hb_message:convert( + #tx{ + format = 2, + tags = + [ + {<<"recipient">>, Bob}, + {<<"quantity">>, <<"10">>}, + {<<"Quantity">>, <<"90">>} + ] + }, + <<"structured@1.0">>, + <<"tx@1.0">>, + Opts + ), + {ok, Ignored} = + dev_token:handle_action( + <<"transfer">>, + Base, + #{ <<"body">> => Body0#{ <<"from">> => Alice } }, + Opts + ), + ?assertEqual(100, balance(Ignored, Alice, Opts)), + ?assertEqual(0, balance(Ignored, Bob, Opts)). + mixed_case_initial_balance_uses_canonical_account_test() -> Opts = opts(), Alice = id(<<"Alice">>), From 6629d51eb3748c7343676e15c3f80c1633f356a2 Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Tue, 11 Aug 2026 10:20:43 +0200 Subject: [PATCH 22/23] chore: Account -> ID terminology update --- src/dev_token.erl | 43 ++++++++++--------- src/dev_token_lib.erl | 20 ++++----- ...b_token_process_scheduler_test_vectors.erl | 4 +- src/preloaded/test/hb_token_props.erl | 16 +++---- src/preloaded/test/hb_token_test_vectors.erl | 42 +++++++++--------- 5 files changed, 64 insertions(+), 61 deletions(-) diff --git a/src/dev_token.erl b/src/dev_token.erl index 3012671..72914e7 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -133,7 +133,7 @@ is_compute_path(Req, Opts) -> _ -> false end. -%% @doc Validate initial supply and canonicalize account keys. +%% @doc Validate initial supply and canonicalize balance-holder ID keys. init(Base, _Req, Opts) -> canonicalize_balances(Base, Opts). @@ -189,13 +189,13 @@ canonicalize_balances(Base, Balances, Opts) -> Amount0, <<"Balance amounts must be non-negative integers.">> ), - Account = lib_token:account_key(Key), + ID = id_key(Key), { ok, ChangedAcc - orelse (Account =/= Key) - orelse maps:is_key(Account, BalancesAcc), - add_balance(Account, Amount, BalancesAcc) + orelse (ID =/= Key) + orelse maps:is_key(ID, BalancesAcc), + add_balance(ID, Amount, BalancesAcc) } end end, @@ -317,18 +317,18 @@ handle_action(Action, Base, Req, Opts) when is_binary(Action) -> handle_action(_Action, _Base, _Req, _Opts) -> {error, <<"Invalid Action format">>}. -%% @doc Get the balance for an account. Normalize the minting state for that -%% account before returning. +%% @doc Get the balance for an ID. Normalize the minting state for that ID +%% before returning. balance(Base, Req, Opts) -> maybe - {ok, Account0} ?= hb_ao:resolve(Req, <<"balance">>, Opts), - true ?= lib_token:validate_address(Account0, [], Opts), - Account = lib_token:account_key(Account0), + {ok, ID0} ?= hb_ao:resolve(Req, <<"balance">>, Opts), + true ?= lib_token:validate_address(ID0, [], Opts), + ID = id_key(ID0), ?event( debug_token, {balance_request, - {account, Account0}, - {canonical_account, Account}, + {id, ID0}, + {canonical_id, ID}, {base, Base} }, Opts @@ -336,7 +336,7 @@ balance(Base, Req, Opts) -> {ok, NormBase} ?= normalize_mint( Base, - hb_ao:set(Req, <<"subject">>, Account, Opts), + hb_ao:set(Req, <<"subject">>, ID, Opts), Opts ), BalanceRes = @@ -344,14 +344,14 @@ balance(Base, Req, Opts) -> [ NormBase, <<"balances">>, - Account + ID ], Opts ), ?event( debug_token, {balance_after_mint_normalization, - {account, Account}, + {id, ID}, {balance, BalanceRes} }, Opts @@ -379,8 +379,8 @@ transfer(Base, Assignment, Opts) -> % validate From/Recipient sanity true ?= lib_token:validate_address(From0, [], Opts), true ?= lib_token:validate_address(Recipient0, [], Opts), - From = lib_token:account_key(From0), - Recipient = lib_token:account_key(Recipient0), + From = id_key(From0), + Recipient = id_key(Recipient0), % Normalize the base's minting state for the sender. {ok, NormBase} ?= case Quantity of @@ -507,7 +507,7 @@ mint_request(Base, Assignment, Opts) -> hb_ao:set( Assignment, <<"subject">>, - lib_token:account_key(Subject), + id_key(Subject), Opts ), as_mint_device(<<"mint">>, Base, MintReq1, Opts) @@ -646,8 +646,11 @@ trie_keys(Balances, Opts) -> {ok, Trie} = hb_device_load:reference(<<"trie@1.0">>, Opts), Trie:keys(Balances, Opts). -add_balance(Account, Amount, Balances) -> - Balances#{ Account => maps:get(Account, Balances, 0) + Amount }. +id_key(ID) when is_binary(ID) -> + hb_util:to_lower(hb_ao:normalize_key(ID)). + +add_balance(ID, Amount, Balances) -> + Balances#{ ID => maps:get(ID, Balances, 0) + Amount }. outbox_send(Messages, Base, Opts) -> maybe diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index 9478e88..49631e0 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -212,18 +212,18 @@ process_id(Process, Req, Opts) -> ) end. -%% @doc Retreive a single balance from the ledger. +%% @doc Retrieve a single ID balance from the ledger. balance(ProcMsg, User, Opts) when not ?IS_ID(User) -> balance(ProcMsg, hb_util:human_id(ar_wallet:to_address(User)), Opts); balance(ProcMsg, ID, Opts) -> - Account = account_key(ID), - case hb_ao:get(<<"now/balances/", Account/binary>>, ProcMsg, not_found, Opts) of + IDKey = id_key(ID), + case hb_ao:get(<<"now/balances/", IDKey/binary>>, ProcMsg, not_found, Opts) of not_found -> hb_ao:get(<<"now/balances/", ID/binary>>, ProcMsg, 0, Opts); Balance -> Balance end. %% @doc Retrieve a single balance through the execution device's `balance` -%% path, allowing lazy mint devices to normalize account state first. +%% path, allowing lazy mint devices to normalize ID state first. normalized_balance(ProcMsg, User, Opts) when not ?IS_ID(User) -> normalized_balance(ProcMsg, hb_util:human_id(ar_wallet:to_address(User)), Opts); normalized_balance(ProcMsg, ID, Opts) -> @@ -490,16 +490,16 @@ normalize_env(Procs) when is_list(Procs) -> normalize_without_root(RootProc, Procs) -> maps:without([hb_message:id(RootProc, all)], normalize_env(Procs)). -account_key(Account) when is_binary(Account) -> - lib_token:account_key(Account). +id_key(ID) when is_binary(ID) -> + hb_util:to_lower(hb_ao:normalize_key(ID)). canonical_balances(Balances) -> lists:foldl( fun ({ID, Amount}, Acc) when ?IS_ID(ID) -> - add_balance(account_key(hb_util:human_id(ID)), Amount, Acc); + add_balance(id_key(hb_util:human_id(ID)), Amount, Acc); ({Wallet, Amount}, Acc) when is_tuple(Wallet) -> - add_balance(account_key(hb_util:human_id(Wallet)), Amount, Acc); + add_balance(id_key(hb_util:human_id(Wallet)), Amount, Acc); (_Other, Acc) -> Acc end, @@ -507,5 +507,5 @@ canonical_balances(Balances) -> maps:to_list(Balances) ). -add_balance(Account, Amount, Balances) -> - Balances#{ Account => maps:get(Account, Balances, 0) + Amount }. +add_balance(ID, Amount, Balances) -> + Balances#{ ID => maps:get(ID, Balances, 0) + Amount }. diff --git a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl index 039b014..71f3436 100644 --- a/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -152,8 +152,8 @@ schedule_and_compute(Process, Body, BodyWallets, RequestWallet, Opts) -> ), {Slot, Computed}. -balance(Process, Account, Opts) -> - dev_token_lib:balance(Process, Account, Opts). +balance(Process, ID, Opts) -> + dev_token_lib:balance(Process, ID, Opts). state_field(Process, Key, Default, Opts) -> hb_ao:get(<<"now/", Key/binary>>, Process, Default, Opts). diff --git a/src/preloaded/test/hb_token_props.erl b/src/preloaded/test/hb_token_props.erl index 5e70e8c..06545f5 100644 --- a/src/preloaded/test/hb_token_props.erl +++ b/src/preloaded/test/hb_token_props.erl @@ -403,10 +403,10 @@ balances(Prefix, ProcMsg, Opts) -> ). balance(ID, ProcMsg, Opts) -> - Account = account_key(ID), - case hb_ao:get(<<"balances/", Account/binary>>, ProcMsg, not_found, Opts) of + IDKey = id_key(ID), + case hb_ao:get(<<"balances/", IDKey/binary>>, ProcMsg, not_found, Opts) of not_found -> - case hb_ao:get(<<"balance/", Account/binary>>, ProcMsg, not_found, Opts) of + case hb_ao:get(<<"balance/", IDKey/binary>>, ProcMsg, not_found, Opts) of not_found -> case hb_ao:get(<<"balances/", ID/binary>>, ProcMsg, not_found, Opts) of not_found -> @@ -423,15 +423,15 @@ balance(ID, ProcMsg, Opts) -> canonical_balances(Balances) -> maps:fold( - fun(Account, Amount, Acc) when is_number(Amount) -> - Key = account_key(Account), + fun(ID, Amount, Acc) when is_number(Amount) -> + Key = id_key(ID), Acc#{ Key => maps:get(Key, Acc, 0) + Amount }; - (_Account, _Amount, Acc) -> + (_ID, _Amount, Acc) -> Acc end, #{}, Balances ). -account_key(Account) when is_binary(Account) -> - hb_util:to_lower(Account). +id_key(ID) when is_binary(ID) -> + hb_util:to_lower(ID). diff --git a/src/preloaded/test/hb_token_test_vectors.erl b/src/preloaded/test/hb_token_test_vectors.erl index 1e81f2f..15aad60 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -20,8 +20,8 @@ id(Bin) when is_binary(Bin) -> id(Other) -> hb_util:human_id(Other). -account_key(Account) -> - lib_token:account_key(Account). +id_key(ID) -> + hb_util:to_lower(hb_ao:normalize_key(ID)). ensure_lib_token() -> case code:ensure_loaded(lib_token) of @@ -64,8 +64,8 @@ lib_token_source() -> canonical_balances(Balances) -> maps:fold( - fun(Account, Amount, Acc) -> - Key = account_key(Account), + fun(ID, Amount, Acc) -> + Key = id_key(ID), Acc#{ Key => maps:get(Key, Acc, 0) + Amount } end, #{}, @@ -116,15 +116,15 @@ raw_token_state(Fields, Opts) -> Opts ). -balance(State, Account, Opts) -> +balance(State, ID, Opts) -> Balances = hb_ao:get(<<"balances">>, State, Opts), - case hb_ao:resolve(Balances, account_key(Account), Opts) of + case hb_ao:resolve(Balances, id_key(ID), Opts) of {ok, Amount} -> Amount; {error, not_found} -> 0 end. -public_balance(State, Account, Opts) -> - dev_token:balance(State, #{ <<"balance">> => Account }, Opts). +public_balance(State, ID, Opts) -> + dev_token:balance(State, #{ <<"balance">> => ID }, Opts). outbox(State, Opts) -> hb_util:message_to_ordered_list( @@ -291,7 +291,7 @@ signed_set_field(State, From, Fields, Opts) -> Opts ). -balance_existing_account_test() -> +balance_existing_id_test() -> Opts = opts(), Alice = id(<<"alice">>), Base = @@ -462,7 +462,7 @@ duplicate_quantity_tags_are_not_first_match_transfer_test() -> ?assertEqual(100, balance(Ignored, Alice, Opts)), ?assertEqual(0, balance(Ignored, Bob, Opts)). -mixed_case_initial_balance_uses_canonical_account_test() -> +mixed_case_initial_balance_uses_canonical_id_test() -> Opts = opts(), Alice = id(<<"Alice">>), Base = @@ -472,9 +472,9 @@ mixed_case_initial_balance_uses_canonical_account_test() -> ), Balances = hb_ao:get(<<"balances">>, Base, Opts), ?assertEqual({error, not_found}, hb_ao:resolve(Balances, Alice, Opts)), - ?assertEqual({ok, 7}, hb_ao:resolve(Balances, account_key(Alice), Opts)), + ?assertEqual({ok, 7}, hb_ao:resolve(Balances, id_key(Alice), Opts)), ?assertEqual({ok, 7}, public_balance(Base, Alice, Opts)), - ?assertEqual({ok, 7}, public_balance(Base, account_key(Alice), Opts)). + ?assertEqual({ok, 7}, public_balance(Base, id_key(Alice), Opts)). init_canonicalizes_raw_initial_balances_test() -> Opts = opts(), @@ -482,7 +482,7 @@ init_canonicalizes_raw_initial_balances_test() -> {ok, RawBalances} = hb_ao:resolve( #{ <<"device">> => <<"trie@1.0">> }, - #{ Alice => 7, account_key(Alice) => 3, <<"path">> => <<"set">> }, + #{ Alice => 7, id_key(Alice) => 3, <<"path">> => <<"set">> }, Opts ), Base = @@ -496,7 +496,7 @@ init_canonicalizes_raw_initial_balances_test() -> {ok, Initialized} = dev_token:init(Base, #{}, Opts), Balances = hb_ao:get(<<"balances">>, Initialized, Opts), ?assertEqual({error, not_found}, hb_ao:resolve(Balances, Alice, Opts)), - ?assertEqual({ok, 10}, hb_ao:resolve(Balances, account_key(Alice), Opts)), + ?assertEqual({ok, 10}, hb_ao:resolve(Balances, id_key(Alice), Opts)), ?assertEqual({ok, 10}, public_balance(Initialized, Alice, Opts)). init_rejects_invalid_initial_balances_test() -> @@ -582,7 +582,7 @@ init_seeds_initial_holder_test() -> {ok, Initialized} = dev_token:init(Base, #{}, Opts), ?assertEqual({ok, 7}, public_balance(Initialized, Alice, Opts)). -balance_missing_account_returns_zero_test() -> +balance_missing_id_returns_zero_test() -> Opts = opts(), Alice = id(<<"alice">>), Bob = id(<<"bob">>), @@ -593,7 +593,7 @@ balance_missing_account_returns_zero_test() -> ), ?assertEqual({ok, 0}, public_balance(Base, Bob, Opts)). -balance_reserved_account_rejected_test() -> +balance_reserved_id_rejected_test() -> Opts = opts(), Base = token_state(#{}, Opts), ?assertEqual( @@ -601,7 +601,7 @@ balance_reserved_account_rejected_test() -> public_balance(Base, <<"path">>, Opts) ). -uppercase_reserved_account_rejected_test() -> +uppercase_reserved_id_rejected_test() -> Opts = opts(), Base = token_state(#{}, Opts), ?assertEqual( @@ -650,11 +650,11 @@ zero_transfer_emits_notices_without_balance_writes_test() -> ?assertEqual(Balances, UpdatedBalances), ?assertEqual( {error, not_found}, - hb_ao:resolve(UpdatedBalances, account_key(Sender), Opts) + hb_ao:resolve(UpdatedBalances, id_key(Sender), Opts) ), ?assertEqual( {error, not_found}, - hb_ao:resolve(UpdatedBalances, account_key(Recipient), Opts) + hb_ao:resolve(UpdatedBalances, id_key(Recipient), Opts) ), ?assertEqual(1, hb_ao:get(<<"total-supply">>, Updated, Opts)), Notices = outbox(Updated, Opts), @@ -794,8 +794,8 @@ mixed_case_transfer_updates_canonical_balances_test() -> {ok, Updated} = transfer(Base, Alice, Bob, 3, Opts), ?assertEqual(7, balance(Updated, Alice, Opts)), ?assertEqual(4, balance(Updated, Bob, Opts)), - ?assertEqual(7, balance(Updated, account_key(Alice), Opts)), - ?assertEqual(4, balance(Updated, account_key(Bob), Opts)), + ?assertEqual(7, balance(Updated, id_key(Alice), Opts)), + ?assertEqual(4, balance(Updated, id_key(Bob), Opts)), Notices = outbox(Updated, Opts), [Debit] = [ Notice From 07e2892108c0bd2e84422a3cab8deb787a3e168b Mon Sep 17 00:00:00 2001 From: Rani Elhusseini Date: Wed, 12 Aug 2026 13:16:14 +0200 Subject: [PATCH 23/23] chore: publish new device instance --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a05b1a..ecbbf9a 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ non-negative `total-supply` equal to their sum. Invalid genesis state fails clos ```bash Published device: token@1.0; -Specification ID: nUrsJwYB4BEOS0ZKKGtRsQf8kxupjWdYrlYcBZNW3U8; +Specification ID: pXHakE4TUBUBg4JFnsCmcvXSis7WMXsUtAP5AzOqmjc; -Implementation ID: dYq-4NC3y3XjpMbUG41ROYcCRHdA8wpaXPJ5_FZeQDk; +Implementation ID: PcMKkrsgVld-4JR63GnRaumlqm1icACGDPUGm86wB1I; Signer: vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0; ```