@@ -764,4 +764,342 @@ mod tests {
764764 let actual_output = result. expect ( "swap succeeds" ) ;
765765 assert ! ( actual_output >= min_output, "Output should meet minimum" ) ;
766766 }
767+
768+ // =========================================================================
769+ // Trading competitions / cross-chain trades / liquidity mining (Issue #997)
770+ // =========================================================================
771+
772+ #[ ink:: test]
773+ fn trading_competition_scores_swaps_and_pays_proportional_rewards ( ) {
774+ let mut dex = setup_dex ( ) ;
775+ let pair_id = create_pool ( & mut dex) ;
776+ let accounts = test:: default_accounts :: < DefaultEnvironment > ( ) ;
777+
778+ test:: set_block_number :: < DefaultEnvironment > ( 100 ) ;
779+
780+ // Admin creates a competition on pair 1 running blocks 100..110.
781+ let competition_id = dex
782+ . create_trading_competition (
783+ Some ( pair_id) ,
784+ String :: from ( "Weekly Volume Race" ) ,
785+ 10_000 ,
786+ 100 ,
787+ 110 ,
788+ 1 ,
789+ 10 ,
790+ String :: from ( "PCG" ) ,
791+ )
792+ . expect ( "competition creation should work" ) ;
793+
794+ let competition = dex. get_trading_competition ( competition_id) . unwrap ( ) ;
795+ assert ! ( competition. active) ;
796+ assert_eq ! (
797+ dex. get_active_competitions( ) . len( ) ,
798+ 1 ,
799+ "competition must be listed as active inside its window"
800+ ) ;
801+
802+ // Claiming before the competition ends is rejected even with score.
803+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
804+ assert_eq ! (
805+ dex. claim_competition_reward( competition_id) ,
806+ Err ( Error :: InvalidRequest )
807+ ) ;
808+
809+ // Bob and Charlie trade inside the window; their quote output is the
810+ // scored volume.
811+ let _ = dex. swap_exact_base_for_quote ( pair_id, 1_000 , 1 ) . unwrap ( ) ;
812+ test:: set_caller :: < DefaultEnvironment > ( accounts. charlie ) ;
813+ let _ = dex. swap_exact_base_for_quote ( pair_id, 2_000 , 1 ) . unwrap ( ) ;
814+
815+ let bob_score = dex. get_competition_score ( competition_id, accounts. bob ) ;
816+ let charlie_score = dex. get_competition_score ( competition_id, accounts. charlie ) ;
817+ assert ! ( bob_score > 0 , "bob must accrue score from his swap" ) ;
818+ assert ! ( charlie_score > bob_score, "larger trade -> larger score" ) ;
819+
820+ assert_eq ! (
821+ dex. list_competition_participants( competition_id) ,
822+ vec![ accounts. bob, accounts. charlie]
823+ ) ;
824+ let leaderboard = dex. get_competition_leaderboard ( competition_id) ;
825+ assert_eq ! ( leaderboard. len( ) , 2 ) ;
826+ assert ! ( leaderboard. contains( & ( accounts. bob, bob_score) ) ) ;
827+ assert ! ( leaderboard. contains( & ( accounts. charlie, charlie_score) ) ) ;
828+
829+ // After the window closes rewards can be claimed pro rata.
830+ test:: set_block_number :: < DefaultEnvironment > ( 111 ) ;
831+ let expected = 10_000u128 * bob_score / ( bob_score + charlie_score) ;
832+ assert ! ( expected > 0 ) ;
833+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
834+ let balance_before = dex. get_governance_balance ( accounts. bob ) ;
835+ let reward = dex. claim_competition_reward ( competition_id) . unwrap ( ) ;
836+ assert_eq ! ( reward, expected) ;
837+ assert_eq ! (
838+ dex. get_governance_balance( accounts. bob) ,
839+ balance_before + reward
840+ ) ;
841+
842+ // Double claims are rejected...
843+ assert_eq ! (
844+ dex. claim_competition_reward( competition_id) ,
845+ Err ( Error :: InvalidRequest )
846+ ) ;
847+
848+ // ...as are claims from accounts that never traded.
849+ test:: set_caller :: < DefaultEnvironment > ( accounts. django ) ;
850+ assert_eq ! (
851+ dex. claim_competition_reward( competition_id) ,
852+ Err ( Error :: InvalidRequest )
853+ ) ;
854+ }
855+
856+ #[ ink:: test]
857+ fn trading_competition_admin_controls_and_validation ( ) {
858+ let mut dex = setup_dex ( ) ;
859+ let accounts = test:: default_accounts :: < DefaultEnvironment > ( ) ;
860+
861+ test:: set_block_number :: < DefaultEnvironment > ( 100 ) ;
862+
863+ // Only the admin may create competitions.
864+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
865+ assert_eq ! (
866+ dex. create_trading_competition(
867+ None ,
868+ String :: from( "Bob's Race" ) ,
869+ 5_000 ,
870+ 100 ,
871+ 110 ,
872+ 1 ,
873+ 10 ,
874+ String :: from( "PCG" )
875+ ) ,
876+ Err ( Error :: Unauthorized )
877+ ) ;
878+
879+ // Validation of title, window and reward amount.
880+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
881+ assert_eq ! (
882+ dex. create_trading_competition(
883+ None ,
884+ String :: new( ) ,
885+ 5_000 ,
886+ 100 ,
887+ 110 ,
888+ 1 ,
889+ 10 ,
890+ String :: from( "PCG" )
891+ ) ,
892+ Err ( Error :: InvalidRequest )
893+ ) ;
894+ assert_eq ! (
895+ dex. create_trading_competition(
896+ None ,
897+ String :: from( "Backwards" ) ,
898+ 5_000 ,
899+ 110 ,
900+ 100 ,
901+ 1 ,
902+ 10 ,
903+ String :: from( "PCG" )
904+ ) ,
905+ Err ( Error :: InvalidRequest )
906+ ) ;
907+ assert_eq ! (
908+ dex. create_trading_competition(
909+ None ,
910+ String :: from( "No Reward" ) ,
911+ 0 ,
912+ 100 ,
913+ 110 ,
914+ 1 ,
915+ 10 ,
916+ String :: from( "PCG" )
917+ ) ,
918+ Err ( Error :: InvalidRequest )
919+ ) ;
920+
921+ let competition_id = dex
922+ . create_trading_competition (
923+ None ,
924+ String :: from ( "Open Race" ) ,
925+ 5_000 ,
926+ 100 ,
927+ 110 ,
928+ 1 ,
929+ 10 ,
930+ String :: from ( "PCG" ) ,
931+ )
932+ . unwrap ( ) ;
933+
934+ // Deactivation stops score accrual immediately...
935+ dex. deactivate_competition ( competition_id) . unwrap ( ) ;
936+ assert ! ( !dex. get_trading_competition( competition_id) . unwrap( ) . active) ;
937+ assert ! ( dex. get_active_competitions( ) . is_empty( ) ) ;
938+
939+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
940+ let other_pool = create_pool ( & mut dex) ;
941+ let _ = dex. swap_exact_base_for_quote ( other_pool, 1_000 , 1 ) ;
942+ assert_eq ! (
943+ dex. get_competition_score( competition_id, accounts. bob) ,
944+ 0 ,
945+ "inactive competitions must not accrue score"
946+ ) ;
947+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
948+
949+ // ...and re-activation restores it.
950+ dex. activate_competition ( competition_id) . unwrap ( ) ;
951+ assert ! ( dex. get_trading_competition( competition_id) . unwrap( ) . active) ;
952+
953+ // Authorization and unknown ids on both toggles.
954+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
955+ assert_eq ! (
956+ dex. deactivate_competition( competition_id) ,
957+ Err ( Error :: Unauthorized )
958+ ) ;
959+ assert_eq ! (
960+ dex. activate_competition( 999 ) ,
961+ Err ( Error :: Unauthorized )
962+ ) ;
963+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
964+ assert_eq ! (
965+ dex. deactivate_competition( 999 ) ,
966+ Err ( Error :: InvalidRequest )
967+ ) ;
968+ assert_eq ! (
969+ dex. activate_competition( 999 ) ,
970+ Err ( Error :: InvalidRequest )
971+ ) ;
972+ }
973+
974+ #[ ink:: test]
975+ fn cross_chain_trade_lifecycle_create_attach_finalize ( ) {
976+ let mut dex = setup_dex ( ) ;
977+ let pair_id = create_pool ( & mut dex) ;
978+ let accounts = test:: default_accounts :: < DefaultEnvironment > ( ) ;
979+
980+ // Bob opens an intent to move value to chain 2.
981+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
982+ let trade_id = dex
983+ . create_cross_chain_trade (
984+ pair_id,
985+ None ,
986+ 2 ,
987+ accounts. charlie ,
988+ 500 ,
989+ 1 ,
990+ )
991+ . expect ( "cross-chain trade creation should work" ) ;
992+ assert_eq ! (
993+ dex. get_portfolio_snapshot( accounts. bob)
994+ . cross_chain_positions,
995+ 1 ,
996+ "a pending intent counts as an open cross-chain position"
997+ ) ;
998+
999+ // Only the trader or admin may attach a bridge request.
1000+ test:: set_caller :: < DefaultEnvironment > ( accounts. django ) ;
1001+ assert_eq ! (
1002+ dex. attach_bridge_request( trade_id, 7 ) ,
1003+ Err ( Error :: Unauthorized )
1004+ ) ;
1005+
1006+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
1007+ dex. attach_bridge_request ( trade_id, 7 ) . unwrap ( ) ;
1008+ assert_eq ! (
1009+ dex. get_portfolio_snapshot( accounts. bob)
1010+ . cross_chain_positions,
1011+ 1 ,
1012+ "bridge-requested trades are still open positions"
1013+ ) ;
1014+
1015+ // Finalization is admin-only...
1016+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
1017+ assert_eq ! (
1018+ dex. finalize_cross_chain_trade( trade_id) ,
1019+ Err ( Error :: Unauthorized )
1020+ ) ;
1021+
1022+ // ...and settles the position.
1023+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
1024+ dex. finalize_cross_chain_trade ( trade_id) . unwrap ( ) ;
1025+ assert_eq ! (
1026+ dex. get_portfolio_snapshot( accounts. bob)
1027+ . cross_chain_positions,
1028+ 0 ,
1029+ "settled intents no longer count as open positions"
1030+ ) ;
1031+ }
1032+
1033+ #[ ink:: test]
1034+ fn cross_chain_trade_rejects_unknown_pair_and_unconfigured_route ( ) {
1035+ let mut dex = setup_dex ( ) ;
1036+ let pair_id = create_pool ( & mut dex) ;
1037+ let accounts = test:: default_accounts :: < DefaultEnvironment > ( ) ;
1038+
1039+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
1040+
1041+ // Unknown pool id.
1042+ assert_eq ! (
1043+ dex. create_cross_chain_trade( 999 , None , 2 , accounts. charlie, 500 , 1 ) ,
1044+ Err ( Error :: PoolNotFound )
1045+ ) ;
1046+
1047+ // Chain 3 has no configured bridge route/fee quote.
1048+ assert_eq ! (
1049+ dex. quote_cross_chain_trade( 3 ) ,
1050+ Err ( Error :: InvalidBridgeRoute )
1051+ ) ;
1052+ assert_eq ! (
1053+ dex. create_cross_chain_trade( pair_id, None , 3 , accounts. charlie, 500 , 1 ) ,
1054+ Err ( Error :: InvalidBridgeRoute )
1055+ ) ;
1056+
1057+ // Attaching or finalizing unknown trade ids fails cleanly.
1058+ assert_eq ! (
1059+ dex. attach_bridge_request( 42 , 1 ) ,
1060+ Err ( Error :: CrossChainTradeNotFound )
1061+ ) ;
1062+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
1063+ assert_eq ! (
1064+ dex. finalize_cross_chain_trade( 42 ) ,
1065+ Err ( Error :: CrossChainTradeNotFound )
1066+ ) ;
1067+ }
1068+
1069+ #[ ink:: test]
1070+ fn liquidity_mining_campaign_is_admin_only_and_validated ( ) {
1071+ let mut dex = setup_dex ( ) ;
1072+ let accounts = test:: default_accounts :: < DefaultEnvironment > ( ) ;
1073+
1074+ // Non-admins cannot configure campaigns.
1075+ test:: set_caller :: < DefaultEnvironment > ( accounts. bob ) ;
1076+ assert_eq ! (
1077+ dex. set_liquidity_mining_campaign( 50 , 10 , 20 , String :: from( "PCG" ) ) ,
1078+ Err ( Error :: Unauthorized )
1079+ ) ;
1080+
1081+ // Validation of emission rate, window and token symbol.
1082+ test:: set_caller :: < DefaultEnvironment > ( accounts. alice ) ;
1083+ assert_eq ! (
1084+ dex. set_liquidity_mining_campaign( 0 , 10 , 20 , String :: from( "PCG" ) ) ,
1085+ Err ( Error :: InvalidRequest )
1086+ ) ;
1087+ assert_eq ! (
1088+ dex. set_liquidity_mining_campaign( 50 , 20 , 20 , String :: from( "PCG" ) ) ,
1089+ Err ( Error :: InvalidRequest )
1090+ ) ;
1091+ assert_eq ! (
1092+ dex. set_liquidity_mining_campaign( 50 , 10 , 20 , String :: new( ) ) ,
1093+ Err ( Error :: InvalidRequest )
1094+ ) ;
1095+
1096+ // A valid campaign is stored and readable.
1097+ dex. set_liquidity_mining_campaign ( 50 , 10 , 20 , String :: from ( "PCG" ) )
1098+ . expect ( "valid campaign config should work" ) ;
1099+ let campaign = dex. get_liquidity_mining_campaign ( ) ;
1100+ assert_eq ! ( campaign. emission_rate, 50 ) ;
1101+ assert_eq ! ( campaign. start_block, 10 ) ;
1102+ assert_eq ! ( campaign. end_block, 20 ) ;
1103+ assert_eq ! ( campaign. reward_token_symbol, String :: from( "PCG" ) ) ;
1104+ }
7671105}
0 commit comments