Shadow Pulse

Version 1.0.2. The listing, or pick it up in the shop and run it.

Shadow Pulse

Shadow Pulse watches the other economy. Economy Pulse covers the clean money, and this one logs every dollar of dirty money that shows up on your server, where it came from, and how fast and how cheaply it gets laundered. Staff get one dashboard with the dirty money created per hour, the laundering rate, the time from dirty to clean, the cut each laundering route takes, who holds the most and who launders the most. Players moving a suspicious amount raise a flag, and you can have it posted to Discord.

Dashboard

What you get

  • A dirty money ledger grouped by source (robberies, heists, drug sales, theft, admin, unknown), written in batches every few minutes so a full server costs a handful of queries an hour.
  • Laundering detection: dirty money leaving a character and clean money arriving on the same character shortly after, at a believable ratio, plus any reason you mark as laundering.
  • Laundering stats: laundering rate (laundered divided by created), median and average time from picking dirty money up to having it clean, and the cut each route takes and how often it's used.
  • Top holders and top launderers, with character names and ids.
  • A verdict in the same style as Economy Pulse: too cheap, too fast, everything gets laundered, stockpiling, or balanced.
  • Flags for one character laundering more than a set amount in an hour, and for dirty money arriving with no known source. Optional Discord webhook.
  • Framework support for ESX Legacy, Qbox and QBCore, with ox_inventory, qb-inventory or ak47_qb_inventory, detected automatically.

What it can see, per framework

Dirty money is a different thing on every server, so here is exactly how each setup is read. Nothing here is guessed: each line was checked against the framework's current source, and server/bridge.lua lists the file and line for every call.

Setup Dirty money is How changes are seen
ESX Legacy the black_money account ESX fires an event with the script's reason on every add, remove and set. Exact, also when ox_inventory stores the account as an item.
QBCore with qb-inventory the markedbills item, value in info.worth qb-inventory's AddListener reports adds and removes with the script's reason. Moves between players, stashes and drops don't fire it, so those come from sampling.
QBCore or Qbox with ox_inventory markedbills (value in metadata.worth) and black_money (one dollar per unit) ox_inventory has no hook when an item is removed, so holdings are sampled every minute. Its createItem and swapItems hooks are used to name where a sampled change came from (the resource that created it, or a move between players or into a stash).
QBCore with ak47_qb_inventory markedbills (value in info.worth) and the black_money account item ak47 answers to qb-inventory's export names, but it has no AddListener, no item hooks (its registerHook only prints "not supported yet") and no item events, and its AddItem drops the reason. So holdings are sampled every minute from ak47's own inventory table, which is more current than PlayerData.items. black_money is read from both the money type and ak47's account slot, and the larger counts, because ak47 can change the slot without a money event. With the optional one-line relay (below), ak47's item log reports each change the moment it happens.
Any, with a dirty money type a money type you added yourself (for example blackmoney) QBCore:Server:OnMoneyChange, exact, with reason. List it in Config.Dirty.qbAccounts.

Honest limits

  • Sampled changes have no reason of their own. On ox_inventory and ak47_qb_inventory, and for qb-inventory moves, a change is only noticed at the next sample (one minute by default) and logged one sample later, so a bag dropped and picked back up cancels out. A sampled change borrows a reason from a money event on the same character between those two samples, give or take Config.MatchSeconds, but only when exactly one kind of reason fits. Marked bills gone while clean cash arrives with a laundering reason (at a payout between a tenth of the bills and KnownMaxRatio) count as laundered, and dirty money gained while money arrives with a source reason (a robbery payout) gets that source. Each money event names one change at most. Anything else is logged as "untracked" under Unknown source. Calling the Record export from your heist and laundering scripts fixes that (see below). On ak47_qb_inventory the optional relay swaps the minute-wide window for the second the item moved.
  • Offline holdings. On ESX the top holders list reads every character's account straight from the database. On QBCore and Qbox the worth of marked bills is buried inside each player's inventory JSON, which older MySQL versions can't add up, so the list shows what each character held when last seen online.
  • Time to clean is measured from when the money was picked up, first in first out. Money held before Shadow Pulse was installed has no pickup time and is left out of the timing (it still counts toward every total).
  • Laundering detection reads a pattern, so it can be wrong. For example, a drug sale paid in clean cash right after a player spent dirty money on something else can look like laundering. Paychecks and admin money never count, and you can add more to IgnoreClean.

