@openora/core 0.4.1-canary.91 → 0.4.1-canary.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/contract/index.d.ts +12 -0
  3. package/dist/admin-console/contract/index.d.ts.map +1 -1
  4. package/dist/admin-console/plugin.d.ts +18 -1
  5. package/dist/admin-console/plugin.d.ts.map +1 -1
  6. package/dist/admin-console/router/index.d.ts +6 -0
  7. package/dist/admin-console/router/index.d.ts.map +1 -1
  8. package/dist/analytics/plugin.d.ts +18 -1
  9. package/dist/analytics/plugin.d.ts.map +1 -1
  10. package/dist/audit/plugin.d.ts +18 -1
  11. package/dist/audit/plugin.d.ts.map +1 -1
  12. package/dist/casino/gaming/plugin.d.ts +18 -1
  13. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  14. package/dist/casino/lobby/plugin.d.ts +18 -1
  15. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  16. package/dist/cms/plugin.d.ts +18 -1
  17. package/dist/cms/plugin.d.ts.map +1 -1
  18. package/dist/compliance/plugin.d.ts +18 -1
  19. package/dist/compliance/plugin.d.ts.map +1 -1
  20. package/dist/contracts/adapters/audit.d.ts +1 -1
  21. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  22. package/dist/contracts/adapters/audit.js.map +1 -1
  23. package/dist/contracts/adapters/index.d.ts +4 -2
  24. package/dist/contracts/adapters/index.d.ts.map +1 -1
  25. package/dist/contracts/adapters/index.js +2 -1
  26. package/dist/contracts/adapters/index.js.map +1 -1
  27. package/dist/contracts/adapters/payment.d.ts +99 -0
  28. package/dist/contracts/adapters/payment.d.ts.map +1 -1
  29. package/dist/contracts/adapters/payment.js +6 -0
  30. package/dist/contracts/adapters/payment.js.map +1 -1
  31. package/dist/contracts/adapters/rate-limit.d.ts +1 -0
  32. package/dist/contracts/adapters/rate-limit.d.ts.map +1 -1
  33. package/dist/contracts/adapters/rate-limit.js +1 -0
  34. package/dist/contracts/adapters/rate-limit.js.map +1 -1
  35. package/dist/contracts/adapters/wallet-asset-catalog.d.ts +34 -0
  36. package/dist/contracts/adapters/wallet-asset-catalog.d.ts.map +1 -0
  37. package/dist/contracts/adapters/wallet-asset-catalog.js +3 -0
  38. package/dist/contracts/adapters/wallet-asset-catalog.js.map +1 -0
  39. package/dist/contracts/schemas/__tests__/common-schemas.test.js +2 -1
  40. package/dist/contracts/schemas/__tests__/common-schemas.test.js.map +1 -1
  41. package/dist/contracts/schemas/common.d.ts.map +1 -1
  42. package/dist/contracts/schemas/common.js +7 -4
  43. package/dist/contracts/schemas/common.js.map +1 -1
  44. package/dist/contracts/schemas/events.d.ts +15 -0
  45. package/dist/contracts/schemas/events.d.ts.map +1 -1
  46. package/dist/contracts/schemas/events.js +16 -2
  47. package/dist/contracts/schemas/events.js.map +1 -1
  48. package/dist/contracts/schemas/platform-config.d.ts +45 -0
  49. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  50. package/dist/contracts/schemas/platform-config.js +47 -0
  51. package/dist/contracts/schemas/platform-config.js.map +1 -1
  52. package/dist/contracts/schemas/wallet-tx.d.ts +38 -1
  53. package/dist/contracts/schemas/wallet-tx.d.ts.map +1 -1
  54. package/dist/contracts/schemas/wallet-tx.js +32 -3
  55. package/dist/contracts/schemas/wallet-tx.js.map +1 -1
  56. package/dist/engagement/chat/plugin.d.ts +18 -1
  57. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  58. package/dist/engagement/chat-commands/plugin.d.ts +18 -1
  59. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  60. package/dist/engagement/notifications/plugin.d.ts +18 -1
  61. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  62. package/dist/engagement/social/plugin.d.ts +18 -1
  63. package/dist/engagement/social/plugin.d.ts.map +1 -1
  64. package/dist/engagement/social-transfers/plugin.d.ts +18 -1
  65. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -1
  66. package/dist/iam/plugin.d.ts +18 -1
  67. package/dist/iam/plugin.d.ts.map +1 -1
  68. package/dist/iam/service/iam.service.d.ts +1 -1
  69. package/dist/pam/identity/plugin.d.ts +18 -1
  70. package/dist/pam/identity/plugin.d.ts.map +1 -1
  71. package/dist/pam/player-management/plugin.d.ts +18 -1
  72. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  73. package/dist/pam/player-note/plugin.d.ts +18 -1
  74. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  75. package/dist/pam/profile/plugin.d.ts +18 -1
  76. package/dist/pam/profile/plugin.d.ts.map +1 -1
  77. package/dist/pam/tag/plugin.d.ts +18 -1
  78. package/dist/pam/tag/plugin.d.ts.map +1 -1
  79. package/dist/server/auth/permissions.d.ts +36 -0
  80. package/dist/server/auth/permissions.d.ts.map +1 -1
  81. package/dist/server/auth/permissions.js +6 -0
  82. package/dist/server/auth/permissions.js.map +1 -1
  83. package/dist/server/db/__tests__/query-helpers.test.js +38 -1
  84. package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
  85. package/dist/server/db/index.d.ts +1 -1
  86. package/dist/server/db/index.d.ts.map +1 -1
  87. package/dist/server/db/index.js +1 -1
  88. package/dist/server/db/index.js.map +1 -1
  89. package/dist/server/db/query-helpers.d.ts +4 -0
  90. package/dist/server/db/query-helpers.d.ts.map +1 -1
  91. package/dist/server/db/query-helpers.js +31 -0
  92. package/dist/server/db/query-helpers.js.map +1 -1
  93. package/dist/server/runtime/core-token-catalog.d.ts +18 -1
  94. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  95. package/dist/server/runtime/core-token-catalog.js +3 -1
  96. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  97. package/dist/testing/mock.d.ts +12 -1
  98. package/dist/testing/mock.d.ts.map +1 -1
  99. package/dist/testing/mock.js +16 -0
  100. package/dist/testing/mock.js.map +1 -1
  101. package/dist/wallet/__tests__/settlement-network.test.d.ts +2 -0
  102. package/dist/wallet/__tests__/settlement-network.test.d.ts.map +1 -0
  103. package/dist/wallet/__tests__/settlement-network.test.js +54 -0
  104. package/dist/wallet/__tests__/settlement-network.test.js.map +1 -0
  105. package/dist/wallet/__tests__/wallet-asset.router.int.test.d.ts +2 -0
  106. package/dist/wallet/__tests__/wallet-asset.router.int.test.d.ts.map +1 -0
  107. package/dist/wallet/__tests__/wallet-asset.router.int.test.js +234 -0
  108. package/dist/wallet/__tests__/wallet-asset.router.int.test.js.map +1 -0
  109. package/dist/wallet/__tests__/wallet-auto-rule.router.int.test.js +5 -3
  110. package/dist/wallet/__tests__/wallet-auto-rule.router.int.test.js.map +1 -1
  111. package/dist/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.js +4 -2
  112. package/dist/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.js.map +1 -1
  113. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js +6 -3
  114. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js.map +1 -1
  115. package/dist/wallet/__tests__/wallet-custody-stubs.router.test.d.ts +2 -0
  116. package/dist/wallet/__tests__/wallet-custody-stubs.router.test.d.ts.map +1 -0
  117. package/dist/wallet/__tests__/wallet-custody-stubs.router.test.js +80 -0
  118. package/dist/wallet/__tests__/wallet-custody-stubs.router.test.js.map +1 -0
  119. package/dist/wallet/__tests__/wallet-kyc-gate.int.test.js +2 -1
  120. package/dist/wallet/__tests__/wallet-kyc-gate.int.test.js.map +1 -1
  121. package/dist/wallet/__tests__/wallet-webhook.router.int.test.js +119 -4
  122. package/dist/wallet/__tests__/wallet-webhook.router.int.test.js.map +1 -1
  123. package/dist/wallet/__tests__/wallet.rate-limit.int.test.js +2 -1
  124. package/dist/wallet/__tests__/wallet.rate-limit.int.test.js.map +1 -1
  125. package/dist/wallet/__tests__/wallet.router.int.test.js +23 -3
  126. package/dist/wallet/__tests__/wallet.router.int.test.js.map +1 -1
  127. package/dist/wallet/__tests__/wallet.service.int.test.js +265 -12
  128. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  129. package/dist/wallet/adapters/wallet-asset-catalog.service.d.ts +14 -0
  130. package/dist/wallet/adapters/wallet-asset-catalog.service.d.ts.map +1 -0
  131. package/dist/wallet/adapters/wallet-asset-catalog.service.js +37 -0
  132. package/dist/wallet/adapters/wallet-asset-catalog.service.js.map +1 -0
  133. package/dist/wallet/admin-reporting.d.ts +2 -2
  134. package/dist/wallet/contract/index.d.ts +428 -4
  135. package/dist/wallet/contract/index.d.ts.map +1 -1
  136. package/dist/wallet/contract/index.js +203 -5
  137. package/dist/wallet/contract/index.js.map +1 -1
  138. package/dist/wallet/drizzle/migrations/0010_worthless_quasimodo.sql +84 -0
  139. package/dist/wallet/drizzle/migrations/meta/0010_snapshot.json +1565 -0
  140. package/dist/wallet/drizzle/migrations/meta/_journal.json +7 -0
  141. package/dist/wallet/plugin.d.ts +18 -1
  142. package/dist/wallet/plugin.d.ts.map +1 -1
  143. package/dist/wallet/plugin.js +19 -2
  144. package/dist/wallet/plugin.js.map +1 -1
  145. package/dist/wallet/router/index.d.ts +235 -3
  146. package/dist/wallet/router/index.d.ts.map +1 -1
  147. package/dist/wallet/router/index.js +117 -20
  148. package/dist/wallet/router/index.js.map +1 -1
  149. package/dist/wallet/schema/index.d.ts +984 -3
  150. package/dist/wallet/schema/index.d.ts.map +1 -1
  151. package/dist/wallet/schema/index.js +141 -2
  152. package/dist/wallet/schema/index.js.map +1 -1
  153. package/dist/wallet/service/wallet.service.d.ts +70 -6
  154. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  155. package/dist/wallet/service/wallet.service.js +379 -16
  156. package/dist/wallet/service/wallet.service.js.map +1 -1
  157. package/package.json +1 -1
