How to stop trade scams in a Roblox trading system

Offers that change after the other player accepts are the classic trade scam. How to reset acceptance on every change and recheck items when the trade completes.

criticalLuaudit check: trade-changes-after-accept

What it looks like

Two players build offers and each presses accept. The server completes the trade once both have accepted, but an offer can still change after the other player accepted it, or the server completes the trade without checking that each player still has what they offered.

Luau
remotes.RemoveFromTrade.OnServerEvent:Connect(function(player, index)
	table.remove(trade.offers[player], index)
end)

How to detect it

  • Find where the server keeps trade state: who is trading, each side's offer and who has accepted.
  • Follow every path that changes an offer: adding or removing items, changing a coin or gem amount, swapping a pet's level or variant. Each one must clear the acceptance of both players. One path that skips the reset is enough.
  • Look at what completes the trade. It must use the offers both players accepted, kept on the server, never an offer sent with the accept remote.
  • At completion, check the server confirms each player still owns every item and has every coin in their offer. Items can be sold, gifted, equipped, deleted or traded elsewhere while a trade is open.
  • Check items are taken from both sides before anything is given, so a failed check can't leave one side paid.
  • If there's a countdown after both accept, any change during it must cancel or restart it.
  • Trades between players in different servers also need Shared value read and written in separate calls and Player data with no session lock; report those under their own checks.

What an exploiter could do

This is the classic trade scam. The exploiter offers something valuable, waits for the other player to accept, then swaps it for something worthless or lowers the coin amount and accepts. The victim gets far less than what they agreed to. If ownership isn't rechecked, the exploiter can also sell or gift an offered item while the trade is open and still have it handed over, duplicating it.

How to fix it

  • Keep each trade's offers and acceptances on the server, and clear both acceptances whenever either offer changes in any way.
  • Give each trade a version number that every change increases. Have the accept remote send the version the player saw, and refuse acceptances for an older version.
  • At completion, check every item and amount is still owned, take everything from both sides, then give everything, all in the same step with no yield between the check and the change.
  • Cancel the trade if either player leaves.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

type Trade = {
	players: { Player },
	offers: { [Player]: { string } },
	accepted: { [Player]: boolean },
}

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

local function owns(player: Player, items: { string }): boolean
	local profile = PlayerData.get(player)
	if not profile then
		return false
	end
	local counts: { [string]: number } = {}
	for _, item in profile.Inventory do
		counts[item] = (counts[item] or 0) + 1
	end
	for _, item in items do
		if (counts[item] or 0) < 1 then
			return false
		end
		counts[item] -= 1
	end
	return true
end

local function move(from: Player, to: Player, items: { string })
	local source = PlayerData.get(from)
	local target = PlayerData.get(to)
	if not source or not target then
		return
	end
	for _, item in items do
		local index = table.find(source.Inventory, item)
		if index then
			table.remove(source.Inventory, index)
			table.insert(target.Inventory, item)
		end
	end
end

remotes.AddToTrade.OnServerEvent:Connect(function(player: Player, item: unknown)
	local trade = trades[player]
	if not trade or typeof(item) ~= "string" or #trade.offers[player] >= 8 then
		return
	end
	local offer = table.clone(trade.offers[player])
	table.insert(offer, item)
	if owns(player, offer) then
		trade.offers[player] = offer
	end
end)

remotes.RemoveFromTrade.OnServerEvent:Connect(function(player: Player, index: unknown)
	local trade = trades[player]
	if not trade or typeof(index) ~= "number" or not trade.offers[player][index] then
		return
	end
	table.remove(trade.offers[player], index)
end)

remotes.AcceptTrade.OnServerEvent:Connect(function(player: Player)
	local trade = trades[player]
	if not trade then
		return
	end
	trade.accepted[player] = true
	local a, b = trade.players[1], trade.players[2]
	if not (trade.accepted[a] and trade.accepted[b]) then
		return
	end
	trades[a], trades[b] = nil, nil
	if owns(a, trade.offers[a]) and owns(b, trade.offers[b]) then
		move(a, b, trade.offers[a])
		move(b, a, trade.offers[b])
	end
end)

Players.PlayerRemoving:Connect(function(player)
	local trade = trades[player]
	if trade then
		for _, member in trade.players do
			trades[member] = nil
		end
	end
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

type Trade = {
	players: { Player },
	offers: { [Player]: { string } },
	accepted: { [Player]: boolean },
	version: number,
}

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

local function owns(player: Player, items: { string }): boolean
	local profile = PlayerData.get(player)
	if not profile then
		return false
	end
	local counts: { [string]: number } = {}
	for _, item in profile.Inventory do
		counts[item] = (counts[item] or 0) + 1
	end
	for _, item in items do
		if (counts[item] or 0) < 1 then
			return false
		end
		counts[item] -= 1
	end
	return true
end

local function move(from: Player, to: Player, items: { string })
	local source = PlayerData.get(from)
	local target = PlayerData.get(to)
	if not source or not target then
		return
	end
	for _, item in items do
		local index = table.find(source.Inventory, item)
		if index then
			table.remove(source.Inventory, index)
			table.insert(target.Inventory, item)
		end
	end
end

local function changed(trade: Trade)
	trade.version += 1
	table.clear(trade.accepted)
	local offers: { [string]: { string } } = {}
	for member, offer in trade.offers do
		offers[tostring(member.UserId)] = offer
	end
	for _, member in trade.players do
		remotes.TradeUpdated:FireClient(member, offers, trade.version)
	end
end

remotes.AddToTrade.OnServerEvent:Connect(function(player: Player, item: unknown)
	local trade = trades[player]
	if not trade or typeof(item) ~= "string" or #trade.offers[player] >= 8 then
		return
	end
	local offer = table.clone(trade.offers[player])
	table.insert(offer, item)
	if owns(player, offer) then
		trade.offers[player] = offer
		changed(trade)
	end
end)

remotes.RemoveFromTrade.OnServerEvent:Connect(function(player: Player, index: unknown)
	local trade = trades[player]
	if not trade or typeof(index) ~= "number" or not trade.offers[player][index] then
		return
	end
	table.remove(trade.offers[player], index)
	changed(trade)
end)

remotes.AcceptTrade.OnServerEvent:Connect(function(player: Player, version: unknown)
	local trade = trades[player]
	if not trade or version ~= trade.version then
		return
	end
	trade.accepted[player] = true
	local a, b = trade.players[1], trade.players[2]
	if not (trade.accepted[a] and trade.accepted[b]) then
		return
	end
	trades[a], trades[b] = nil, nil
	if owns(a, trade.offers[a]) and owns(b, trade.offers[b]) then
		move(a, b, trade.offers[a])
		move(b, a, trade.offers[b])
	end
end)

Players.PlayerRemoving:Connect(function(player)
	local trade = trades[player]
	if trade then
		for _, member in trade.players do
			trades[member] = nil
		end
	end
end)

Common false positives