Clans — Bank Module
Last updated
May be stale
This page was last updated ago, on 30 August 2026. Check what it says against the code before relying on it.
Module 2. Shared clan funds: deposits, rank-gated withdrawals, and an audit ledger.
Status: In Design. Depends on core.md for ranks and permissions. See design.md for the module framework.
Source
Issue #1418 (Clan/Business Account) asks for a clan wallet the owner and officers can deposit to and withdraw from, motivated by "business-oriented clans that wish to have a clan account to place funds into for business expenses, which may include real estate, equipment, salaries."
Issue #1600 separately proposes clan group accounts with leader-configured access, and interest on stored balances as a deposit incentive.
Economic stance
Deposit and withdrawal are neutral transfers. Wallet to bank, 1:1. They become a sink or a faucet only if a fee or interest is attached — and that is the actual decision, not something inherent to having a bank.
No interest
Interest is money created from nothing, uncapped, on every balance, forever.
The game already runs a 10% income tax (cityrp.configuration["Default Tax"])
specifically to manage money supply on earned income; adding an ungated faucet
that rewards parking cash more reliably than working is inconsistent with that
stance.
If #1600's underlying goal — give players a reason to bank rather than hoard — still matters, the sink-safe forms are a fee-funded redistribution pool, or a non-monetary prestige incentive. Both are larger scope and neither is proposed for v1.
No deposit or withdrawal fee
Decided: no fee mechanism is built. This is not a configurable rate defaulted to zero — there is no fee code path, and none needs testing.
A percentage haircut punishes the legitimate case #1418 describes (a business clan saving for real estate) and is a rounding error to anyone moving duped or stolen money — they do not care about efficiency loss on money they did not earn. Taxing the transaction type does not distinguish good actors from bad; it just taxes the honest ones harder, because they are the ones who notice and change behaviour.
If a fee is ever proposed, it needs fresh justification rather than a config change: the analysis above concluded fees are the wrong lever for the problem they are usually reached for, so re-adding one means the reasoning changed.
The laundering question, honestly framed
Deposit-by-A / withdraw-by-B is an untaxed player-to-player transfer. But this
is not a new capability — /salary add already does a raw
GiveMoney(-x) / GiveMoney(x) with no fee and no persistent audit trail
(employee_salary/sv_init.lua:131).
The bank changes the shape of an existing route in three ways that matter:
- Pooling breaks attribution — a withdrawal does not reveal whose deposit it was.
- Lump sum, not metered — salary drips per payday; a withdrawal is instant.
- Cheap containers — $50,000 is trivial against amounts worth laundering.
What closes it is throttling and attribution, not taxation: per-transaction and rolling caps limit blast radius and force multiple logged steps; a small, named withdrawer set shrinks the suspect pool; and a queryable ledger makes it investigable. On a human-moderated server, staff investigation is the real enforcement layer — the ledger is the control, and it is not optional scope.
Worth checking separately: if
/salaryremains unlogged, the bank must not become the softer target purely by having better logging than the alternative. Flagged in plan.md as an adjacent gap.
Tuning knobs
| Variable | Type | Default | Safe range | Breaks if too low | Breaks if too high |
|---|---|---|---|---|---|
deposit_fee_pct |
— | not implemented | — | — | No fee. Not a knob defaulted to zero — the mechanism is not built |
withdrawal_fee_pct |
— | not implemented | — | — | As above |
interest_rate |
— | not implemented | — | — | Any nonzero value is an uncapped faucet with no offsetting sink |
per_withdrawal_cap |
currency | $25,000 | $10k–$100k | Legitimate large purchases need many repeated withdrawals; players route around the bank via /salary |
Stops constraining anything at the $50k creation-cost scale; one action empties a treasury |
rolling_withdrawal_cap |
currency / 24h, clan-scoped | $75,000 | 3–6× per-transaction cap | Blocks legitimate high-tempo operations | Above ~10× it stops throttling at all |
bank_capacity_ceiling |
currency | BIGINT column ceiling |
— | — | Not unbounded. clans.bank is currently int DEFAULT 0 NULL — a signed 32-bit ceiling of 2,147,483,647, and NULL-able, so SET bank = bank + ? against a NULL row yields NULL and the deposit vanishes. Migrate to BIGINT NOT NULL DEFAULT 0 before the Bank module ships |
creation_cost_seeds_bank |
bool | false | — | — | Seeding creates arbitrage between creation cost, deposits and the refund paths |
balance_on_delete |
enum | pro-rata by net contribution | — | — | forfeit destroys money members contributed in good faith; refund-leader is a direct exit-scam vector |
self_reclaim_window |
seconds | 600 | 120–1800 | Too short to catch a mistake or a pressured deposit | Long enough to use the bank as a personal stash, and widens the window where a legitimate clan spend is undone underneath it |
consent_reprompt_days |
days | 30 | 7–90 | Prompt fatigue; players click through without reading | The warning goes stale — leadership may have changed entirely since they last agreed |
unlock_min_age |
days | 3 | 1–7 | Negligible friction against a same-day mule clan | Locks out the small business clan #1418 describes for over a week |
unlock_min_members |
count | 3 | 2–5 | At 2, the cheapest collusion pair clears it trivially | At 5 it excludes #1418's stated use case |
Two of these are load-bearing rather than ordinary knobs:
- The rolling cap must be clan-scoped, not per-person. If each promoted withdrawer got a fresh allowance, the promote → withdraw → demote cycle bypasses it by rotating accounts. One clock, shared.
unlock_min_age= 3 days is what actually closes the deposit-then-undo scam, not a special case in the undo path. Three days ≫ the 300-second undo window, so a bank cannot hold deposits while undo is available. Treat that as a primary reason for the gate, not a coincidence.
The unlock is a one-way latch
Once a clan meets the gate, it never relocks, even if membership later falls
below unlock_min_members.
This must be implemented as a stored flag — clans.bank_unlocked_at, set
once when the conditions are first met. Do not evaluate
age >= N AND members >= M on each action: that is the obvious implementation
and it relocks automatically as a side effect.
Relocking would be actively harmful:
- It traps funds. Members would be unable to withdraw money they are entitled to, for a reason unrelated to their own conduct.
- It is a griefing vector. A leader could kick members specifically to freeze the bank.
- It does not serve the gate's purpose. The gate exists to stop disposable mule clans, which is a creation-time concern. A clan that has demonstrably existed for three days with three members has satisfied it permanently; shrinking later does not make it retroactively disposable.
The latch also simplifies dissolution: a clan that never unlocked has a zero balance by construction, so there is nothing to distribute.
unlock_min_members = 3 is deliberately lighter than the 5-member gate proposed
for clan inventory in #1624 — money access has lower per-unit abuse potential
than concrete item storage. That differential is a judgement call worth
confirming.
Rank gating
Withdrawal is not "acting on another rank", it is "acting on the treasury", so it does not use the threshold shape.
| Permission | Kind | Leader | Officer | Lieutenant | Private | Recruit |
|---|---|---|---|---|---|---|
bank.deposit |
boolean | ✓ | ✓ | ✓ | ✓ | ✓ |
bank.withdraw |
boolean | ✓ | ✓ | ✗ | ✗ | ✗ |
- Deposit open to all. Depositing cannot hurt the depositor beyond what they willingly hand over; gating it only frustrates "I want to chip in".
- Withdraw = Leader and Officer, matching #1418's explicit "owner/officers".
No
bank.configure_limitspermission. The tuning knobs (per_withdrawal_cap,rolling_withdrawal_cap,unlock_min_age,unlock_min_members,self_reclaim_window,consent_reprompt_days) are server operator config, not per-clan settings — no in-game surface edits them, so there is nothing for a permission to gate. A permission with no connected feature was a specification gap, not a real capability; removed rather than defined after the fact.
There is no per-rank withdrawal cap. per_withdrawal_cap is a single global
value applying to every withdraw-capable rank, so bank.withdraw is a plain
boolean rather than a NUMERIC_LIMIT.
This simplifies the atomic statement materially. A per-rank cap is racy — the
rank can change between check and write, which is why it had to live inside the
statement. A global cap is a configuration constant that cannot change
underneath a request, so it is validated in Lua alongside MAX_TRANSACTION and
never appears in SQL at all.
Note:
NUMERIC_LIMITnow has no consumer. It is retained in core.md as a general-purpose kind, but nothing currently uses it — worth revisiting if no module claims it by the time Core ships.
The clan-scoped rolling cap and the unlock gate are not rank permissions either. They are aggregate constraints evaluated after the permission check passes, and live in this module's own logic.
Informed deposit
Deposits are open to every rank, but withdrawal is not — so a member without withdraw permission is handing over money they cannot themselves retrieve. That must be a choice, not a surprise.
A consent prompt is shown before the deposit completes when either:
- this is the player's first deposit to this clan, or
- more than
consent_reprompt_dayshave passed since their last bank interaction with this clan.
A bank interaction is an explicit deposit or withdrawal — a reclaim counts, since the ledger already treats it as a withdrawal. Passive activity does not: opening the bank tab, viewing the balance or reading the ledger leave the clock running. The prompt exists because the player has not moved money recently enough to be confident the arrangement still holds.
Otherwise no prompt appears. Consent is stored per (steamid, clan), not
globally — agreeing in one clan says nothing about trusting another, and clan
leadership changes over time, which is what the staleness re-prompt exists for.
The prompt must state plainly:
- Deposited funds become clan property.
- Only ranks holding
bank.withdrawcan take money out — and it names whether the player currently holds it. - Deposits can be reclaimed by the depositor for
self_reclaim_windowafter the deposit, and not after. - If the clan is dissolved, remaining funds are distributed by net contribution.
Self-reclaim
A depositor may reclaim their own recent deposit without holding
bank.withdraw.
| Property | Rule |
|---|---|
| Eligibility | Per deposit, within self_reclaim_window of that deposit |
| Amount | The lesser of the unreclaimed remainder of that deposit and the current balance |
| Permission | None required — this is not a withdrawal |
| Rolling cap | Does not count against rolling_withdrawal_cap |
| Ledger | Recorded as a distinct reclaim action, but counts as a withdrawal for net-contribution purposes |
| Atomicity | Same conditional UPDATE pattern as withdrawal |
Exempting reclaims from the rolling cap is deliberate. If they counted, a malicious leader could deliberately exhaust the clan's daily cap to block a member from recovering their own money.
Reclaim is not a general withdrawal route: it is capped at what you put in, it expires, and it cannot touch anyone else's contribution.
Correctness and security requirements
The ledger is authoritative — event sourcing
clan_bank_transactions is an append-only event stream and the sole source
of truth. Balance is not stored data; it is a projection — a fold over the
stream. clans.bank is a materialised view of that projection and is
authoritative for nothing.
Consequences that follow from the model rather than from policy:
- Every balance change is an event. Deposits, withdrawals, reclaims and dissolution payouts are all entries, distinguished by action type.
- State is always reconstructable by replaying the stream. The cache can be discarded and rebuilt at any time.
- Nothing is ever edited or deleted. A correction is a compensating entry with its own reason, so the history of the correction is itself auditable.
- The ledger outlives the clan. It is not deleted on dissolution; it is both the audit record and the input to the distribution calculation.
- Any invariant expressible over the stream — such as "balance equals the sum of deltas" — is checkable rather than assumed.
clans.bank is display only. It is read for UI and for cheap
non-authoritative eligibility hints, and nothing else depends on it.
Rules that follow:
- The ledger insert is the authoritative write. It happens first, and it is what authorises and records the mutation.
- The cache update is a fire-and-forget mutation issued after the ledger callback returns. If it fails, nothing is lost — the value is derived, and reconciliation or the next write corrects it. Do not retry it, do not block on it, and do not fail the transaction because of it.
- On any disagreement, the ledger wins. Rebuild the cache from the stream.
- Dissolution distribution reads the ledger, never the cache.
- Reconciliation on clan load and periodically: assert
clans.bank == SUM(delta). Log a mismatch at Error, rebuild, and surface it to staff — but treat it as an observability signal, not a fault.
This removes the db.lua transaction gap entirely. With the ledger written first
and the cache derived, there is no window in which money moves without an audit
record — the failure mode inverts from "unrecoverable" to "cosmetic".
Consequence: the cache was also the lock
The cache UPDATE was doing a second job — a conditional update against a single
clans row takes a row lock, and that is what serialised concurrent withdrawals
across server processes. Taking it off the critical path removes that, and
summing the ledger in a subquery serialises nothing: two withdrawals can both
read the same total and both pass.
Replace it with a per-clan sequence number, which is the standard optimistic concurrency control for an event stream:
- The ledger carries
seq, withUNIQUE (clan, seq). - Each write computes
seq = COALESCE(MAX(seq), 0) + 1for that clan inside the sameINSERT ... SELECT. - Concurrent writers compute the same
seq; the unique constraint rejects the loser with a duplicate-key error. - The loser retries — recomputing balance and sequence against the now-current stream. Bank operations are low-frequency, so contention is rare and a small retry budget is sufficient.
Also store balance_after on each row, computed in the same statement. It makes
the current balance a single-row read rather than a full fold, and makes cache
rebuilds and audits cheap.
A duplicate-key rejection is not a user-facing error. It means "someone else wrote first" — retry, and only surface a failure if the retry budget is exhausted.
Withdrawals must be a single atomic statement
The balance check, the permission check and the ledger append are one statement. Never read the balance in Lua, decide, then write.
INSERT INTO clan_bank_ledger
(clan, seq, steamid, action, delta, balance_after, created_at)
SELECT ?, -- clan
COALESCE(MAX(l.seq), 0) + 1, -- next sequence
?, -- actor
'withdraw',
-?, -- amount
COALESCE(SUM(l.delta), 0) - ?, -- amount
UNIX_TIMESTAMP()
FROM clan_bank_ledger l
WHERE l.clan = ?
AND EXISTS (
SELECT 1
FROM clan_members m
LEFT JOIN clan_rank_permissions p
ON p.clan = m.clan AND p.rankid = m.rank
AND p.permission_id = 'bank.withdraw'
WHERE m.clan = ? AND m.steamid = ?
AND COALESCE(p.value, ?) = 1 -- ? = registered default
)
HAVING COALESCE(SUM(l.delta), 0) >= ? -- amount
COALESCE(p.value, ?) is still required even for a boolean: Core resolves module
defaults at read time and never materialises them, so an inner JOIN would find
no row for a rank relying on the default and wrongly deny it. The LEFT JOIN
plus the default bound as a parameter applies the same resolution order as
hasPermission.
The per-transaction cap does not appear here — it is global configuration,
checked in validateBankAmount.
Then branch on the outcome:
| Result | Meaning |
|---|---|
| 1 row inserted | Accepted. Issue the fire-and-forget cache update. |
| 0 rows | Rejected — insufficient balance, or permission absent or too low. |
Duplicate-key error on (clan, seq) |
Lost a race. Retry, do not report failure. |
A clan with no ledger rows still returns one aggregate row of NULLs, so the
COALESCE calls yield seq = 1 and balance = 0 — a first deposit works
(subject to the unlock gate below), and a withdrawal against an empty bank
correctly fails the HAVING.
The LEFT JOIN and COALESCE are both load-bearing — an earlier draft of
this statement checked only a boolean permission flag, which was wrong twice
over:
- It never compared the requested amount against the rank's
NUMERIC_LIMITcap, so the cap would have to be checked in Lua before the statement — reopening precisely the rank-change-then-withdraw ordering race this pattern exists to close. - An inner
JOINagainstclan_rank_permissionsonly matches materialised rows. Core resolves module defaults at read time and never materialises them, so a rank relying on the declared default would have no row and be wrongly denied.
Deposits are gated by the unlock latch too
Decided: deposits are blocked until the clan's bank has unlocked, not only withdrawals. An earlier pass left this implicit — "the deposit-then-undo scam cannot arise because the bank does not exist yet" — without ever writing the statement that makes it true. That is a real gap: nothing upstream of the ledger actually stops a deposit into a locked bank on its own. Fixed here by giving deposits the same rigor as withdrawal's statement, not by relying on it following from prose elsewhere in this document.
INSERT INTO clan_bank_ledger
(clan, seq, steamid, action, delta, balance_after, created_at)
SELECT ?, -- clan
COALESCE(MAX(l.seq), 0) + 1, -- next sequence
?, -- actor
'deposit',
?, -- amount
COALESCE(SUM(l.delta), 0) + ?, -- amount
UNIX_TIMESTAMP()
FROM clan_bank_ledger l
WHERE l.clan = ?
AND EXISTS (
SELECT 1 FROM clans c
WHERE c.id = ? AND c.bank_unlocked_at IS NOT NULL
)
No permission predicate — bank.deposit is universal — so the EXISTS clause
carries only the unlock check, which is why this statement is otherwise
simpler than withdrawal's. Same result branching: 1 row inserted is accepted,
0 rows means the bank has not unlocked yet (there is no other rejection reason
for a deposit once validateBankAmount has already passed in Lua).
This closes the deposit-then-undo scam by construction rather than by
implication: a bank that has not unlocked cannot accept a deposit, full stop,
independent of the unlock_min_age vs. self_reclaim_window/undo-window
arithmetic argued above. The arithmetic argument still holds and is worth
keeping as the reason unlock_min_age is sized the way it is, but the
deposit statement — not the arithmetic — is what actually enforces it.
COALESCE(p.value, ?) resolves this: Lua reads the registered default from the
permission registry and binds it as a parameter, so the SQL applies the same
resolution order as hasPermission without the defaults ever being written to
the table. A NULL default coalesces to NULL, and NULL >= x is not true, so
"no permission" still fails closed.
The same shape applies to any module permission gating a racing mutation.
This single pattern closes three separate attacks at once:
- Lost update / double spend — two withdrawals racing cannot both read the same starting balance, because there is no separate read.
- Rank-change-then-withdraw ordering — a demotion cannot land in a gap between the permission check and the money movement, because there is no gap.
- Replay — combined with a request row keyed on
status = 'pending', resubmitting a processed request finds no matching row and is a guaranteed no-op.
Prerequisite.
DB:RunCheckedandDB:QueryParamsChecked(db.lua:164, 257) currently discard the affected-row count for non-INSERTqueries. This pattern needs it — a small addition to both functions, to be agreed with the lead programmer before implementation.
Cross-process note. Multiple game servers and the web app share one MySQL
database. An in-process Lua lock protects nothing against a second server
process. The atomic conditional UPDATE is the only real serialisation point,
because the database is the only thing all writers share.
Amount validation
One validateBankAmount(amount) function, called by both deposit and
withdrawal. Do not write two.
if amount == nil then -- non-numeric
elseif amount ~= amount then -- NaN
elseif amount == math.huge then -- non-finite ("1e400" parses to inf)
elseif amount ~= math.floor(amount) then -- non-integer; money is whole units
elseif amount <= 0 then -- zero and negative both rejected
elseif amount > MAX_TRANSACTION then -- also guards precision loss above 2^53
MAX_TRANSACTION is not the withdrawal cap. The two are unrelated and were
previously easy to confuse:
MAX_TRANSACTION |
per_withdrawal_cap |
|
|---|---|---|
| Job | Safety rail against absurd input, precision loss above 2^53, and the column ceiling | Policy: how much a rank may take at once |
| Applies to | Deposits and withdrawals | Withdrawals only |
| Enforced in | validateBankAmount, before any other check |
The bank.withdraw permission value |
| Varies by rank | No — it is a global constant | Yes |
Proposed value: $100,000,000. Two thousand times the clan creation cost, far
above any plausible legitimate single transfer, and small enough that even
repeated maximum deposits stay many orders of magnitude below the BIGINT
ceiling. Lua numbers are doubles, so this also sits comfortably under the 2^53
integer-precision limit.
A negative "deposit" that escapes this is functionally an unauthenticated
withdrawal — asymmetric validation between the two paths is itself the
vulnerability. This check is UX only; the atomic UPDATE is the enforcement.
Audit ledger
Two logs, two purposes — do not conflate them.
cityrp.logs.add()for the admin-visible trail. Note it buffers in memory and flushes every 20 seconds, so a crash inside that window loses the entry. Acceptable for chat logs; not acceptable as the sole record of money.- A dedicated
clan_bank_transactionsrow, written in the same callback as the atomicUPDATE, before notifying the player. This is the audit of record. Minimum fields: clan, request ID, actor SteamID and name, action, amount, resulting balance, timestamp, originating surface.
Rate limiting
A 10-second cooldown per player, shared across all bank entry routes — deposit, withdraw and reclaim draw on one clock, so alternating between them cannot bypass it. That is six actions per minute, comfortably above any real usage pattern and well below anything worth automating.
Enforced server-side at the entry route. A client-side cooldown is a usability affordance, never the control.
The internal sequence-collision retry is exempt. It is not an entry-route invocation — it is machinery responding to contention that the player did not cause and cannot see. Subjecting it to the cooldown would convert a routine race into a ten-second stall or a spurious failure. Retries are bounded by the retry budget, not by the rate limit.
Degenerate strategies
| Exploit | Mechanism | Closing rule |
|---|---|---|
| Throwaway mule clan | Create, invite, deposit, withdraw, abandon | Age and member gates delay it; caps force multiple logged actions; ledger makes it traceable |
| Promote → withdraw → demote cycling | Rotate allies through Officer to reset a per-person cap | Clan-scoped rolling cap — everyone draws against one clock |
| Bank-drain-before-leaving | An Officer withdraws heavily then leaves | Not mechanically preventable while withdrawal is instant. The control is attribution: a small, named, logged withdrawer set makes it attributable and bannable. State this plainly — it is a trust control, not a hard block |
| Deposit-to-join scam | A leader tells a recruit to deposit to "prove commitment", withdraws it, then kicks them. Requires no exploit — it is the permission model working as specified | Consent prompt makes the risk explicit before the first deposit; self-reclaim gives a self_reclaim_window escape; pro-rata dissolution returns a share even to kicked members; the ledger makes the rest investigable |
| Reclaim-cap starvation | A leader exhausts the clan rolling cap specifically to block a member reclaiming their own deposit | Reclaims are exempt from the rolling cap |
| Kick-before-dissolve | A leader kicks members before deleting, to keep their contributions | Distribution includes everyone in the ledger, not only current members |
| Deposit-then-undo arbitrage | Mark deposits during the 300s undo window; creator undoes | Closed by construction via the 3-day unlock gate. Defensive guard regardless: if bank != 0, block /clan undo and require /clan delete |
| Self-serving drain-then-delete | A withdraw-capable member withdraws, then immediately deletes the clan, profiting from pro-rata dilution — see Why there is no deletion hold | Not mechanically preventable — same class as bank-drain-before-leaving. Attribution via the ledger; ongoing pattern monitoring is a community-management call, not an engineering one |
Undo and delete
/clan undo (300s grace, full $50,000 refund): with the 3-day unlock gate,
and deposits themselves blocked until the gate is met (see
Deposits are gated by the unlock latch too),
the scenario cannot arise — a clan within its 300s undo window is nowhere near
unlock_min_age, so its bank cannot hold a deposit yet. Add the guard anyway
so this stays true if the gate is ever loosened. No new bank logic is needed in
the common case.
If the age gate is rejected, the fallback is: refund the creator's $50,000 as today, and separately refund each depositor their own logged deposits to their own wallet — never crediting bank contents to the creator. If per-depositor refund is not buildable for v1, the safe minimum is to disallow undo outright whenever the balance is nonzero.
/clan delete (leader only, confirmation code, $25,000 refund) with a
nonzero balance:
- The $25,000 refund to the leader is unchanged — it compensates the creation cost and is independent of the bank.
- The balance is distributed pro-rata by net contribution, computed from the ledger. It is neither forfeited nor paid to the leader.
- The confirmation prompt must state the current balance and that it will be distributed this way, so the leader is not destroying or redirecting other members' money unknowingly.
- Deletion is immediate. There is no hold or countdown; distribution runs as part of the deletion, and members learn of it from their payout.
Why there is no deletion hold
An earlier draft had a 300-second broadcast-and-hold. It existed because the balance was originally forfeited — a last chance for withdraw-capable members to rescue funds before they were destroyed. Pro-rata distribution removed that justification entirely: contributors receive their share automatically.
Left in place, it would have actively created an arbitrage. Because negative nets are clamped to zero, total claims are always at least the balance, so a pro-rata share is diluted relative to net contribution — while a withdrawal converts at 1:1:
Balance 900. A net +800, B net −200, C net +300; total claims 1100. A's share is
900 × 800/1100= 654. If A first withdraws 200: balance 700, A net +600, claims 900. A's share is700 × 600/900= 466, plus the 200 already taken = 666.
A profits by draining first, and only members already holding bank.withdraw
can do it.
Removing the hold narrows this risk to its irreducible case; it does not close it. What a hold specifically enables is a third party reacting to an announced, pending distribution — that is the window a broadcast-and-hold opens, and that is what dropping the hold removes. It does not touch the actor who unilaterally controls the timing of deletion itself: a Leader (or an Officer, who can also withdraw) can always withdraw first and delete immediately after, with no broadcast, no hold, and no window for anyone to react to. Nothing in this module — with or without a hold — stops that, because the actor's own decision to delete is the trigger; there is nothing to race.
This is the same class of risk as bank-drain-before-leaving, above: not mechanically preventable while withdrawal is instant and the withdrawer controls their own timing. The control is the same, too — attribution. A small, named, logged withdrawer set (the ledger) makes the drain-then-delete sequence investigable and bannable after the fact, even though nothing stops it beforehand. This residual risk is a moderation/community-management concern, not an engineering one — ongoing monitoring for the pattern (a withdrawal immediately followed by deletion) is a call for whoever owns community management, not something this module's design can close by adding more mechanism.
Do not reintroduce a hold to try to close it. A hold does not touch the self-serving case above — the actor doesn't need the broadcast window, since they already know deletion is coming — and it reopens the third-party-reacts arbitrage this section exists to avoid. Any window between confirmation and distribution reopens that arbitrage unless the bank is frozen for its duration, which is the trade the earlier draft made and this design rejects.
The remaining safeguard against the third-party case is the type-the-clan-name confirmation, which is the standard pattern for irreversible destructive actions and does not have this problem.
Distribution formula
For every SteamID appearing in this clan's ledger:
net_i = Σ(deposits_i) − Σ(withdrawals_i + reclaims_i)
claim_i = max(net_i, 0)
total_claims = Σ(claim_i)
share_i = floor(balance × claim_i / total_claims)
| Symbol | Type | Range | Description |
|---|---|---|---|
net_i |
int | unbounded | Net contribution; may be negative for a net withdrawer |
claim_i |
int | ≥ 0 | Net contribution clamped at zero |
total_claims |
int | ≥ 0 | Sum of all positive claims |
balance |
int | ≥ 0 | Funds remaining at dissolution |
share_i |
int | ≥ 0 | Amount paid to this SteamID |
Rules that must be explicit or two implementations will differ:
-
Negative net is clamped to zero. A member who withdrew more than they deposited receives nothing, and never owes anything.
-
If
total_claimsis zero, fall back to equal distribution among everyone with at least one deposit entry and a net of zero. If no such participant exists, the entire balance goes to the leader.This branch should be unreachable. Under event sourcing the balance is the sum of all deltas, so if every net is at most zero the balance is also at most zero and there is nothing to distribute. Reaching it means the ledger and the cached balance have diverged, or funds entered outside the ledger. Therefore: log at Error and alarm before applying the fallback, and reconcile from the ledger first in case the balance itself is wrong. Treat the rule as damage limitation, not routine behaviour. It also avoids a division by zero.
-
Shares are floored; the remainder is discarded. It is at most one unit per claimant and not worth the complexity of a distribution rule.
-
Everyone in the ledger is included, not only current members. Someone who deposited and later left or was kicked still contributed the money. This also removes the incentive to kick members before dissolving. Worth confirming — see open question 6.
-
The ledger is not deleted with the clan. It is the audit record and the input to this calculation; deleting it would make the distribution unverifiable.
Payout delivery
| Recipient | Mechanism |
|---|---|
| Online | GiveMoney directly, followed by SaveData |
| Offline | Insert a remote row of a new type — see below |
A new remote type is required. The existing pmoney type is
moneypack-specific: it grants 50000 × qty plus trading cards and stickers and
announces publicly. It cannot be reused.
The new handler should follow pmoney's structure, which already implements the
behaviour needed:
- Return without calling
done()when the player is offline, so the row staysdone = 0and is retried until they connect. This is the offline delivery mechanism. - Return without
done()oncityrp.remote.noSaveservers (build,event) so a real server picks the row up instead. - Require
IsValid(ply)andply._Initializedbefore granting. - Call
SaveData()afterGiveMoneyso the payout persists. - Target
server = 'all'. Do not hardcode server IDs — that is defect 5.
Claim the row before granting, not after: UPDATE remote SET done = 1 WHERE id = ? AND done = 0 and proceed only if one row was affected. The existing
grant-then-mark ordering leaves a window in which two pollers could both pay out.
Open questions
| # | Question | Notes |
|---|---|---|
| Resolved — no fee. Not a zero default awaiting evidence; the fee mechanism is not built. See No deposit or withdrawal fee | ||
| Confirmed. The unlock is a one-way latch and never relocks — see The unlock is a one-way latch | ||
Resolved — three layers: a consent prompt on first deposit and after consent_reprompt_days of inactivity; a self_reclaim_window on your own deposits; and pro-rata distribution at dissolution |
||
Resolved — no per-rank cap. per_withdrawal_cap is global and bank.withdraw is a boolean, which takes the cap check out of SQL entirely |
||
/salary's lack of an audit log need fixing alongside? |
Tracked separately as #3004. Not blocking this module | |
| Resolved — everyone in the ledger. Fairer, and it removes the incentive to kick members before dissolving |