@@ -1,24 +1,35 @@
1
- import { makeNotFoundError, makeConflictError, createDomainError, findOneOrThrow, pageToOffset, withAdvisoryXactLock, assertRateLimit, createLogger, moneyToNumber, moneyEquals, } from '@openora/core/server';
2
- import { normalizeKycStatus, } from '@openora/core/contracts';
1
+ import { makeNotFoundError, serializeRow, makeConflictError, createDomainError, findOneOrThrow, pageToOffset, withAdvisoryXactLock, assertRateLimit, createLogger, moneyToNumber, moneyEquals, moneyAdd, moneyCompare, moneySubtract, } from '@openora/core/server';
2
+ import { normalizeKycStatus, DEFAULT_PAYMENT_PROVIDER, } from '@openora/core/contracts';
3
3
  import { eq, asc, desc, sql, and, gte, lte, count, inArray, isNull, or } from 'drizzle-orm';
4
4
  import { createHash } from 'node:crypto';
5
- import { wallet, walletBalance, walletTransaction, autoWithdrawalRule, walletAutoWithdrawalConfig, walletDepositAddress, } from '../schema/index.js';
5
+ import { wallet, walletBalance, walletTransaction, autoWithdrawalRule, walletAutoWithdrawalConfig, walletDepositAddress, walletAsset, } from '../schema/index.js';
6
6
  const logger = createLogger('wallet');
