Why closing an invite prompt shouldn't pay a reward

GameInvitePromptClosed and ShareSheetClosed fire whether or not anything was sent. How to reward invites when the friend actually joins instead.

warningLuaudit check: invite-prompt-rewards

What it looks like

The game pays players for inviting or sharing, but pays when the prompt closes rather than when a friend actually joins. The reward hangs off SocialService.ShareSheetClosed, a client's GameInvitePromptClosed handler that fires a remote, or a remote that reports how many friends were invited.

Luau
SocialService.ShareSheetClosed:Connect(function(player)
	profile.Coins += 50
end)

What an exploiter could do

They open the invite prompt and close it straight away, as often as the game allows, and collect the reward each time without inviting anyone. Where the client reports a friend count, they send the largest count the server accepts. A reward meant to grow the game becomes free currency, and none of it brings in a single player.

How to fix it

  • Don't reward opening or closing an invite or share prompt. Show the prompt, and leave it at that.
  • Pay for invites when the invited friend joins: read ReferredByPlayerId on the server in Players.PlayerAdded, pay the inviter and the invitee once per invitee, and save that the referral was paid.
  • If the game wants a small thank-you for using the prompt at all, make it a one-off per account, saved, with no currency attached.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local SocialService = game:GetService("SocialService")

local PlayerData = require(ServerScriptService.PlayerData)

local shareReward = 50
local lastShared: { [Player]: number } = {}

ReplicatedStorage.Remotes.ShareGame.OnServerEvent:Connect(function(player: Player)
	local ok = pcall(SocialService.PromptLinkSharingAsync, SocialService, player)
	if not ok then
		warn("Couldn't open the share sheet")
	end
end)

SocialService.ShareSheetClosed:Connect(function(player: Player)
	local now = os.clock()
	if now - (lastShared[player] or -60) < 60 then
		return
	end
	lastShared[player] = now
	local profile = PlayerData.get(player)
	if profile then
		profile.Coins += shareReward
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastShared[player] = nil
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local SocialService = game:GetService("SocialService")

local PlayerData = require(ServerScriptService.PlayerData)

local referrals = DataStoreService:GetDataStore("Referrals")
local inviterReward = 50
local lastPrompted: { [Player]: number } = {}

ReplicatedStorage.Remotes.ShareGame.OnServerEvent:Connect(function(player: Player)
	local now = os.clock()
	if now - (lastPrompted[player] or -10) < 10 then
		return
	end
	lastPrompted[player] = now
	local ok = pcall(SocialService.PromptLinkSharingAsync, SocialService, player)
	if not ok then
		warn("Couldn't open the share sheet")
	end
end)

Players.PlayerAdded:Connect(function(player)
	local referrerId: unknown = (player:GetJoinData() :: any).ReferredByPlayerId
	if typeof(referrerId) ~= "number" or referrerId <= 0 or referrerId == player.UserId then
		return
	end
	local ok, first = pcall(function()
		local fresh = false
		referrals:UpdateAsync(tostring(player.UserId), function(paidTo: number?): number?
			fresh = paidTo == nil
			if paidTo then
				return nil
			end
			return referrerId
		end)
		return fresh
	end)
	if not ok or not first then
		return
	end
	local inviter = Players:GetPlayerByUserId(referrerId)
	local profile = inviter and PlayerData.get(inviter)
	if profile then
		profile.Coins += inviterReward
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastPrompted[player] = nil
end)

How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.