Economy journal
Bastrel keeps a journal of the money and of the items that move on your server. It records them, and it ships three blocking rules (see “Blocking rules” below). Those rules are delivered unverified: they stay in observe (journal only, no refusal, no sanction, no score) until they are lifted. Three anomaly codes (see “Anomalies” below) are fed directly, in memory, by the same money-change events and inventory hooks as the journal (they keep working with journalMoney = false) and are unverified too.
What is recorded
Section titled “What is recorded”| Source | What is recorded | Needs |
|---|---|---|
| Money | Each change of a player’s account (cash, bank, crypto): the account, the raw amount of the event, the signed change (delta), the balance afterwards when it is known, the reason given by the framework |
QBX (qbx_core) |
| Items | Each successful move or swap, purchase, craft and item creation of ox_inventory: the item, the quantity, where it comes from and where it goes, the player when the event names one |
ox_inventory and adapters.inventory.hooks = true |
With another framework (QBCore, ESX, standalone) the money is not recorded in this version: Bastrel prints one [bastrel] economy: money is not journaled with this framework line at startup, and the items are still recorded.
Vehicles (purchase, gift, deletion) are not recorded in this version: no official source for them has been established yet.
The lines go to the bastrel_transactions table, kept 90 days (logger.retention.transactionsDays). They are written asynchronously: a slow or missing database never delays the server, a line that cannot be written is counted and dropped.
What the journal does not tell you
Section titled “What the journal does not tell you”- The resource that made the change is never recorded (the
resourcecolumn is always empty). Neither the money event of the framework nor the inventory hooks give it, and Bastrel does not guess it. This is a known limit. - A
deltathat Bastrel cannot compute stays empty and is never 0. For example, asetof an account whose previous balance is not known has an amount (the new balance) but nodelta. The balance is read fromqbx_coreonce, when the character loads; until then it is unknown. - An item creation (
reason = create) does not prove that an item was delivered: the hook can fire before a shop purchase that then fails. A rejected move, purchase or craft is not recorded. - The balance is a cache fed by the events of
qbx_core. A money change that does not go through theOnMoneyChangeevent (a script that writes the account directly) makesbalance_after, and thedeltaof a laterset, drift until the character loads again. A non-integer amount refused by Bastrel makes the balance of that account unknown, never wrong. - Cash may also be an item of
ox_inventory(inventory:accounts): a payment may then show as a money line and as an item line. This is being measured. - The money event of
qbx_coreis a local event that other scripts of your server could trigger by mistake or on purpose. Bastrel records it only when it comes fromqbx_coreitself and not from a player. What it refuses is counted, never recorded.
Reading the journal
Section titled “Reading the journal”/bas tx <server id | license:identifier> <duration> (permission bastrel.staff) lists the lines of a player over the last 30m, 2h, 7d or 4w, newest first, at most economy.txListMax lines. The journal is kept per licence, so a player with no known licence cannot be listed. It never shows an IP address or a hardware token. There is no automatic rollback.
Settings (config/modules.lua, block economy)
Section titled “Settings (config/modules.lua, block economy)”| Setting | Default | Meaning |
|---|---|---|
enabled |
true |
Turns the journal off completely |
journalMoney |
true |
Records the money changes |
journalItems |
true |
Records the inventory movements |
journalBurstPerWindow |
200 |
Lines written per player and per window (1 to 100000); the others are counted and written as ONE line aggregated at the end of the window |
journalWindowMs |
10000 |
The window of the setting above (1000 to 3600000) |
journalInFlightMax |
32 |
Writes waiting for the database at the same time (1 to 1000); beyond, the line is counted lost so that a storm cannot take the whole queue of the logger from the detections |
txListMax |
50 |
Lines of /bas tx (1 to 500) |
migrationRetryMs |
30000 |
If the startup update of the database fails, try again after this long (1000 to 3600000) |
migrationRetryMaxMs |
600000 |
The wait doubles after each failure up to this (1000 to 86400000) |
An invalid value takes its default, with a warning at startup. Nothing is written until the database has the delta column (Bastrel adds it at startup if your database is older, and tries again after a failure; see install-sql). A warning is printed at startup if journalInFlightMax is above the share of the logger queue open to every entry.
Blocking rules (BAS-EC-001, BAS-EC-002, BAS-EC-003)
Section titled “Blocking rules (BAS-EC-001, BAS-EC-002, BAS-EC-003)”On top of the journal, Bastrel judges three kinds of ox_inventory movement before they happen, through a hook with a callback on swapItems, buyItem and craftItem (the only events that can be refused and that name the player). createItem is never judged: it names no player and cannot be refused.
| Code | What it catches | Needs |
|---|---|---|
BAS-EC-001 |
A purchase or a craft whose origin is not in your list (never a give: its origin is always the giver’s inventory) | whitelists.economy.sourceInventories, then calibrated = true |
BAS-EC-002 |
A swap or give with another player further away than maxTransferDistance |
nothing |
BAS-EC-003 |
A quantity above the cap of the item in one movement that brings items from another inventory (a rearrangement inside one inventory is never judged) | itemCaps and/or defaultItemCap |
All three are heuristics, never a proof, and all three ship as unverified: the payloads of the three events, the refusal through the hook and the position of a stash have not been captured in game yet. Until a later release lifts that flag, a detection is only a line in the detection journal: nothing is refused, there is no alert, no score, no Discord message, whatever your mode.
Once lifted, a movement is refused only when Bastrel is in enforce (and, for BAS-EC-001, only with whitelists.economy.calibrated = true). The weight of BAS-EC-001 is 0: it never feeds the score, so a forgotten list entry can never lead to a kick. If you raise that weight yourself, know that the score can then reach the kick threshold. BAS-EC-002 and BAS-EC-003 weigh 10 each. A refusal only cancels the action; it is never a ban.
The list of origins. It ships empty and nothing is invented for you. Origins are written as the journal writes them (/bas tx): stash:police_locker, shop:General:1 (shop type and id), bench:3. An entry allows the origin that equals it and every origin that continues it after a colon: shop allows every shop, shop:General allows shop:General:1 and shop:General:2 (never shop:Gen). There is no pattern or wildcard. A player inventory (player, player:12) cannot be listed: a player id changes at every connection, and the origin of a give is always the giver’s own inventory; such an entry refuses the whole list. While the list is empty, every purchase and craft is a detection in observe: that is the point, read them, then build the list from what is legitimate on your server, then set calibrated = true. One invalid entry (not a text, empty, longer than 100 bytes, more than 512 entries) refuses the whole list and BAS-EC-001 then judges nothing; a warning is printed at startup.
Known limits.
- The cap of
BAS-EC-003applies to what arrives from another inventory (a purchase, a craft, a swap between two inventories). Moving 5000 items from one slot to another of the same inventory is not judged. - A stash, a trunk, the ground: their position is not read (not documented yet), so
BAS-EC-002only judges a swap with another player. - A player who moves fast can be further than the limit between the click and the hook (a false positive risk, which is why the limit is configurable).
- The routing bucket (instance) of the players is not compared.
- The hook runs inside the action of
ox_inventoryitself, so it only reads memory; the detection is written at the next tick (about a second later). A slow callback is counted and logged (hook_slow, once per window) but never changes a verdict. - The shape of the craft recipe (
recipe.count) is not documented: a craft with no readable quantity is not judged byBAS-EC-003. - If the hooks of
ox_inventoryare turned off or missing (adapters.inventory.hooks = false), the rules cannot run: one[bastrel]line says so at startup. - When the queue is full (see
ruleQueueMax,ruleQueuePerPlayer), detections are lost and ONErule_queue_lostline perjournalWindowMsgives the count. - Turn all of it off with
economy.rules = false(the journal keeps running).
| Setting | Default | Meaning |
|---|---|---|
rules |
true |
false: no hook with a callback |
ruleQueueMax |
64 |
Detections waiting for the next tick (2 to 1000); beyond, counted lost (a refusal is still decided) |
ruleQueuePerPlayer |
16 |
Most detections of ONE player in that queue (1 to 1000; never more than half of ruleQueueMax), so that one player cannot fill it |
maxTransferDistance |
15 |
Metres between two players (1 to 100000) |
itemCaps |
{} |
['bread'] = 50: most units of an item in one movement; at most 512 items, caps from 1 to 1000000000 |
defaultItemCap |
0 |
Cap of the other items; 0 = none |
hookBudgetUs |
2000 |
Microseconds of CPU for one callback; over it, one hook_slow line per journalWindowMs. Information only and indicative: the clock is no finer than about 1 ms on Windows and counts the CPU of the whole process on Linux |
The evidence of a detection names the event, the item, the quantity, the origin and destination (or the distance and the other player, or the cap) and refused, which says what was answered to ox_inventory at that moment.
Anomalies (BAS-EC-010, BAS-EC-011, BAS-EC-020)
Section titled “Anomalies (BAS-EC-010, BAS-EC-011, BAS-EC-020)”These three codes never block anything and never act on a player on their own. They are unverified in this version: the detection is written to the journal and nothing else happens (no alert, no score, nothing on Discord) until a session on a real server lifts the flag. Turn them all off with economy.anomalies = false (the journal keeps running).
What they see. The money they add up is what QBCore:Server:OnMoneyChange reports while bastrel runs (QBX only in this version), in the accounts of moneyAccounts (cash and bank by default; crypto only if you add it). A change that does not go through that event is not seen, and the balance Bastrel keeps for a player can drift until his next login, so a set can be counted wrong. Nothing is read in the tables of your framework.
BAS-EC-010, gain above the threshold of the job. The net gain of a player (gains minus losses) over the last gainWindowMs (one hour) is above the threshold of his job: jobGainPerHour = { default = 500000, police = 200000 }, the key being the job name of the framework; a job that is not listed takes default. A sale that is balanced by a purchase does not count. The check runs every anomalyCheckMs (30 s), a few players per tick. One detection per player and per gainWindowMs: a player who earns a lot honestly produces one line an hour, not one every 30 seconds, and it is not repeated just because his total went down and up again. When the flag is lifted its score weight is the alert threshold (30), so one detection alerts a human. Guarantee: whatever the gain, the score of this code stays strictly under the kick threshold, provided weight / (1 - 2^(-gainWindowMs / halfLifeMs)) is under scoring.thresholds.kick (40 against 100 with the shipped values). A gainWindowMs that would break it (600000, ten minutes, would settle at 145) is raised at startup to the smallest safe value, with a warning. When no safe window exists (a weight at or above the kick threshold, or a smallest safe window beyond 24 h because of a long half-life), the guarantee does NOT hold: your gainWindowMs is kept as is and the startup warning says “no safe window”; lower the weight under the kick threshold, and if it already is, lower the half-life. jobGainPerHour is a rate per hour: the sum of the window is compared with it scaled to gainWindowMs (threshold x gainWindowMs / 3600000, at least 1), so a window raised at startup needs no change of thresholds. The weight, the half-life and the kick threshold are read from your scoring config. The window follows the licence of the player: when he leaves it is kept under his licence (at most departedMax windows, freed once the hour is over) and given back if he comes back under another server id, so reconnecting does not reset his gain.
BAS-EC-011, rare item from nowhere. Fill whitelists.rareItems.items with the names of the items that must never appear from nowhere ({ 'gold_bar' }). It ships empty and BAS-EC-011 then judges nothing. A createItem of one of them for a player is judged rareSourceWindowMs (30 s) after it: if that player had a purchase, a craft or a move out of a stash, a trunk or the ground (anything that is not a player) of the same item in the 30 s before OR after, it is explained and nothing is written; otherwise BAS-EC-011 names the item. The quantity is not compared. A script of yours that creates a rare item on purpose is reported too: that is what the list is for. The last rareSourceMax (64) incoming movements of each player are kept. One incoming movement explains at most ONE creation (a purchase followed by 50 creations leaves 49 to judge). One invalid entry refuses the whole list (a warning at startup). The creation of a player who disconnects before the verdict is not erased: if he comes back (same licence) it is judged for him; otherwise it is written as an economy / rare_departed journal line (code BAS-EC-011, item, licence), never as a detection on a server id that is no longer his; if the same licence comes back within one more window, the verdict becomes a BAS-EC-011 detection on the new id (delayed = true). Limit: the createItem hook of ox_inventory does not give the type of the inventory (an integer can be the id of a stash), so Bastrel does not attribute a createItem to a player: until this has been checked in game, BAS-EC-011 receives no real creation (the code is unverified anyway).
BAS-EC-020, variation of the money issue. This is a finding about the server, never about a player: it has no player, no score and no sanction. Bastrel adds up the net money issue of each UTC day (the sum of the changes above) and keeps it in the table bastrel_meta (economy_issue_YYYYMMDD). The first tick after midnight UTC, the day that has just ended is compared with the average of the moneyMassBaselineDays (7) days before it; if it differs by more than moneyMassDailyPct (30) percent, up or down, one finding is written (day, amount, average, percentage). Nothing is written before 7 days of history exist, and a baseline that is not positive (a server that destroys more money than it issues) cannot be compared: no finding. The day judged is remembered (economy_mass_checked), so a restart never repeats it. The issue is counted only while bastrel runs: hours with the resource stopped are missing from the total.
| Setting | Default | Meaning |
|---|---|---|
anomalies |
true |
false: none of the three codes |
moneyAccounts |
{ 'cash', 'bank' } |
The accounts that are added up (1 to 8 names) |
gainWindowMs |
3600000 |
The window of the gain (60000 to 86400000; jobGainPerHour is scaled to it); also the shortest time between two BAS-EC-010 for one player |
jobGainPerHour |
{ default = 500000 } |
Gain allowed PER HOUR, by job (scaled to gainWindowMs); default is required (1 to 1000000000000) |
anomalyCheckMs / anomalyBatch |
30000 / 32 |
How often the gains are checked, and how many players per tick |
rareSourceWindowMs |
30000 |
A rare item is explained by an incoming movement this long before or after (1000 to 3600000) |
rareSourceMax |
64 |
Incoming movements of rare items kept per player |
rarePendingMax / rarePendingPerPlayer |
256 / 32 |
Creations waiting for the verdict, in the whole server and per player; a creation without room is counted and logged (rare_pending_lost, once per journalWindowMs) |
departedMax |
1024 |
Bound of three things, each: the windows of gain and the unexplained rare-item verdicts kept for players who left (found again by licence when they come back), and the inventories that get their own journal window for createItem lines without a player (the next ones share one window). 16 to 65536; the excess is counted (parkedLost in the stats) and not kept |
moneyMassDailyPct |
30 |
BAS-EC-020: variation of a day against the days before it, in percent (up to 10000) |
moneyMassBaselineDays |
7 |
… how many days before it must all be known (1 to 60) |
massFlushMs / massRetryMs |
60000 / 60000 |
The total of the day is written to bastrel_meta at most this often when it changed; a read that failed is tried again after this long |
Known limits. A payday paid to many players at once is a gain for each of them, under the threshold of a normal job. A server whose economy changes a lot from one day to the next (an event, a wipe) will produce BAS-EC-020 findings: read them, they are not accusations. The ox_inventory hook createItem can run before the shop confirms a purchase, which is why a purchase just after the creation also explains it. The reverse is a false positive: when the creation ran and the purchase then FAILED, a refused buyItem never reaches Bastrel, so there is no incoming movement and BAS-EC-011 names the item. Read the line before acting on it. If bastrel_meta cannot be read or written (database down), BAS-EC-020 stays silent and tries again; the other two codes do not depend on it.