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..ecbbf9a 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 @@ -14,16 +14,22 @@ rebar3 device package 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 -device publish: token@1.0 +Published device: token@1.0; -spec=ibZf_Tqk4omTejmBIgCI6HCvp5HWYlgKhjkDMZPh4UY +Specification ID: pXHakE4TUBUBg4JFnsCmcvXSis7WMXsUtAP5AzOqmjc; -impl=y8vkutjPnDdfOLP2ISSnj1eOBw9x0jTddgY4dhVpWrQ +Implementation ID: PcMKkrsgVld-4JR63GnRaumlqm1icACGDPUGm86wB1I; -signer=vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0 +Signer: vZY2XY1RD9HIfWi8ift-1_DnHLDadZMWrufSh-_rKF0; ``` ## local node @@ -31,19 +37,21 @@ 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": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" + "security@1.0": "RrkCKxGm72vA9tuDAxKSgDzfvdDhwk1dM0g2ZWmtRKI" } } ``` @@ -74,8 +82,8 @@ 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 -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..8c5da85 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "trusted-devices": { + "mint-authority@1.0": "uzHd158Q7i40TDjwsbGAB88E_idazRRIBctTq_rLzGo", "process-outbox@1.0": "HOcPV7wxMHYb3rSQ3EfykQhHx_b8waRWhXolhcBNgHo", - "security@1.0": "ARgymad5oYZcWPpxuV-A9hoSgmm4ElgPIvxMwmeh674" + "security@1.0": "RrkCKxGm72vA9tuDAxKSgDzfvdDhwk1dM0g2ZWmtRKI" } } diff --git a/rebar.config b/rebar.config index 6066598..1d33ba3 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, "10c0b765cc235d52086a6bd30d2cd9d698d9deca"}}} ]}. {overrides, [ @@ -13,7 +15,7 @@ {plugins, [ {plugin, {git_subdir, "https://github.com/permaweb/HyperBEAM.git", - {ref, "a0bb76ccda9fdbbc5e82008e6794e588c28d2c18"}, + {ref, "10c0b765cc235d52086a6bd30d2cd9d698d9deca"}, "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,_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 fe7979d..72914e7 100644 --- a/src/dev_token.erl +++ b/src/dev_token.erl @@ -1,16 +1,22 @@ %%% @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]). -%%% Public helpers. --export([validate_address/2]). -include_lib("hb/include/hb.hrl"). -implements(<<"token@1.0">>). +-device_libraries([lib_token]). -define(PROCESS_OUTBOX_DEVICE, <<"process-outbox@1.0">>). @@ -48,29 +54,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) -> @@ -95,21 +78,62 @@ 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">> - ] - }. - -%% @doc Canonicalize account keys in the initial balance trie. + #{ 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 Validate initial supply and canonicalize balance-holder ID keys. init(Base, _Req, Opts) -> canonicalize_balances(Base, Opts). @@ -122,44 +146,76 @@ snapshot(Base, _Req, _Opts) -> {ok, Base}. canonicalize_balances(Base, Opts) -> + maybe + Balances0 = initial_balances(Base, 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. + +initial_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 + 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) -> - {Changed, FlatBalances} = - lists:foldl( - fun(Key, {ChangedAcc, BalancesAcc}) -> - Account = 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) - } - 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 + {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 + (_Key, {error, _} = Error) -> + Error; + (Key, {ok, ChangedAcc, BalancesAcc}) -> + maybe + true ?= lib_token:validate_address(Key, [], Opts), + {ok, Amount0} ?= hb_ao:resolve(Balances, Key, Opts), + {ok, Amount} ?= + nonneg_int( + Amount0, + <<"Balance amounts must be non-negative integers.">> + ), + ID = id_key(Key), + { + ok, + ChangedAcc + orelse (ID =/= Key) + orelse maps:is_key(ID, BalancesAcc), + add_balance(ID, 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 @@ -169,27 +225,50 @@ 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 -> + Body = hb_maps:get(<<"body">>, Assignment, #{}, Opts), + tx_field(Body, <<"target">>, <<>>, Opts) =:= current_process_id(Base, Opts); + _ -> + 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) -> @@ -221,28 +300,35 @@ 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; -%% @doc Get the balance for an account. Normalize the minting state for that -%% account before returning. +handle_action(_Action, _Base, _Req, _Opts) -> + {error, <<"Invalid Action format">>}. + +%% @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 ?= validate_address(Account0, [], Opts), - Account = 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 @@ -250,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 = @@ -258,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 @@ -283,19 +369,30 @@ 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), % validate From/Recipient sanity - true ?= validate_address(From0, [], Opts), - true ?= validate_address(Recipient0, [], Opts), - From = account_key(From0), - Recipient = account_key(Recipient0), + true ?= lib_token:validate_address(From0, [], Opts), + true ?= lib_token:validate_address(Recipient0, [], Opts), + From = id_key(From0), + Recipient = id_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#{ + <<"body">> => hb_ao:set(Req, <<"subject">>, From, Opts) + }, + Opts + ) + end, % Retrieve balances from the base state. Balances = hb_ao:get(<<"balances">>, NormBase, Opts), ?event(debug_token, {balances_before_transfer, Balances}, Opts), @@ -316,13 +413,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} = @@ -357,6 +452,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), @@ -384,6 +487,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); @@ -393,12 +502,12 @@ mint(Base, Assignment, Opts) -> as_mint_device(<<"mint">>, Base, Assignment, Opts); {ok, Subject} -> maybe - true ?= validate_address(Subject, [], Opts), + true ?= lib_token:validate_address(Subject, [], Opts), MintReq1 = hb_ao:set( Assignment, <<"subject">>, - account_key(Subject), + id_key(Subject), Opts ), as_mint_device(<<"mint">>, Base, MintReq1, Opts) @@ -406,6 +515,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) -> @@ -525,62 +642,15 @@ 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). +id_key(ID) when is_binary(ID) -> + hb_util:to_lower(hb_ao:normalize_key(ID)). -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. +add_balance(ID, Amount, Balances) -> + Balances#{ ID => maps:get(ID, Balances, 0) + Amount }. outbox_send(Messages, Base, Opts) -> maybe @@ -594,6 +664,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. @@ -604,6 +676,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} -> @@ -714,3 +812,57 @@ 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) -> + Matches = + hb_maps:fold( + fun(_Index, #{ <<"name">> := Name, <<"value">> := Value }, Acc) -> + case hb_util:to_lower(Name) =:= Key of + true -> [Value | Acc]; + false -> Acc + end; + (_Index, _Tag, Acc) -> + Acc + 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 + {ok, Int} when Int >= 0 -> {ok, Int}; + _ -> {error, Error} + end. diff --git a/src/dev_token_lib.erl b/src/dev_token_lib.erl index c264fd7..49631e0 100644 --- a/src/dev_token_lib.erl +++ b/src/dev_token_lib.erl @@ -48,7 +48,9 @@ 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">>, <<"Subscribe">>, <<"Unsubscribe">>] }, ModExtra ), @@ -210,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) -> @@ -488,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) -> - hb_util:to_lower(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, @@ -505,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 new file mode 100644 index 0000000..71f3436 --- /dev/null +++ b/src/preloaded/test/hb_token_process_scheduler_test_vectors.erl @@ -0,0 +1,903 @@ +%%% @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, ID, Opts) -> + dev_token_lib:balance(Process, ID, 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)). + +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">>, + <<"mint-nonce">> => 0, + <<"mode">> => <<"single">>, + <<"recipient">> => #{}, + <<"quantity">> => 1 + }, + MintAuthorityWallet + }, + { + #{ + <<"action">> => <<"Mint">>, + <<"mint-nonce">> => 0, + <<"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}. + +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">>, + <<"mint-nonce">> => 0, + <<"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">>, + <<"mint-nonce">> => 0, + <<"mode">> => <<"single">>, + <<"recipient">> => Recipient, + <<"quantity">> => 7 + }, + MintAuthorityWallet, + MintAuthorityWallet, + Opts + ), + ?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">>, + <<"mint-nonce">> => 0, + <<"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)). + +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">>, + <<"mint-nonce">> => 0, + <<"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}. + +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(), + Process0 = + dev_token_lib:ledger( + #{ + <<"execution-device">> => <<"token@1.0">>, + <<"security-device">> => <<"security@1.0">>, + <<"authority">> => [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 }, + <<"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, Subscribed} = + schedule_and_compute( + Transferred, + #{ + <<"action">> => <<"Subscribe">>, + <<"from-process">> => Dex, + <<"subscribe-action">> => <<"register">>, + <<"timestamp">> => 1 + }, + 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, 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, + <<"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)). + +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}. + +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)). diff --git a/src/preloaded/test/hb_token_props.erl b/src/preloaded/test/hb_token_props.erl index da47e3f..06545f5 100644 --- a/src/preloaded/test/hb_token_props.erl +++ b/src/preloaded/test/hb_token_props.erl @@ -10,24 +10,53 @@ -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() -> + ensure_lib_token(), 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()). +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}. @@ -170,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). @@ -363,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 -> @@ -383,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 e4c25eb..15aad60 100644 --- a/src/preloaded/test/hb_token_test_vectors.erl +++ b/src/preloaded/test/hb_token_test_vectors.erl @@ -6,30 +6,66 @@ -define(PROCESS_OUTBOX_DEVICE, <<"process-outbox@1.0">>). opts() -> + ensure_lib_token(), 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)>>, - <>; + hb_util:human_id(<>); id(Other) -> hb_util:human_id(Other). -account_key(Account) -> - hb_util:to_lower(Account). +id_key(ID) -> + hb_util:to_lower(hb_ao:normalize_key(ID)). + +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( - 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, #{}, @@ -66,15 +102,29 @@ token_state(Params, Opts) -> ), hb_message:commit(Base, Opts). -balance(State, Account, 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, 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( @@ -132,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) -> @@ -160,13 +219,26 @@ 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). + +mint(State, From, Recipient, Quantity, MintNonce, Opts) -> dev_token:handle_action( <<"mint">>, State, #{ <<"body">> => #{ + <<"action">> => <<"Mint">>, + <<"mint-nonce">> => MintNonce, <<"from">> => From, <<"recipient">> => Recipient, <<"quantity">> => Quantity @@ -175,6 +247,29 @@ mint(State, From, Recipient, Quantity, Opts) -> 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, + #{ + <<"body">> => + #{ + <<"action">> => <<"Mint">>, + <<"mint-nonce">> => MintNonce, + <<"from">> => From, + <<"mode">> => <<"batch">>, + <<"quantities">> => Quantities + } + }, + 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">>, @@ -196,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 = @@ -206,7 +301,168 @@ balance_existing_account_test() -> ), ?assertEqual({ok, 7}, public_balance(Base, Alice, Opts)). -mixed_case_initial_balance_uses_canonical_account_test() -> +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 + ) + ). + +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)). + +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_id_test() -> Opts = opts(), Alice = id(<<"Alice">>), Base = @@ -216,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(), @@ -226,17 +482,13 @@ init_canonicalizes_raw_initial_balances_test() -> {ok, RawBalances} = hb_ao:resolve( #{ <<"device">> => <<"trie@1.0">> }, - #{ Alice => 7, <<"path">> => <<"set">> }, + #{ Alice => 7, id_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 @@ -244,10 +496,93 @@ 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, 10}, hb_ao:resolve(Balances, id_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 => <<"three">> }, + 10, + <<"Balance amounts must be non-negative integers.">> + }, + { + #{ MixedAlice => 7, <<" invalid">> => 3 }, + 10, + <<"Address contains unsupported characters.">> + } + ], + 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, <<"seven">>] + ), + Mismatched = + raw_token_state( + #{ <<"balances">> => Balances, <<"total-supply">> => 6 }, + Opts + ), + ?assertEqual( + {error, <<"Total supply does not match balances.">>}, + 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() -> +balance_missing_id_returns_zero_test() -> Opts = opts(), Alice = id(<<"alice">>), Bob = id(<<"bob">>), @@ -258,19 +593,19 @@ 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( - {error, <<"Address is a reserved ao/custom key">>}, + {error, <<"Address uses the reserved path key.">>}, public_balance(Base, <<"path">>, Opts) ). -uppercase_reserved_account_rejected_test() -> +uppercase_reserved_id_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( @@ -303,6 +638,145 @@ 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, id_key(Sender), Opts) + ), + ?assertEqual( + {error, not_found}, + hb_ao:resolve(UpdatedBalances, id_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">>), + 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)). + +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">>), @@ -320,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 @@ -456,6 +930,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">>), @@ -494,6 +1007,425 @@ 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(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">>), + 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">>), + 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">>), + 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">>),