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.
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.
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
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
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
- Offers that can only grow after acceptance, such as a tip jar, don't hurt the other player. Check whether any change can make an offer worse for them.
- A trade that clears acceptance on every change through one shared function, like
changedabove, is fine even if each remote looks bare on its own. - One-way gifts with no acceptance step are judged by Instance arguments used without an ownership check and Choice not checked against what the player has unlocked, not this check.