7
7
  export const WalletNotFoundError = makeNotFoundError('Wallet');
8
8
  export const WithdrawalNotFoundError = makeNotFoundError('Withdrawal');
9
9
  export const AutoWithdrawalConfigNotFoundError = makeNotFoundError('AutoWithdrawalConfig');
10
+ export const PlayerNotFoundError = makeNotFoundError('Player');
10
11
  export const InsufficientBalanceError = createDomainError('InsufficientBalanceError', (available, requested) => `Insufficient balance: available ${available}, requested ${requested}`);
11
12
  export const WithdrawalNotPendingError = makeConflictError('WithdrawalNotPendingError', 'Withdrawal is not pending and cannot be reviewed');
12
13
  export const KycRequiredError = makeConflictError('KycRequiredError', 'KYC verification required before withdrawal');
13
14
  export const IdempotencyKeyReuseError = makeConflictError('IdempotencyKeyReuseError', 'Idempotency key was already used with a different amount');
14
15
  export const DepositAddressUnsupportedError = makeConflictError('DepositAddressUnsupportedError', 'The bound payment adapter does not support address-based deposits');
15
16
  export const DestinationAddressRequiredError = makeConflictError('DestinationAddressRequiredError', 'A destination address is required for a crypto-rail withdrawal');
17
+ export const WalletAssetNotFoundError = makeNotFoundError('WalletAsset');
18
+ export const WalletAssetAlreadyExistsError = makeConflictError('WalletAssetAlreadyExistsError', 'An asset is already configured for this currency and network');
19
+ export const WalletAssetUnsupportedError = makeConflictError('WalletAssetUnsupportedError', 'The bound payment adapter cannot serve this currency and network');
20
+ export const WalletAssetInUseError = makeConflictError('WalletAssetInUseError', 'Players still hold a balance in this currency');
21
+ export const WalletAssetUnknownProviderError = makeConflictError('WalletAssetUnknownProviderError', 'providerName is not a registered payment provider');
22
+ export const WalletAssetHasInFlightTransactionsError = makeConflictError('WalletAssetHasInFlightTransactionsError', 'A pending or processing transaction exists for this currency and network');
23
+ export const AmbiguousNetworkError = createDomainError('AmbiguousNetworkError', (currency, networks) => `Currency ${currency} settles on several networks (${networks}) - a network is required`);
24
+ export const UnsupportedNetworkError = createDomainError('UnsupportedNetworkError', (currency, network) => `Currency ${currency} is not settled on network ${network}`);
25
+ export const WithdrawalDisabledError = createDomainError('WithdrawalDisabledError', (currency) => `Withdrawals are disabled for ${currency} on every configured network`);
26
+ export const BelowMinimumWithdrawalError = createDomainError('BelowMinimumWithdrawalError', (amount, minimum, currency, network) => `Withdrawal of ${amount} ${currency} on ${network} is below the ${minimum} ${currency} minimum`);
16
27
  const KYC_PASS_STATUSES = new Set(['approved', 'manually_overridden']);
17
28
  export const CurrencyMismatchError = createDomainError('CurrencyMismatchError', (requested, walletCurrency) => `Currency mismatch: requested ${requested}, wallet holds ${walletCurrency}`);
18
29
  export const AmbiguousDepositAddressError = createDomainError('AmbiguousDepositAddressError', (address, network) => `Deposit address ${address} on ${network ?? 'an unknown network'} is issued to more than one user`);
19
- // Crypto currencies settle on the crypto rail (Fireblocks); everything else on the
20
- // fiat rail (a PSP). The concrete provider is recorded per transaction, not here.
21
- // Overridable per-operator via `platformConfig.wallet.cryptoCurrencies` - see `railFor`.
30
+ // Crypto currencies settle on the crypto rail through a custody/MPC vendor; everything
31
+ // else on the fiat rail (a PSP). The concrete provider is recorded per transaction, not
32
+ // here. Overridable per-operator via `platformConfig.wallet.cryptoCurrencies` - see `railFor`.
22
33
  const DEFAULT_CRYPTO_CURRENCIES = new Set(['BTC', 'ETH', 'USDT', 'USDC']);
