CityRP Documentation Repository

Clans — Bank Module

Last updated

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:

  1. Pooling breaks attribution — a withdrawal does not reveal whose deposit it was.
  2. Lump sum, not metered — salary drips per payday; a withdrawal is instant.
  3. 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 /salary remains 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_limits permission. 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_LIMIT now 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_days have 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:

  1. Deposited funds become clan property.
  2. Only ranks holding bank.withdraw can take money out — and it names whether the player currently holds it.
  3. Deposits can be reclaimed by the depositor for self_reclaim_window after the deposit, and not after.
  4. 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, with UNIQUE (clan, seq).
  • Each write computes seq = COALESCE(MAX(seq), 0) + 1 for that clan inside the same INSERT ... 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:

  1. It never compared the requested amount against the rank's NUMERIC_LIMIT cap, 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.
  2. An inner JOIN against clan_rank_permissions only 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:RunChecked and DB:QueryParamsChecked (db.lua:164, 257) currently discard the affected-row count for non-INSERT queries. 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_transactions row, written in the same callback as the atomic UPDATE, 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 is 700 × 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_claims is 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 stays done = 0 and is retried until they connect. This is the offline delivery mechanism.
  • Return without done() on cityrp.remote.noSave servers (build, event) so a real server picks the row up instead.
  • Require IsValid(ply) and ply._Initialized before granting.
  • Call SaveData() after GiveMoney so 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
1 Does economy telemetry justify any fee? Resolved — no fee. Not a zero default awaiting evidence; the fee mechanism is not built. See No deposit or withdrawal fee
2 Are 3 days / 3 members the right unlock gate? Confirmed. The unlock is a one-way latch and never relocks — see The unlock is a one-way latch
3 No return path for a non-privileged depositor 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
4 Should per-rank withdrawal caps differ? 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
5 Does /salary's lack of an audit log need fixing alongside? Tracked separately as #3004. Not blocking this module
6 Pay out to former members at dissolution? Resolved — everyone in the ledger. Fairer, and it removes the incentive to kick members before dissolving