Requirements

  • ESX Legacy, Qbox or QBCore
  • oxmysql
  • MySQL 5.7+ or MariaDB 10.2+
  • Optional: ox_inventory, qb-inventory or ak47_qb_inventory for item based dirty money

Install

  1. Unzip so that resources/shadow_pulse/fxmanifest.lua exists (not resources/shadow_pulse-v1.0.0/shadow_pulse/..., which Windows "Extract All" creates), then add ensure shadow_pulse. Start order doesn't matter: it waits up to 30 seconds for your framework and prints what it found, for example watching dirty money on qbox (ox).
  2. Give staff access in server.cfg:
    add_ace group.admin shadowpulse.admin allow
    
  3. Optional, for Discord alerts on flags. Use set, never setr, which would send the URL to every client:
    set shadow_pulse_webhook "https://discord.com/api/webhooks/..."
    
  4. Start the server. The tables create themselves (sql/install.sql is there if your database user can't create tables).
  5. Open the dashboard with /shadowpulse in game. Type shadowpulse in the server console for a one-line summary.

Give it a day before you read anything into the numbers. Laundering stats need real traffic, and the chart needs two hours of activity.

Configure

Everything lives in config.lua, with comments.

  • Config.Dirty: your dirty account (ESX), any dirty money types you added (QBCore, Qbox), and your dirty items with the metadata key that holds their value. If your marked bills use another key (value, amount), change worth. Set perUnit = false if a stack's worth is already the total.
  • Config.Sources: reason text that names where dirty money came from. Check Unknown source on the dashboard after a day. Whatever lands there is a script with a reason you haven't mapped yet, or a sampled change nobody named.
  • Config.Inventory: auto finds ox_inventory, ak47_qb_inventory or qb-inventory. ak47_qb_inventory is used whenever it runs, even with qb set, because it answers to qb-inventory's name without working like it.
  • Config.MatchSeconds: the leeway, in seconds, a sampled change gets when it borrows the reason of a money event (15 by default, also when the line is missing from an older config).
  • Config.Laundering: the time window (180 seconds), the ratio band (clean paid out divided by dirty taken, 0.4 to 1.0), the reasons that mean laundering and name the route, and clean income that never counts.
  • Config.RequireLaunderReason: 'auto' (the default, also when the line is missing from an older config) means a dirty drop and a clean payout only pair when one of them has a laundering reason, on ak47_qb_inventory only. There every drop is seen without a reason, so pairing on amounts alone would call any cash a player got near a drop laundering. true or false forces it on every inventory.
  • Config.Flags: the hourly laundering limit per character and the smallest unknown-source amount worth a flag.
  • Config.Verdict: what counts as too fast and too cheap for your server.

ak47_qb_inventory: optional one-line relay

ak47 has no item events (no shade to ak47), but its readable webhooks.lua has a SendLog function that ak47 calls on every inventory change. One line there hands the same log to Shadow Pulse. Open resources/[qb]/ak47_qb_inventory/webhooks.lua (wherever ak47 sits on your server), find SendLog = function(type, logData) and add this as the first line inside it:

TriggerEvent('ak47_qb_inventory:server:itemLog', type, logData)

Restart the server. Shadow Pulse's start lines then say ak47_qb_inventory: item log relay set up in webhooks.lua, and item log relay active once the first inventory log arrives. Your Discord webhooks keep working, the line only adds a listener.

What it changes:

  • Exact timing. A change to black_money is logged with the amount in the log, two seconds after it happens (so a payout the same script makes right after is already known). The log has no worth for marked bills, so a change to them makes Shadow Pulse read that player again two seconds later instead of waiting for the next sample. Either way, the change borrows the reason of a money event within Config.MatchSeconds of the moment it happened, instead of anywhere in the minute between two samples.
  • Moves are named. A bag or black money put into a stash, trunk, glovebox or drop shows as Stashes and drops, and one handed to another player as Player transfers. Neither counts as spent or laundered, and taking it back out isn't new dirty money.
  • Counted once. The same change reaching Shadow Pulse through the relay, a money event, the sampler and a Record call still counts once.

The log names items by their label, so Shadow Pulse reads the labels from ak47's item list (Black Money, Marked Money by default). An ak47 update replaces webhooks.lua and drops the line, so add it again after updating. Without it, everything still works through sampling.

Hook up your heist and laundering scripts

Reasons are what make the dashboard useful. On ESX and with qb-inventory, the reason your script already passes to addAccountMoney or AddItem gets used as is. Where the bridge can't see a reason (ox_inventory) or can't see the money at all (a script with its own cash system), tell Shadow Pulse yourself, server side:

-- dirty money paid out by a heist
exports.shadow_pulse:Record(source, 'in', 25000, 'pacific heist')

-- dirty money spent or destroyed
exports.shadow_pulse:Record(source, 'out', 4000, 'black market weapon')

-- dirty money laundered, with the clean payout if your script knows it
exports.shadow_pulse:Record(source, 'laundered', 10000, 'moneywash', 7000)

You can call Record before or after your script changes the inventory, in the same tick or a few seconds apart. If the same change also reaches Shadow Pulse through an ESX event, a qb-inventory listener, the ak47 relay or the sampler, it counts once. With the ak47 relay, call Record before the change or within two seconds after it if you want your reason to be the one logged. Amounts that aren't positive numbers are ignored and Record returns false. Pass 0 as the source for money with no player attached.

For your own panels, exports.shadow_pulse:GetSummary('24h' | '7d' | '30d') returns the same numbers the dashboard shows.

How laundering is detected

For each character, recent dirty money going out and clean money (cash or bank) coming in wait in two short lists. When one side arrives, Shadow Pulse looks for the oldest entry on the other side within WindowSeconds whose ratio of clean to dirty sits inside MinRatio to MaxRatio. When either side carries a laundering reason, any ratio up to KnownMaxRatio matches. With Config.RequireLaunderReason on (ak47_qb_inventory by default), a pair needs that reason, and a drop with no laundering reason on either side stays spent. Dirty money removed with a laundering reason that never meets its payout still counts as laundered when the window closes, with the payout unknown. The route shown on the dashboard is the first Routes entry whose text appears in either reason.

Security

The framework money events are listened to with AddEventHandler only, never as network events, so a modified client calling TriggerServerEvent('esx:addAccountMoney', ...) reaches nothing in this resource. The dashboard checks the ace permission on every request, and the webhook URL lives in a server convar.

Troubleshooting

  • Nothing happens on start, or "couldn't find fxmanifest.lua": the folder is nested one level too deep. You need resources/shadow_pulse/fxmanifest.lua.
  • No framework finished starting within 30s: the message lists what it saw. Make sure your core is started, or set Config.Framework.
  • Which mode is running: the start lines say it, for example qb-inventory: hooks (AddListener) for adds and removes, sampling every 60 s for moves, ak47_qb_inventory: item log relay set up in webhooks.lua or ak47_qb_inventory: no item hooks or events (AddItem drops its reason), sampling every 60 s. These lines are information, nothing is broken.
  • ak47 relay line added but no item log relay active line: move any item in your inventory, since the line prints on the first log. If it still doesn't show, ak47 hasn't been restarted since the edit, or the line is commented out or sits outside SendLog.
  • qb-inventory: no AddListener in this version: your qb-inventory predates listeners. Everything still works through sampling, with reasons only where a money event names the change. Update qb-inventory or use Record.
  • ak47_qb_inventory and Unknown source: ak47 drops the reason your scripts pass to AddItem, so bags and black money given by a script land in Unknown source unless a money event with a reason happens in the same window. Call Record from those scripts to name them.
  • Everything lands in Unknown source: your scripts pass no reason, or one that Config.Sources doesn't cover. Add the text, or call Record with a reason.
  • Marked bills show as $0: your bills keep their value under another metadata key. Change worth in Config.Dirty.items.
  • No flags on Discord: check the convar is set with set (not setr) and the URL is a Discord webhook.
  • Database setup failed: oxmysql isn't connected or the database user can't create tables. Run sql/install.sql by hand.

Escrow

The core is encrypted with Cfx asset escrow. These stay open so you can adapt them: config.lua, locales/, server/bridge.lua (all framework and inventory code lives there, edit it if your core is forked) and sql/install.sql.

Support

Open a ticket in the Discord with your framework, inventory, oxmysql version and the server console output from startup.