23
34
  // Mirrors the `wallet.currency` column default: what a player without a wallet row
24
35
  // reads as their active currency before one is created on first deposit.
@@ -27,6 +38,49 @@ const DEFAULT_WALLET_CURRENCY = 'USD';
27
38
  // fraud (idempotency + the ledger guard cover correctness). An overlay rebinds
28
39
  // RATE_LIMITER to change the backend, not this policy.
29
40
  const WALLET_MUTATION_RATE_LIMIT = { limit: 30, windowMs: 60 * 1000 };
41
+ /**
42
+ * Pins the chain a payout settles on. With one payable network the choice is implied; with
43
+ * several an explicit network is mandatory, because picking one silently would send a
44
+ * player's USDT over a chain their receiving wallet may not support.
45
+ *
46
+ * `assets` is every catalog row for the currency, enabled or not, so the two empty cases
47
+ * stay distinguishable: no rows at all means the operator never configured this currency
48
+ * (a fiat PSP), and the caller's choice passes through unchecked; rows that are all
49
+ * withdrawal-disabled is a deliberate operator decision and fails closed.
50
+ */
51
+ export function resolveWithdrawalNetwork(assets, currency, network) {
52
+ if (assets.length === 0) {
53
+ return network?.toUpperCase() ?? null;
54
+ }
55
+ const payable = assets.filter((asset) => asset.withdrawalEnabled);
56
+ const [only, ...rest] = payable;
57
+ if (!only) {
58
+ throw new WithdrawalDisabledError(currency);
59
+ }
60
+ if (network === undefined) {
61
+ if (rest.length > 0) {
62
+ throw new AmbiguousNetworkError(currency, payable.map((asset) => asset.network).join(', '));
63
+ }
64
+ return only.network.toUpperCase();
65
+ }
66
+ const wanted = network.toUpperCase();
67
+ if (!payable.some((asset) => asset.network.toUpperCase() === wanted)) {
68
+ throw new UnsupportedNetworkError(currency, wanted);
69
+ }
70
+ return wanted;
71
+ }
72
+ /**
73
+ * Rejects a payout worth less than the operator's per-network floor. The floor is per chain,
74
+ * not per currency: moving USDT costs cents on BEP20 and dollars on ERC20, so one
75
+ * currency-wide minimum is either too high for the cheap chain or below the fee on the
76
+ * expensive one.
77
+ */
78
+ export function assertAboveMinimumWithdrawal(assets, amount, currency, network) {
79
+ const asset = assets.find((candidate) => candidate.withdrawalEnabled && candidate.network.toUpperCase() === network);
80
+ if (asset && moneyCompare(amount, asset.minWithdrawal) < 0) {
81
+ throw new BelowMinimumWithdrawalError(amount, asset.minWithdrawal, currency, asset.network);
82
+ }
83
+ }
30
84
  export function railFor(currency, cryptoCurrencies) {
31
85
  const set = cryptoCurrencies
32
86
  ? new Set(cryptoCurrencies.map((c) => c.toUpperCase()))
@@ -69,6 +123,7 @@ export async function readWalletBalance(txn, walletId, currency) {
69
123
  // prefix won't parse - hash namespace + key into a stable pseudo-uuid instead.
70
124
  const DEPOSIT_IDEMPOTENCY_NAMESPACE = 'deposit';
71
125
  const WITHDRAW_IDEMPOTENCY_NAMESPACE = 'withdraw';
126
+ const MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE = 'manual-adjustment';
72
127
  function namespacedIdempotencyKey(namespace, rawKey) {
73
128
  const hex = createHash('sha256').update(`${namespace}:${rawKey}`).digest('hex');
74
129
  return [
@@ -119,10 +174,17 @@ function toAutoWithdrawalConfigDto(row) {
119
174
  createdAt: row.createdAt.toISOString(),
120
175
  };
121
176
  }
122
- // The concrete settlement provider recorded per transaction: the crypto rail settles
123
- // through Fireblocks, the fiat rail through a PSP.
124
- function providerNameFor(rail) {
125
- return rail === 'crypto' ? 'fireblocks' : 'psp';
177
+ const PUBLIC_ASSET_COLUMNS = {
178
+ currency: walletAsset.currency,
179
+ network: walletAsset.network,
180
+ minDeposit: walletAsset.minDeposit,
181
+ minWithdrawal: walletAsset.minWithdrawal,
182
+ withdrawalFee: walletAsset.withdrawalFee,
183
+ depositEnabled: walletAsset.depositEnabled,
184
+ withdrawalEnabled: walletAsset.withdrawalEnabled,
185
+ };
186
+ function toWalletAssetDto(row) {
187
+ return serializeRow(row, { dateFields: ['createdAt', 'updatedAt'], decimalFields: [] });
126
188
  }
127
189
  /**
128
190
  * Owns all wallet balance mutations and the withdrawal review/auto-approval
@@ -136,6 +198,7 @@ export class WalletService {
136
198
  drizzle;
137
199
  events;
138
200
  payment;
201
+ paymentProviders;
139
202
  identityReader;
140
203
  directory;
141
204
  platformConfig;
@@ -143,10 +206,11 @@ export class WalletService {
143
206
  riskTags;
144
207
  tagEvaluationCommands;
145
208
  audit;
146
- constructor({ drizzle, events, payment, directory, identityReader, platformConfig, limiter, riskTags, tagEvaluationCommands, audit, }) {
209
+ constructor({ drizzle, events, payment, paymentProviders, directory, identityReader, platformConfig, limiter, riskTags, tagEvaluationCommands, audit, }) {
147
210
  this.drizzle = drizzle;
148
211
  this.events = events;
149
212
  this.payment = payment;
213
+ this.paymentProviders = paymentProviders;
150
214
  this.directory = directory;
151
215
  this.identityReader = identityReader;
152
216
  this.platformConfig = platformConfig;
@@ -155,9 +219,36 @@ export class WalletService {
155
219
  this.tagEvaluationCommands = tagEvaluationCommands;
156
220
  this.audit = audit;
157
221
  }
222
+ // Every catalog row for the currency, enabled or not - resolveWithdrawalNetwork needs
223
+ // both to tell "never configured" from "deliberately disabled".
224
+ assetsForCurrency(currency) {
225
+ return this.drizzle.db
226
+ .select({
227
+ network: walletAsset.network,
228
+ minWithdrawal: walletAsset.minWithdrawal,
229
+ withdrawalEnabled: walletAsset.withdrawalEnabled,
230
+ })
231
+ .from(walletAsset)
232
+ .where(eq(walletAsset.currency, currency.toUpperCase()));
233
+ }
158
234
  resolveRail(currency) {
159
235
  return railFor(currency, this.platformConfig?.wallet?.cryptoCurrencies);
160
236
  }
237
+ // The concrete vendor a transaction/deposit-address settles through: the catalog row
238
+ // for the exact (currency, network) pair names the bound provider. Reconciliation
239
+ // scopes its diff by this column, so a null network (an unconfigured pair - no
240
+ // catalog row could ever match it) and a configured-but-null providerName column both
241
+ // resolve to the single default binding rather than leaving the column null.
242
+ async providerNameFor(currency, network) {
243
+ if (network === null) {
244
+ return DEFAULT_PAYMENT_PROVIDER;
245
+ }
246
+ const [row] = await this.drizzle.db
247
+ .select({ providerName: walletAsset.providerName })
248
+ .from(walletAsset)
249
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)));
250
+ return row?.providerName ?? DEFAULT_PAYMENT_PROVIDER;
251
+ }
161
252
  rateLimit(userId) {
162
253
  return this.limiter
163
254
  ? assertRateLimit(this.limiter, `wallet-mutation:${userId}`, WALLET_MUTATION_RATE_LIMIT)
@@ -281,6 +372,117 @@ export class WalletService {
281
372
  }
282
373
  return { transactionId, status: 'completed' };
283
374
  }
375
+ async manualAdjust({ adminId, userId, direction, amount, currency, reason, idempotencyKey, ip, userAgent, }) {
376
+ const playerId = await this.identityReader.getPlayerIdByUserId(userId);
377
+ if (!playerId) {
378
+ throw new PlayerNotFoundError(userId);
379
+ }
380
+ const { result, emitted } = await this.drizzle.db.transaction(async (txn) => {
381
+ let walletRecord = (await txn.select().from(wallet).where(eq(wallet.userId, userId)).for('update')).at(0);
382
+ if (!walletRecord) {
383
+ if (direction === 'debit') {
384
+ throw new InsufficientBalanceError('0', amount);
385
+ }
386
+ const [created] = await txn
387
+ .insert(wallet)
388
+ .values({ userId, currency })
389
+ .onConflictDoNothing()
390
+ .returning();
391
+ walletRecord =
392
+ created ??
393
+ findOneOrThrow(await txn.select().from(wallet).where(eq(wallet.userId, userId)).for('update'), new WalletNotFoundError(userId));
394
+ }
395
+ const existing = await this.findByIdempotencyKey(txn, walletRecord.id, namespacedIdempotencyKey(MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE, idempotencyKey));
396
+ if (existing) {
397
+ this.assertManualAdjustmentReplayMatches(existing, {
398
+ adminId,
399
+ direction,
400
+ amount,
401
+ currency,
402
+ reason,
403
+ });
404
+ return { result: { transactionId: existing.id, status: existing.status }, emitted: false };
405
+ }
406
+ const { row, replayed } = await this.insertIdempotentTransaction(txn, {
407
+ namespace: MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE,
408
+ walletId: walletRecord.id,
409
+ rawIdempotencyKey: idempotencyKey,
410
+ amount,
411
+ currency,
412
+ values: {
413
+ walletId: walletRecord.id,
414
+ type: direction === 'credit' ? 'manual_credit' : 'manual_debit',
415
+ amount,
416
+ currency,
417
+ status: 'completed',
418
+ reviewedBy: adminId,
419
+ reviewedAt: new Date(),
420
+ reviewReason: reason,
421
+ },
422
+ });
423
+ if (replayed) {
424
+ this.assertManualAdjustmentReplayMatches(row, {
425
+ adminId,
426
+ direction,
427
+ amount,
428
+ currency,
429
+ reason,
430
+ });
431
+ return { result: { transactionId: row.id, status: row.status }, emitted: false };
432
+ }
433
+ const balances = direction === 'credit'
434
+ ? await creditWalletBalance(txn, walletRecord.id, currency, amount)
435
+ : await debitWalletBalance(txn, walletRecord.id, currency, amount);
436
+ const [balance] = balances;
437
+ if (!balance) {
438
+ throw new InsufficientBalanceError(await readWalletBalance(txn, walletRecord.id, currency), amount);
439
+ }
440
+ // Derived from the row the update returned, never read separately beforehand. The
441
+ // wallet row is locked here but the deposit credit path does not take that lock, so
442
+ // a deposit can commit between a pre-read and this update - and `before` is going
443
+ // into an append-only audit record that cannot be corrected later.
444
+ const balanceBefore = direction === 'credit'
445
+ ? moneySubtract(balance.amount, amount)
446
+ : moneyAdd(balance.amount, amount);
447
+ await this.audit.recordInTransaction(txn, {
448
+ actorId: adminId,
449
+ actorType: 'admin',
450
+ action: 'wallet.manual_adjustment.created',
451
+ resourceType: 'wallet_transaction',
452
+ resourceId: row.id,
453
+ before: { balance: balanceBefore, currency },
454
+ after: {
455
+ balance: balance.amount,
456
+ currency,
457
+ transactionId: row.id,
458
+ direction,
459
+ amount,
460
+ reason,
461
+ },
462
+ ip,
463
+ userAgent,
464
+ });
465
+ return { result: { transactionId: row.id, status: row.status }, emitted: true };
466
+ });
467
+ // Every other balance-mutating path on this service emits; without this one an admin
468
+ // correction moves a real balance while responsible-gaming monitoring, analytics and
469
+ // any operator subscriber see nothing at all.
470
+ if (emitted) {
471
+ this.events.emit('wallet.manual_adjustment.created', {
472
+ userId,
473
+ playerId,
474
+ adminId,
475
+ amount,
476
+ currency,
477
+ transactionId: result.transactionId,
478
+ direction,
479
+ reason,
480
+ ip,
481
+ userAgent,
482
+ });
483
+ }
484
+ return result;
485
+ }
284
486
  // Pre-PSP replay check for deposit; also returns the resolved wallet so the transaction
285
487
  // below can skip re-selecting it. Throws IdempotencyKeyReuseError on an amount/currency mismatch.
286
488
  async findDepositReplay({ userId, idempotencyKey, amount, currency, }) {
@@ -307,6 +509,14 @@ export class WalletService {
307
509
  throw new IdempotencyKeyReuseError();
308
510
  }
309
511
  }
512
+ assertManualAdjustmentReplayMatches(existing, { adminId, direction, amount, currency, reason, }) {
513
+ if (existing.type !== (direction === 'credit' ? 'manual_credit' : 'manual_debit') ||
514
+ existing.reviewedBy !== adminId ||
515
+ existing.reviewReason !== reason) {
516
+ throw new IdempotencyKeyReuseError();
517
+ }
518
+ this.assertReplayMatches(existing, amount, currency);
519
+ }
310
520
  // Two concurrent requests with the same key can both pass the pre-insert check and race
311
521
  // on the unique index; onConflictDoNothing makes the loser's insert a no-op so it re-reads
312
522
  // the winner's committed row and returns it as a replay instead of aborting the transaction.
@@ -345,12 +555,15 @@ export class WalletService {
345
555
  * return an already-`completed`/`processing` result if the request
346
556
  * qualifies for (and passes) auto-approval - see `maybeAutoApprove`.
347
557
  */
348
- async withdraw({ userId, amount, currency, idempotencyKey, destinationAddress, ip, userAgent, }) {
558
+ async withdraw({ userId, amount, currency, network, idempotencyKey, destinationAddress, ip, userAgent, }) {
349
559
  await this.rateLimit(userId);
350
560
  await this.assertKycForWithdrawal(userId);
351
561
  if (this.resolveRail(currency) === 'crypto' && !destinationAddress) {
352
562
  throw new DestinationAddressRequiredError();
353
563
  }
564
+ const assets = await this.assetsForCurrency(currency);
565
+ const settlementNetwork = resolveWithdrawalNetwork(assets, currency, network);
566
+ assertAboveMinimumWithdrawal(assets, amount, currency, settlementNetwork);
354
567
  const { transactionId, status, replayed, walletId, rail } = await this.drizzle.db.transaction(async (txn) => {
355
568
  // FOR UPDATE serializes concurrent withdrawals against a double-debit TOCTOU under READ COMMITTED.
356
569
  const current = findOneOrThrow(await txn.select().from(wallet).where(eq(wallet.userId, userId)).for('update'), new WalletNotFoundError(userId));
@@ -381,6 +594,7 @@ export class WalletService {
381
594
  currency,
382
595
  status: 'pending',
383
596
  rail: this.resolveRail(currency),
597
+ network: settlementNetwork,
384
598
  destinationAddress: destinationAddress ?? null,
385
599
  },
386
600
  });
@@ -513,6 +727,7 @@ export class WalletService {
513
727
  username: summary?.username ?? '',
514
728
  amount: r.tx.amount,
515
729
  currency: r.tx.currency,
730
+ network: r.tx.network,
516
731
  rail: r.tx.rail ?? null,
517
732
  status: r.tx.status,
518
733
  kycStatus: summary?.kycStatus ?? null,
@@ -584,6 +799,8 @@ export class WalletService {
584
799
  rail: tx.rail,
585
800
  adminId,
586
801
  destinationAddress: tx.destinationAddress,
802
+ // Pinned at request time; without it an adapter cannot tell ERC20 USDT from TRC20.
803
+ network: tx.network,
587
804
  });
588
805
  }
589
806
  catch (err) {
@@ -598,7 +815,10 @@ export class WalletService {
598
815
  if (result.status !== 'completed') {
599
816
  await this.drizzle.db
600
817
  .update(walletTransaction)
601
- .set({ providerName: providerNameFor(tx.rail), providerRefId: result.externalId })
818
+ .set({
819
+ providerName: await this.providerNameFor(tx.currency, tx.network),
820
+ providerRefId: result.externalId,
821
+ })
602
822
  .where(eq(walletTransaction.id, tx.id));
603
823
  return { transactionId: tx.id, status: 'processing' };
604
824
  }
@@ -606,7 +826,7 @@ export class WalletService {
606
826
  .update(walletTransaction)
607
827
  .set({
608
828
  status: 'completed',
609
- providerName: providerNameFor(tx.rail),
829
+ providerName: await this.providerNameFor(tx.currency, tx.network),
610
830
  providerRefId: result.externalId,
611
831
  })
612
832
  .where(eq(walletTransaction.id, tx.id));
@@ -878,6 +1098,142 @@ export class WalletService {
878
1098
  return config;
879
1099
  });
880
1100
  }
1101
+ async listWalletAssets() {
1102
+ const rows = await this.drizzle.db
1103
+ .select()
1104
+ .from(walletAsset)
1105
+ .orderBy(asc(walletAsset.currency), asc(walletAsset.network));
1106
+ return rows.map(toWalletAssetDto);
1107
+ }
1108
+ listEnabledWalletAssets() {
1109
+ return this.drizzle.db
1110
+ .select(PUBLIC_ASSET_COLUMNS)
1111
+ .from(walletAsset)
1112
+ .where(or(eq(walletAsset.depositEnabled, true), eq(walletAsset.withdrawalEnabled, true)))
1113
+ .orderBy(asc(walletAsset.currency), asc(walletAsset.network));
1114
+ }
1115
+ async getWalletAsset(currency, network) {
1116
+ const [row] = await this.drizzle.db
1117
+ .select()
1118
+ .from(walletAsset)
1119
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)));
1120
+ return row ? toWalletAssetDto(row) : null;
1121
+ }
1122
+ // The catalog is operator-editable, so an admin can name a pair the bound vendor has
1123
+ // never heard of. Reject at write time rather than at a player's deposit request.
1124
+ assertAdapterSupports(currency, network) {
1125
+ if (this.payment.supportsAsset && !this.payment.supportsAsset(currency, network)) {
1126
+ throw new WalletAssetUnsupportedError();
1127
+ }
1128
+ }
1129
+ // Same discipline as assertAdapterSupports, for the provider key itself: an
1130
+ // unvalidated typo silently falls back to the default adapter, which means eg a
1131
+ // crypto payout attempted through a PSP. Undefined (the default binding) always passes.
1132
+ assertProviderNameValid(providerName) {
1133
+ if (providerName !== undefined && !this.paymentProviders.names().includes(providerName)) {
1134
+ throw new WalletAssetUnknownProviderError();
1135
+ }
1136
+ }
1137
+ async createWalletAsset(adminId, input, meta) {
1138
+ this.assertAdapterSupports(input.currency, input.network);
1139
+ this.assertProviderNameValid(input.providerName);
1140
+ return this.drizzle.db.transaction(async (txn) => {
1141
+ const rows = await txn.insert(walletAsset).values(input).onConflictDoNothing().returning();
1142
+ // Empty => the (currency, network) unique index rejected it.
1143
+ const asset = toWalletAssetDto(findOneOrThrow(rows, new WalletAssetAlreadyExistsError()));
1144
+ await this.audit.recordInTransaction(txn, {
1145
+ actorId: adminId,
1146
+ actorType: 'admin',
1147
+ action: 'wallet.wallet_asset.created',
1148
+ resourceType: 'wallet_asset',
1149
+ resourceId: asset.id,
1150
+ before: null,
1151
+ after: asset,
1152
+ ...meta,
1153
+ });
1154
+ return asset;
1155
+ });
1156
+ }
1157
+ // Deliberately touches only the catalog row: a withdrawal already pending or processing
1158
+ // in this currency keeps its own terms and is never cancelled by disabling the pair.
1159
+ async updateWalletAsset(adminId, { currency, network, ...changes }, meta) {
1160
+ if (changes.providerAssetId !== undefined) {
1161
+ this.assertAdapterSupports(currency, network);
1162
+ }
1163
+ return this.drizzle.db.transaction(async (txn) => {
1164
+ const [before] = await txn
1165
+ .select()
1166
+ .from(walletAsset)
1167
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)));
1168
+ if (!before) {
1169
+ throw new WalletAssetNotFoundError(`${currency}/${network}`);
1170
+ }
1171
+ const rows = await txn
1172
+ .update(walletAsset)
1173
+ .set(changes)
1174
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)))
1175
+ .returning();
1176
+ const asset = toWalletAssetDto(findOneOrThrow(rows, new WalletAssetNotFoundError(`${currency}/${network}`)));
1177
+ await this.audit.recordInTransaction(txn, {
1178
+ actorId: adminId,
1179
+ actorType: 'admin',
1180
+ action: 'wallet.wallet_asset.updated',
1181
+ resourceType: 'wallet_asset',
1182
+ resourceId: asset.id,
1183
+ before: toWalletAssetDto(before),
1184
+ after: asset,
1185
+ ...meta,
1186
+ });
1187
+ return asset;
1188
+ });
1189
+ }
1190
+ async deleteWalletAsset(adminId, currency, network, meta) {
1191
+ return this.drizzle.db.transaction(async (txn) => {
1192
+ const [before] = await txn
1193
+ .select()
1194
+ .from(walletAsset)
1195
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)));
1196
+ if (!before) {
1197
+ return false;
1198
+ }
1199
+ // wallet_balance is keyed by currency only (no network column), so this guard is
1200
+ // necessarily currency-wide: removing one network of a currency players still hold
1201
+ // is blocked even if their balance arrived over another network. Fails safe.
1202
+ const [held] = await txn
1203
+ .select({ n: count() })
1204
+ .from(walletBalance)
1205
+ .where(and(eq(walletBalance.currency, currency), sql `${walletBalance.amount} > 0`));
1206
+ if ((held?.n ?? 0) > 0) {
1207
+ throw new WalletAssetInUseError();
1208
+ }
1209
+ // Renaming a pair is a delete plus a create (the (currency, network) key AND
1210
+ // providerName are immutable), so this delete is the only way providerName ever
1211
+ // effectively changes. Block it while a pending/processing transaction exists for
1212
+ // this exact (currency, network) pair - otherwise the vendor reference an in-flight
1213
+ // payout is settling through gets rewritten out from under it.
1214
+ const [inFlight] = await txn
1215
+ .select({ n: count() })
1216
+ .from(walletTransaction)
1217
+ .where(and(eq(walletTransaction.currency, currency), eq(walletTransaction.network, network), inArray(walletTransaction.status, ['pending', 'processing'])));
1218
+ if ((inFlight?.n ?? 0) > 0) {
1219
+ throw new WalletAssetHasInFlightTransactionsError();
1220
+ }
1221
+ await txn
1222
+ .delete(walletAsset)
1223
+ .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network)));
1224
+ await this.audit.recordInTransaction(txn, {
1225
+ actorId: adminId,
1226
+ actorType: 'admin',
1227
+ action: 'wallet.wallet_asset.deleted',
1228
+ resourceType: 'wallet_asset',
1229
+ resourceId: before.id,
1230
+ before: toWalletAssetDto(before),
1231
+ after: null,
1232
+ ...meta,
1233
+ });
1234
+ return true;
1235
+ });
1236
+ }
881
1237
  async autoApprovalKycStatus(userId) {
882
1238
  // No directory bound => cannot verify KYC => fail closed.
883
1239
  if (!this.directory) {
@@ -1001,7 +1357,7 @@ export class WalletService {
1001
1357
  });
1002
1358
  return { transactionId: tx.id, status: 'rejected' };
1003
1359
  }
