How to check IsPaidItemTradingAllowed before players trade

Trading items bought with Robux, or with currency bought with Robux, needs a PolicyService check for each player. How to add it on the server.

warningLuaudit check: paid-item-trading

What it looks like

Players can trade, or sell to each other, items that were bought with Robux or with a currency the game sells for Robux. The server never checks PolicyService to see whether each player is allowed to trade paid items.

Luau
remotes.SendTrade.OnServerEvent:Connect(function(player, target, items)
	startTrade(player, target, items)
end)

How to detect it

  • Find every way an item moves from one player to another: trades, player markets, auction houses, stalls and listings for coins.
  • Work out whether any of those items are paid items. An item is paid if it was bought with Robux, or with a currency any developer product grants, or came from a paid random item such as a crate bought with that currency. Follow the currency back to ProcessReceipt.
  • Look for a server-side PolicyService:GetPolicyInfoForPlayerAsync call and a check of IsPaidItemTradingAllowed before a player can start, join or complete a trade that includes paid items. Each player in the trade needs their own check.
  • GetPolicyInfoForPlayerAsync is a web call and can error, so it needs pcall. Treating an unknown result as not allowed is the safe choice.
  • A check that only hides the trade button on the client doesn't count. The server handler has to refuse.
  • Gifts between players move items the same way. If paid items can be gifted, say the gift path needs the same check rather than treating it as certain.
  • Robux prices can differ by region. Roblox's regional pricing guide warns that trades and gifts of paid items between players with different price levels allow price arbitrage, and suggests MarketplaceService:GetUsersPriceLevelsAsync to limit those transfers. It says to call it when players join rather than caching the result between sessions. Mention it where paid items move between players, as advice rather than a finding.

What an exploiter could do

No exploit is needed. Roblox's paid random items page says that when IsPaidItemTradingAllowed is false, creators must not let that player trade paid items, including what came from a paid random item. Without the check, players in places where this is restricted can still trade them, which breaks Roblox's rules for the experience.

How to fix it

  • When a player joins, call GetPolicyInfoForPlayerAsync on the server inside pcall, retry on failure, and keep IsPaidItemTradingAllowed in a server table.
  • In every handler that starts, accepts or completes a trade or listing, refuse when the stored value isn't true for either player and paid items are involved.
  • Let those players keep and use what they bought. Only the transfer to others is restricted.

Vulnerable example

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

local remotes = ReplicatedStorage.Remotes
local requests: { [Player]: Player } = {}

remotes.RequestTrade.OnServerEvent:Connect(function(player: Player, target: unknown)
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	requests[player] = target
	remotes.TradeRequested:FireClient(target, player)
end)

Players.PlayerRemoving:Connect(function(player)
	requests[player] = nil
	for from, to in requests do
		if to == player then
			requests[from] = nil
		end
	end
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local PolicyService = game:GetService("PolicyService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local remotes = ReplicatedStorage.Remotes
local requests: { [Player]: Player } = {}
local canTrade: { [Player]: boolean } = {}

local function checkPolicy(player: Player)
	for attempt = 1, 3 do
		local ok, policy = pcall(PolicyService.GetPolicyInfoForPlayerAsync, PolicyService, player)
		if ok then
			if player.Parent == Players then
				canTrade[player] = policy.IsPaidItemTradingAllowed == true
			end
			return
		end
		task.wait(attempt * 2)
	end
end

remotes.RequestTrade.OnServerEvent:Connect(function(player: Player, target: unknown)
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	if not canTrade[player] or not canTrade[target] then
		return
	end
	requests[player] = target
	remotes.TradeRequested:FireClient(target, player)
end)

Players.PlayerAdded:Connect(checkPolicy)
for _, player in Players:GetPlayers() do
	task.spawn(checkPolicy, player)
end

Players.PlayerRemoving:Connect(function(player)
	canTrade[player] = nil
	requests[player] = nil
	for from, to in requests do
		if to == player then
			requests[from] = nil
		end
	end
end)

Common false positives

  • Trading items that can only be earned in play, never bought with Robux or a bought currency, isn't paid item trading. Check that no product, pass or bundle leads to them.
  • Games where every item in the trade comes from free play can trade freely even if other items in the game are paid, as long as the server keeps paid items out of trades.
  • A trade system that's checked once on join and refuses everyone whose result is unknown is the fix, not a finding.