diff --git a/include/blockchain_vars.hrl b/include/blockchain_vars.hrl index 3911cc7418..f3d1b843ab 100644 --- a/include/blockchain_vars.hrl +++ b/include/blockchain_vars.hrl @@ -362,3 +362,9 @@ %% Multi-key -define(use_multi_keys, use_multi_keys). + +%% transfer hotspot +%% This is the number of blocks after which a hotspot which hasn't had a +%% POC challenge for X blocks would be considered stale for the purposes +%% of a hotspot transfer. (We do not allow stale hotspots to be transferred.) +-define(transfer_hotspot_stale_poc_blocks, transfer_hotspot_stale_poc_blocks). diff --git a/rebar.lock b/rebar.lock index 08ed08f303..831e509eaa 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ 0}, {<<"exor_filter">>, {git,"https://github.com/mpope9/exor_filter", - {ref,"7b7493b08a2feb7d8e3685f1fd6ffa36db0df8fa"}}, + {ref,"b44086d6ad71deb34b935cbff2a3c376a7900652"}}, 0}, {<<"getopt">>,{pkg,<<"getopt">>,<<"1.0.1">>},2}, {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, @@ -41,7 +41,7 @@ 0}, {<<"helium_proto">>, {git,"https://github.com/helium/proto.git", - {ref,"37dd23b7d59f5ae110ffd9cac7332304301ab811"}}, + {ref,"09f4ae5764e940d490ac4e3f096be9c348ca4219"}}, 0}, {<<"inert">>, {git,"https://github.com/msantos/inert", @@ -59,7 +59,7 @@ 0}, {<<"libp2p">>, {git,"https://github.com/helium/erlang-libp2p.git", - {ref,"a7ab7bfc8eeb8e9845e6ce5859e4c1db8fa80faf"}}, + {ref,"edf8e052b99a13656c4d847cb30adb8bf21003df"}}, 0}, {<<"libp2p_crypto">>,{pkg,<<"libp2p_crypto">>,<<"1.1.0">>},1}, {<<"merkerl">>, diff --git a/src/blockchain_utils.erl b/src/blockchain_utils.erl index 8cdda39b5d..2038ebc4b8 100644 --- a/src/blockchain_utils.erl +++ b/src/blockchain_utils.erl @@ -34,6 +34,7 @@ calculate_dc_amount/2, calculate_dc_amount/3, do_calculate_dc_amount/2, deterministic_subset/3, + fold_condition_checks/1, %% exports for simulations free_space_path_loss/4, @@ -470,6 +471,22 @@ poc_per_hop_max_witnesses(Ledger) -> ?POC_PER_HOP_MAX_WITNESSES end. +%%-------------------------------------------------------------------- +%% @doc Given a list of tuples of zero arity functions that return a +%% boolean and error tuples, evaluate each function. If a function +%% returns `false' then immediately return the associated error tuple. +%% Otherwise, if all conditions evaluate as `true', return `ok'. +%% @end +%%-------------------------------------------------------------------- +-spec fold_condition_checks([{Condition :: fun(() -> boolean()), + Error :: {error, any()}}]) -> ok | {error, any()}. +fold_condition_checks(Conditions) -> + do_condition_check(Conditions, undefined, true). + +do_condition_check(_Conditions, PrevErr, false) -> PrevErr; +do_condition_check([], _PrevErr, true) -> ok; +do_condition_check([{Condition, Error}|Tail], _PrevErr, true) -> + do_condition_check(Tail, Error, Condition()). majority(N) -> (N div 2) + 1. @@ -588,4 +605,16 @@ count_votes_test() -> ?assertEqual(5, count_votes(Artifact, PKeys ++ [libp2p_crypto:pubkey_to_bin(PubKey5)], Sigs ++ [ExtraSig])), ok. +fold_condition_checks_good_test() -> + Conditions = [{fun() -> true end, {error, true_isnt_true}}, + {fun() -> 100 > 10 end, {error, one_hundred_greater_than_10}}, + {fun() -> <<"blort">> == <<"blort">> end, {error, blort_isnt_blort}}], + ?assertEqual(ok, fold_condition_checks(Conditions)). + +fold_condition_checks_bad_test() -> + Bad = [{fun() -> true end, {error, true_isnt_true}}, + {fun() -> 10 > 100 end, {error, '10_not_greater_than_100'}}, + {fun() -> <<"blort">> == <<"blort">> end, {error, blort_isnt_blort}}], + ?assertEqual({error, '10_not_greater_than_100'}, fold_condition_checks(Bad)). + -endif. diff --git a/src/transactions/blockchain_txn.erl b/src/transactions/blockchain_txn.erl index cddfcf2fc0..a2e7421a2d 100644 --- a/src/transactions/blockchain_txn.erl +++ b/src/transactions/blockchain_txn.erl @@ -36,6 +36,7 @@ | blockchain_txn_update_gateway_oui_v1:txn_update_gateway_oui() | blockchain_txn_price_oracle_v1:txn_price_oracle() | blockchain_txn_gen_price_oracle_v1:txn_genesis_price_oracle() + | blockchain_txn_transfer_hotspot_v1:txn_transfer_hotspot() | blockchain_txn_state_channel_close_v1:txn_state_channel_close(). -type before_commit_callback() :: fun((blockchain:blockchain(), blockchain_block:hash()) -> ok | {error, any()}). @@ -108,7 +109,8 @@ {blockchain_txn_payment_v2, 19}, {blockchain_txn_state_channel_open_v1, 20}, {blockchain_txn_update_gateway_oui_v1, 21}, - {blockchain_txn_state_channel_close_v1, 22} + {blockchain_txn_state_channel_close_v1, 22}, + {blockchain_txn_transfer_hotspot_v1, 23} ]). block_delay() -> @@ -185,7 +187,9 @@ wrap_txn(#blockchain_txn_state_channel_close_v1_pb{}=Txn) -> wrap_txn(#blockchain_txn_price_oracle_v1_pb{}=Txn) -> #blockchain_txn_pb{txn={price_oracle_submission, Txn}}; wrap_txn(#blockchain_txn_gen_price_oracle_v1_pb{}=Txn) -> - #blockchain_txn_pb{txn={gen_price_oracle, Txn}}. + #blockchain_txn_pb{txn={gen_price_oracle, Txn}}; +wrap_txn(#blockchain_txn_transfer_hotspot_v1_pb{}=Txn) -> + #blockchain_txn_pb{txn={transfer_hotspot, Txn}}. -spec unwrap_txn(#blockchain_txn_pb{}) -> blockchain_txn:txn(). unwrap_txn(#blockchain_txn_pb{txn={bundle, #blockchain_txn_bundle_v1_pb{transactions=Txns} = Bundle}}) -> @@ -564,7 +568,9 @@ type(#blockchain_txn_state_channel_close_v1_pb{}) -> type(#blockchain_txn_price_oracle_v1_pb{}) -> blockchain_txn_price_oracle_v1; type(#blockchain_txn_gen_price_oracle_v1_pb{}) -> - blockchain_txn_gen_price_oracle_v1. + blockchain_txn_gen_price_oracle_v1; +type(#blockchain_txn_transfer_hotspot_v1_pb{}) -> + blockchain_txn_transfer_hotspot_v1. -spec validate_fields([{{atom(), iodata() | undefined}, diff --git a/src/transactions/v1/blockchain_txn_transfer_hotspot_v1.erl b/src/transactions/v1/blockchain_txn_transfer_hotspot_v1.erl new file mode 100644 index 0000000000..e5b6576058 --- /dev/null +++ b/src/transactions/v1/blockchain_txn_transfer_hotspot_v1.erl @@ -0,0 +1,367 @@ +-module(blockchain_txn_transfer_hotspot_v1). +-behavior(blockchain_txn). +-behavior(blockchain_json). + +-include("blockchain_json.hrl"). +-include("blockchain_utils.hrl"). +-include("blockchain_txn_fees.hrl"). +-include("blockchain_vars.hrl"). +-include_lib("helium_proto/include/blockchain_txn_transfer_hotspot_v1_pb.hrl"). + +-define(STALE_POC_DEFAULT, 0). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + +-export([ + new/4, new/5, + gateway/1, + seller/1, + buyer/1, + seller_signature/1, + buyer_signature/1, + amount_to_seller/1, + buyer_nonce/1, + fee/2, fee/1, + calculate_fee/2, calculate_fee/5, + hash/1, + sign/2, + sign_seller/2, + sign_buyer/2, + is_valid/2, + is_valid_seller/1, + is_valid_buyer/1, + absorb/2, + print/1, + to_json/2 +]). + +-type txn_transfer_hotspot() :: #blockchain_txn_transfer_hotspot_v1_pb{}. +-export_type([txn_transfer_hotspot/0]). + +-spec new(Gateway :: libp2p_crypto:pubkey_bin(), + Seller :: libp2p_crypto:pubkey_bin(), + Buyer :: libp2p_crypto:pubkey_bin(), + BuyerNonce :: non_neg_integer() + ) -> txn_transfer_hotspot(). +new(Gateway, Seller, Buyer, BuyerNonce) -> + new(Gateway, Seller, Buyer, BuyerNonce, 0). + +%% @doc AmountToSeller should be given in Bones, not raw HNT +-spec new(Gateway :: libp2p_crypto:pubkey_bin(), + Seller :: libp2p_crypto:pubkey_bin(), + Buyer :: libp2p_crypto:pubkey_bin(), + BuyerNonce :: non_neg_integer(), + AmountToSeller :: non_neg_integer()) -> txn_transfer_hotspot(). +new(Gateway, Seller, Buyer, BuyerNonce, AmountToSeller) when is_integer(AmountToSeller) + andalso AmountToSeller >= 0 -> + #blockchain_txn_transfer_hotspot_v1_pb{ + gateway=Gateway, + seller=Seller, + buyer=Buyer, + seller_signature= <<>>, + buyer_signature= <<>>, + buyer_nonce=BuyerNonce, + amount_to_seller=AmountToSeller, + fee=0 + }. + +-spec hash(txn_transfer_hotspot()) -> blockchain_txn:hash(). +hash(Txn) -> + BaseTxn = Txn#blockchain_txn_transfer_hotspot_v1_pb{seller_signature= <<>>, + buyer_signature= <<>>}, + EncodedTxn = blockchain_txn_transfer_hotspot_v1_pb:encode_msg(BaseTxn), + crypto:hash(sha256, EncodedTxn). + +-spec gateway(txn_transfer_hotspot()) -> libp2p_crypto:pubkey_bin(). +gateway(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.gateway. + +-spec seller(txn_transfer_hotspot()) -> libp2p_crypto:pubkey_bin(). +seller(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.seller. + +-spec buyer(txn_transfer_hotspot()) -> libp2p_crypto:pubkey_bin(). +buyer(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.buyer. + +-spec seller_signature(txn_transfer_hotspot()) -> binary(). +seller_signature(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.seller_signature. + +-spec buyer_signature(txn_transfer_hotspot()) -> binary(). +buyer_signature(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.buyer_signature. + +-spec buyer_nonce(txn_transfer_hotspot()) -> non_neg_integer(). +buyer_nonce(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.buyer_nonce. + +-spec amount_to_seller(txn_transfer_hotspot()) -> non_neg_integer(). +amount_to_seller(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.amount_to_seller. + +-spec fee(txn_transfer_hotspot()) -> non_neg_integer(). +fee(Txn) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb.fee. + +-spec fee(txn_transfer_hotspot(), non_neg_integer()) -> txn_transfer_hotspot(). +fee(Txn, Fee) -> + Txn#blockchain_txn_transfer_hotspot_v1_pb{fee=Fee}. + +-spec calculate_fee(txn_transfer_hotspot(), blockchain:blockchain()) -> non_neg_integer(). +calculate_fee(Txn, Chain) -> + ?calculate_fee_prep(Txn, Chain). + +-spec calculate_fee(txn_transfer_hotspot(), + blockchain_ledger_v1:ledger(), + pos_integer(), pos_integer(), boolean()) -> non_neg_integer(). +calculate_fee(_Txn, _Ledger, _DCPayloadSize, _TxnFeeMultiplier, false) -> + ?LEGACY_TXN_FEE; +calculate_fee(Txn, Ledger, DCPayloadSize, TxnFeeMultiplier, true) -> + ?calculate_fee(Txn#blockchain_txn_transfer_hotspot_v1_pb{fee=0, + buyer_signature= <<0:512>>, + seller_signature= <<0:512>>}, + Ledger, DCPayloadSize, TxnFeeMultiplier). + +-spec sign(Txn :: txn_transfer_hotspot(), + SigFun :: libp2p_crypto:sig_fun()) -> txn_transfer_hotspot(). +sign(Txn, SigFun) -> + BaseTxn = Txn#blockchain_txn_transfer_hotspot_v1_pb{buyer_signature= <<>>, + seller_signature= <<>>}, + BinTxn = blockchain_txn_transfer_hotspot_v1_pb:encode_msg(BaseTxn), + Txn#blockchain_txn_transfer_hotspot_v1_pb{seller_signature=SigFun(BinTxn)}. + +-spec sign_seller(Txn :: txn_transfer_hotspot(), + SigFun :: libp2p_crypto:sig_fun()) -> txn_transfer_hotspot(). +sign_seller(Txn, SigFun) -> sign(Txn, SigFun). + +-spec sign_buyer(Txn :: txn_transfer_hotspot(), + SigFun :: libp2p_crypto:sig_fun()) -> txn_transfer_hotspot(). +sign_buyer(Txn, SigFun) -> + BaseTxn = Txn#blockchain_txn_transfer_hotspot_v1_pb{buyer_signature= <<>>, + seller_signature= <<>>}, + BinTxn = blockchain_txn_transfer_hotspot_v1_pb:encode_msg(BaseTxn), + Txn#blockchain_txn_transfer_hotspot_v1_pb{buyer_signature=SigFun(BinTxn)}. + +-spec is_valid_seller(txn_transfer_hotspot()) -> boolean(). +is_valid_seller(#blockchain_txn_transfer_hotspot_v1_pb{seller=Seller, + seller_signature=SellerSig} = Txn) -> + BaseTxn = Txn#blockchain_txn_transfer_hotspot_v1_pb{buyer_signature= <<>>, + seller_signature= <<>>}, + EncodedTxn = blockchain_txn_transfer_hotspot_v1_pb:encode_msg(BaseTxn), + Pubkey = libp2p_crypto:bin_to_pubkey(Seller), + libp2p_crypto:verify(EncodedTxn, SellerSig, Pubkey). + +-spec is_valid_buyer(txn_transfer_hotspot()) -> boolean(). +is_valid_buyer(#blockchain_txn_transfer_hotspot_v1_pb{buyer=Buyer, + buyer_signature=BuyerSig} = Txn) -> + BaseTxn = Txn#blockchain_txn_transfer_hotspot_v1_pb{buyer_signature= <<>>, + seller_signature= <<>>}, + EncodedTxn = blockchain_txn_transfer_hotspot_v1_pb:encode_msg(BaseTxn), + Pubkey = libp2p_crypto:bin_to_pubkey(Buyer), + libp2p_crypto:verify(EncodedTxn, BuyerSig, Pubkey). + +-spec is_valid(txn_transfer_hotspot(), blockchain:blockchain()) -> ok | {error, any()}. +is_valid(#blockchain_txn_transfer_hotspot_v1_pb{seller=Seller, + buyer=Buyer, + amount_to_seller=Bones}=Txn, + Chain) -> + Ledger = blockchain:ledger(Chain), + AreFeesEnabled = blockchain_ledger_v1:txn_fees_active(Ledger), + Conditions = [{fun() -> Seller /= Buyer end, {error, seller_is_buyer}}, + {fun() -> ?MODULE:is_valid_seller(Txn) end, + {error, bad_seller_signature}}, + {fun() -> ?MODULE:is_valid_buyer(Txn) end, + {error, bad_buyer_signature}}, + {fun() -> is_integer(Bones) andalso Bones >= 0 end, + {error, invalid_hnt_to_seller}}, + {fun() -> gateway_not_stale(Txn, Ledger) end, + {error, gateway_too_stale}}, + {fun() -> seller_owns_gateway(Txn, Ledger) end, + {error, gateway_not_owned_by_seller}}, + {fun() -> buyer_nonce_correct(Txn, Ledger) end, + {error, wrong_buyer_nonce}}, + {fun() -> txn_fee_valid(Txn, Chain, AreFeesEnabled) end, + {error, wrong_txn_fee}}, + {fun() -> buyer_has_enough_hnt(Txn, Ledger) end, + {error, buyer_insufficient_hnt_balance}}], + blockchain_utils:fold_condition_checks(Conditions). + +-spec absorb(txn_transfer_hotspot(), blockchain:blockchain()) -> ok | {error, any()}. +absorb(Txn, Chain) -> + Ledger = blockchain:ledger(Chain), + AreFeesEnabled = blockchain_ledger_v1:txn_fees_active(Ledger), + Gateway = ?MODULE:gateway(Txn), + Seller = ?MODULE:seller(Txn), + Buyer = ?MODULE:buyer(Txn), + Fee = ?MODULE:fee(Txn), + BuyerNonce = ?MODULE:buyer_nonce(Txn), + HNTToSeller = ?MODULE:amount_to_seller(Txn), + + {ok, GWInfo} = blockchain_gateway_cache:get(Gateway, Ledger), + %% fees here are in DC (and perhaps converted to HNT automagically) + case blockchain_ledger_v1:debit_fee(Buyer, Fee, Ledger, AreFeesEnabled) of + {error, _Reason} = Error -> Error; + ok -> + ok = blockchain_ledger_v1:debit_account(Buyer, HNTToSeller, BuyerNonce, Ledger), + ok = blockchain_ledger_v1:credit_account(Seller, HNTToSeller, Ledger), + NewGWInfo = blockchain_ledger_gateway_v2:owner_address(Buyer, GWInfo), + ok = blockchain_ledger_v1:update_gateway(NewGWInfo, Gateway, Ledger) + end. + +-spec print(txn_transfer_hotspot()) -> iodata(). +print(undefined) -> <<"type=transfer_hotspot, undefined">>; +print(#blockchain_txn_transfer_hotspot_v1_pb{ + gateway=GW, seller=Seller, buyer=Buyer, + seller_signature=SS, buyer_signature=BS, + buyer_nonce=Nonce, fee=Fee, amount_to_seller=HNT}) -> + io_lib:format("type=transfer_hotspot, gateway=~p, seller=~p, buyer=~p, seller_signature=~p, buyer_signature=~p, buyer_nonce=~p, fee=~p (dc), amount_to_seller=~p", + [?TO_ANIMAL_NAME(GW), ?TO_B58(Seller), ?TO_B58(Buyer), SS, BS, Nonce, Fee, HNT]). + +-spec to_json(txn_transfer_hotspot(), blockchain_json:options()) -> blockchain_json:json_object(). +to_json(Txn, _Opts) -> + #{ + type => <<"transfer_hotspot_v1">>, + hash => ?BIN_TO_B64(hash(Txn)), + gateway => ?BIN_TO_B58(gateway(Txn)), + seller => ?BIN_TO_B58(seller(Txn)), + buyer => ?BIN_TO_B58(buyer(Txn)), + fee => fee(Txn), + buyer_nonce => buyer_nonce(Txn), + amount_to_seller => amount_to_seller(Txn) + }. + +%% private functions +-spec seller_owns_gateway(txn_transfer_hotspot(), blockchain_ledger_v1:ledger()) -> boolean(). +seller_owns_gateway(#blockchain_txn_transfer_hotspot_v1_pb{gateway=GW, + seller=Seller}, Ledger) -> + case blockchain_gateway_cache:get(GW, Ledger) of + {error, _} -> false; + {ok, GwInfo} -> + GwOwner = blockchain_ledger_gateway_v2:owner_address(GwInfo), + Seller == GwOwner + end. + +-spec gateway_not_stale(txn_transfer_hotspot(), blockchain_ledger_v1:ledger()) -> boolean(). +gateway_not_stale(#blockchain_txn_transfer_hotspot_v1_pb{gateway=GW}, Ledger) -> + StaleInterval = get_config_or_default(?transfer_hotspot_stale_poc_blocks, Ledger), + case blockchain_gateway_cache:get(GW, Ledger) of + {error, _} -> false; + {ok, GwInfo} -> + {ok, Height} = blockchain_ledger_v1:current_height(Ledger), + LastPOC = case blockchain_ledger_gateway_v2:last_poc_challenge(GwInfo) of + undefined -> 0; + X when is_integer(X) -> X + end, + Interval = Height - LastPOC, + Interval >= 0 andalso Interval =< StaleInterval + end. + +-spec buyer_has_enough_hnt(txn_transfer_hotspot(), blockchain_ledger_v1:ledger()) -> boolean(). +buyer_has_enough_hnt(#blockchain_txn_transfer_hotspot_v1_pb{fee=Fee, + amount_to_seller=SellerHNT, + buyer=Buyer}, + Ledger) -> + {ok, FeeInHNT} = blockchain_ledger_v1:dc_to_hnt(Fee, Ledger), + TotalHNT = FeeInHNT + SellerHNT, + case blockchain_ledger_v1:check_balance(Buyer, TotalHNT, Ledger) of + {error, _Reason} -> false; + ok -> true + end. + +-spec txn_fee_valid(txn_transfer_hotspot(), blockchain:blockchain(), boolean()) -> boolean(). +txn_fee_valid(#blockchain_txn_transfer_hotspot_v1_pb{fee=Fee}=Txn, Chain, AreFeesEnabled) -> + ExpectedTxnFee = calculate_fee(Txn, Chain), + ExpectedTxnFee =< Fee orelse not AreFeesEnabled. + +-spec buyer_nonce_correct(txn_transfer_hotspot(), blockchain_ledger_v1:ledger()) -> boolean(). +buyer_nonce_correct(#blockchain_txn_transfer_hotspot_v1_pb{buyer_nonce=Nonce, + buyer=Buyer}, Ledger) -> + case blockchain_ledger_v1:find_entry(Buyer, Ledger) of + {error, _} -> false; + {ok, Entry} -> + Nonce =:= blockchain_ledger_entry_v1:nonce(Entry) + 1 + end. + +get_config_or_default(?transfer_hotspot_stale_poc_blocks=Config, Ledger) -> + case blockchain_ledger_v1:config(Config, Ledger) of + {error, not_found} -> ?STALE_POC_DEFAULT; + {ok, Value} -> Value; + Other -> Other + end. + +-ifdef(TEST). +new_4_test() -> + Tx = #blockchain_txn_transfer_hotspot_v1_pb{gateway= <<"gateway">>, + seller= <<"seller">>, + seller_signature = <<>>, + buyer= <<"buyer">>, + buyer_signature = <<>>, + buyer_nonce=1, + amount_to_seller=0, + fee=0}, + ?assertEqual(Tx, new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1)). + +new_5_test() -> + Tx = #blockchain_txn_transfer_hotspot_v1_pb{gateway= <<"gateway">>, + seller= <<"seller">>, + seller_signature = <<>>, + buyer= <<"buyer">>, + buyer_signature = <<>>, + buyer_nonce=1, + amount_to_seller=100, + fee=0}, + ?assertEqual(Tx, new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1, 100)). + +gateway_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1), + ?assertEqual(<<"gateway">>, gateway(Tx)). + +seller_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1), + ?assertEqual(<<"seller">>, seller(Tx)). + +seller_signature_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1), + ?assertEqual(<<>>, seller_signature(Tx)). + +buyer_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1), + ?assertEqual(<<"buyer">>, buyer(Tx)). + +buyer_signature_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1), + ?assertEqual(<<>>, buyer_signature(Tx)). + +amount_to_seller_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1, 100), + ?assertEqual(100, amount_to_seller(Tx)). + +fee_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1, 100), + ?assertEqual(20, fee(fee(Tx, 20))). + +sign_seller_test() -> + #{public := PubKey, secret := PrivKey} = libp2p_crypto:generate_keys(ecc_compact), + Tx = new(<<"gateway">>, libp2p_crypto:pubkey_to_bin(PubKey), <<"buyer">>, 1), + SigFun = libp2p_crypto:mk_sig_fun(PrivKey), + Tx0 = sign(Tx, SigFun), + ?assert(is_valid_seller(Tx0)). + +sign_buyer_test() -> + #{public := PubKey, secret := PrivKey} = libp2p_crypto:generate_keys(ecc_compact), + Tx = new(<<"gateway">>, <<"seller">>, libp2p_crypto:pubkey_to_bin(PubKey), 1), + SigFun = libp2p_crypto:mk_sig_fun(PrivKey), + Tx0 = sign_buyer(Tx, SigFun), + ?assert(is_valid_buyer(Tx0)). + +to_json_test() -> + Tx = new(<<"gateway">>, <<"seller">>, <<"buyer">>, 1, 100), + Json = to_json(Tx, []), + ?assert(lists:all(fun(K) -> maps:is_key(K, Json) end, + [type, hash, gateway, seller, buyer, buyer_nonce, amount_to_seller, fee])). + + +-endif. diff --git a/src/transactions/v1/blockchain_txn_vars_v1.erl b/src/transactions/v1/blockchain_txn_vars_v1.erl index 702635dc25..8980559059 100644 --- a/src/transactions/v1/blockchain_txn_vars_v1.erl +++ b/src/transactions/v1/blockchain_txn_vars_v1.erl @@ -1063,6 +1063,9 @@ validate_var(?use_multi_keys, Value) -> _ -> throw({error, {invalid_multi_keys, Value}}) end; +validate_var(?transfer_hotspot_stale_poc_blocks, Value) -> + validate_int(Value, "transfer_hotspot_stale_poc_blocks", 1, 50000, false); + validate_var(Var, Value) -> %% something we don't understand, crash invalid_var(Var, Value). diff --git a/test/blockchain_hotspot_transfer_SUITE.erl b/test/blockchain_hotspot_transfer_SUITE.erl new file mode 100644 index 0000000000..c641a94c23 --- /dev/null +++ b/test/blockchain_hotspot_transfer_SUITE.erl @@ -0,0 +1,374 @@ +-module(blockchain_hotspot_transfer_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include("blockchain_vars.hrl"). + +-export([all/0, init_per_testcase/2, end_per_testcase/2]). + +-export([ + basic_validity_test/1, + bad_seller_signature_test/1, + bad_buyer_signature_test/1, + buyer_has_enough_hnt_test/1, + gateway_not_owned_by_seller_test/1, + gateway_stale_test/1, + replay_test/1 + ]). + +all() -> + [ + basic_validity_test, + bad_seller_signature_test, + bad_buyer_signature_test, + buyer_has_enough_hnt_test, + gateway_not_owned_by_seller_test, + gateway_stale_test, + replay_test + ]. + +%%-------------------------------------------------------------------- +%% TEST CASE SETUP +%%-------------------------------------------------------------------- + +init_per_testcase(TestCase, Config) -> + Config0 = blockchain_ct_utils:init_base_dir_config(?MODULE, TestCase, Config), + Balance = 5000, + {ok, Sup, {PrivKey, PubKey}, Opts} = test_utils:init(?config(base_dir, Config0)), + + ExtraVars = case TestCase of + gateway_stale_test -> #{ }; %% default is 0 + _ -> #{ ?transfer_hotspot_stale_poc_blocks => 10 } + end, + + {ok, GenesisMembers, _GenesisBlock, ConsensusMembers, Keys} = + test_utils:init_chain(Balance, {PrivKey, PubKey}, true, ExtraVars), + + Chain = blockchain_worker:blockchain(), + Swarm = blockchain_swarm:swarm(), + N = length(ConsensusMembers), + + % Check ledger to make sure everyone has the right balance + Ledger = blockchain:ledger(Chain), + Entries = blockchain_ledger_v1:entries(Ledger), + _ = lists:foreach(fun(Entry) -> + Balance = blockchain_ledger_entry_v1:balance(Entry), + 0 = blockchain_ledger_entry_v1:nonce(Entry) + end, maps:values(Entries)), + + [ + {balance, Balance}, + {sup, Sup}, + {pubkey, PubKey}, + {privkey, PrivKey}, + {opts, Opts}, + {chain, Chain}, + {swarm, Swarm}, + {n, N}, + {consensus_members, ConsensusMembers}, + {genesis_members, GenesisMembers}, + Keys + | Config0 + ]. + +%%-------------------------------------------------------------------- +%% TEST CASE TEARDOWN +%%-------------------------------------------------------------------- + +end_per_testcase(_, Config) -> + Sup = ?config(sup, Config), + % Make sure blockchain saved on file = in memory + case erlang:is_process_alive(Sup) of + true -> + true = erlang:exit(Sup, normal), + ok = test_utils:wait_until(fun() -> false =:= erlang:is_process_alive(Sup) end); + false -> + ok + end, + ok = test_utils:cleanup_tmp_dir(?config(base_dir, Config)), + ok. + +%%-------------------------------------------------------------------- +%% TEST CASES +%%-------------------------------------------------------------------- + +basic_validity_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, SellerPubkeyBin, BuyerPubkeyBin, 1), + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + ct:pal("IsValidSeller: ~p", [blockchain_txn_transfer_hotspot_v1:is_valid_seller(BuyerSignedTxn)]), + + ok = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +bad_seller_signature_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + _SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, SellerPubkeyBin, BuyerPubkeyBin, 1), + + %% this is not seller's signature + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, BuyerSigFun), + + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + ct:pal("IsValidSeller: ~p", [blockchain_txn_transfer_hotspot_v1:is_valid_seller(BuyerSignedTxn)]), + + {error, bad_seller_signature} = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +bad_buyer_signature_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + _BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, SellerPubkeyBin, BuyerPubkeyBin, 1), + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + + %% this is not buyer's signature + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, SellerSigFun), + + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + ct:pal("IsValidSeller: ~p", [blockchain_txn_transfer_hotspot_v1:is_valid_seller(BuyerSignedTxn)]), + + {error, bad_buyer_signature} = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +buyer_has_enough_hnt_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, + SellerPubkeyBin, + BuyerPubkeyBin, + 1, + 10000), %% buyer only has 5K hnt to boot + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + ct:pal("IsValidSeller: ~p", [blockchain_txn_transfer_hotspot_v1:is_valid_seller(BuyerSignedTxn)]), + + {error, buyer_insufficient_hnt_balance} = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +gateway_not_owned_by_seller_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(BuyerPubkeyBin, %% this is not the seller's gw + SellerPubkeyBin, + BuyerPubkeyBin, + 1), + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + ct:pal("IsValidSeller: ~p", [blockchain_txn_transfer_hotspot_v1:is_valid_seller(BuyerSignedTxn)]), + + {error, gateway_not_owned_by_seller} = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +gateway_stale_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, + SellerPubkeyBin, + BuyerPubkeyBin, + 1), + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + + {error, gateway_too_stale} = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + ok. + +replay_test(Config) -> + GenesisMembers = ?config(genesis_members, Config), + ConsensusMembers = ?config(consensus_members, Config), + Chain = ?config(chain, Config), + Ledger = blockchain:ledger(Chain), + + %% In the test, SellerPubkeyBin = GatewayPubkeyBin + + %% Get some owner and their gateway + [{SellerPubkeyBin, Gateway}, + {BuyerPubkeyBin, _} | _] = maps:to_list(blockchain_ledger_v1:active_gateways(Ledger)), + + %% Get seller privkey and sigfun + {_SellerPubkey, SellerPrivKey, _} = proplists:get_value(SellerPubkeyBin, GenesisMembers), + SellerSigFun = libp2p_crypto:mk_sig_fun(SellerPrivKey), + + %% Get buyer privkey and sigfun + {_BuyerPubkey, BuyerPrivKey, _} = proplists:get_value(BuyerPubkeyBin, GenesisMembers), + BuyerSigFun = libp2p_crypto:mk_sig_fun(BuyerPrivKey), + + ct:pal("Seller: ~p", [SellerPubkeyBin]), + ct:pal("Gateway: ~p", [Gateway]), + ct:pal("BuyerPubkeyBin: ~p", [BuyerPubkeyBin]), + + %% This is valid, nonce=1 + Txn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, + SellerPubkeyBin, + BuyerPubkeyBin, + 1), + SellerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(Txn, SellerSigFun), + BuyerSignedTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedTxn, BuyerSigFun), + ct:pal("SignedTxn: ~p", [BuyerSignedTxn]), + + ok = blockchain_txn:is_valid(BuyerSignedTxn, Chain), + + %% Put the valid txn in a block and gossip + {ok, Block2} = test_utils:create_block(ConsensusMembers, [BuyerSignedTxn]), + _ = blockchain_gossip_handler:add_block(Block2, Chain, self(), blockchain_swarm:swarm()), + ?assertEqual({ok, blockchain_block:hash_block(Block2)}, blockchain:head_hash(Chain)), + ?assertEqual({ok, Block2}, blockchain:head_block(Chain)), + ?assertEqual({ok, 2}, blockchain:height(Chain)), + + %% Now the same txn should not work + {error, {invalid_txns, _}} = test_utils:create_block(ConsensusMembers, [BuyerSignedTxn]), + + %% Back to seller + BackToSellerTxn = blockchain_txn_transfer_hotspot_v1:new(SellerPubkeyBin, + BuyerPubkeyBin, + SellerPubkeyBin, + 1), + SellerSignedBackToSellerTxn = blockchain_txn_transfer_hotspot_v1:sign_seller(BackToSellerTxn, BuyerSigFun), + BuyerSignedBackToSellerTxn = blockchain_txn_transfer_hotspot_v1:sign_buyer(SellerSignedBackToSellerTxn, SellerSigFun), + ct:pal("SignedTxn2: ~p", [BuyerSignedBackToSellerTxn]), + + ok = blockchain_txn:is_valid(BuyerSignedBackToSellerTxn, Chain), + + %% Put the buyback txn in a block + {ok, Block3} = test_utils:create_block(ConsensusMembers, [BuyerSignedBackToSellerTxn]), + _ = blockchain_gossip_handler:add_block(Block3, Chain, self(), blockchain_swarm:swarm()), + ?assertEqual({ok, blockchain_block:hash_block(Block3)}, blockchain:head_hash(Chain)), + ?assertEqual({ok, Block3}, blockchain:head_block(Chain)), + ?assertEqual({ok, 3}, blockchain:height(Chain)), + + %% The first txn should be now invalid + {error, _} = blockchain_txn:is_valid(Txn, Chain), + %% Also check it does not appear in a block + {error, {invalid_txns, _}} = test_utils:create_block(ConsensusMembers, [BuyerSignedTxn]), + ok. +