1004
- async getTransactions({ userId, page, limit, sortBy, sortOrder, }) {
1360
+ async getTransactions({ userId, page, limit, sortBy, sortOrder, includeInternal = false, }) {
1005
1361
  const db = this.drizzle.db;
1006
1362
  const [walletRecord] = await db.select().from(wallet).where(eq(wallet.userId, userId));
1007
1363
  if (!walletRecord) {
@@ -1035,8 +1391,12 @@ export class WalletService {
1035
1391
  type: tx.type,
1036
1392
  amount: tx.amount,
1037
1393
  currency: tx.currency,
1394
+ network: tx.network,
1038
1395
  status: tx.status,
1039
1396
  createdAt: tx.createdAt.toISOString(),
1397
+ reviewedBy: includeInternal ? tx.reviewedBy : null,
1398
+ reviewedAt: includeInternal ? (tx.reviewedAt?.toISOString() ?? null) : null,
1399
+ reviewReason: includeInternal ? tx.reviewReason : null,
1040
1400
  })),
1041
1401
  total: Number(n),
1042
1402
  page,
@@ -1060,7 +1420,7 @@ export class WalletService {
1060
1420
  network: network ?? null,
1061
1421
  address: issued.address,
1062
1422
  tag: issued.tag ?? null,
1063
- providerName: providerNameFor(this.resolveRail(currency)),
1423
+ providerName: await this.providerNameFor(currency, network ?? null),
1064
1424
  })
1065
1425
  .onConflictDoNothing()
1066
1426
  .returning();
@@ -1103,6 +1463,9 @@ export class WalletService {
1103
1463
  currency: event.currency,
1104
1464
  status: 'completed',
1105
1465
  rail: this.resolveRail(event.currency),
1466
+ // Prefer the chain the vendor reported; fall back to the one the address was
1467
+ // issued on, which is the only network an address-only webhook can imply.
1468
+ network: event.network ?? depositAddress.network,
1106
1469
  providerName: depositAddress.providerName,
1107
1470
  providerRefId: event.externalId,
1108
1471
  destinationAddress: event.address,