Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
"tables": [
"auto_withdrawal_rule",
"wallet",
"wallet_auto_withdrawal_config",
"wallet_deposit_address",
"wallet_transaction"
],
Expand All @@ -300,13 +301,15 @@
"wallet.delete",
"wallet.deposit",
"wallet.get",
"wallet.get",
"wallet.getAddress",
"wallet.getBalance",
"wallet.list",
"wallet.listPlayerTransactions",
"wallet.listTransactions",
"wallet.reject",
"wallet.set",
"wallet.set",
"wallet.webhook",
"wallet.withdraw"
]
Expand Down Expand Up @@ -1455,6 +1458,10 @@
"name": "SetPlayerLimitInputSchema",
"file": "packages/core/src/compliance/contract/rg.ts"
},
{
"name": "SetWalletAutoWithdrawalConfigInputSchema",
"file": "packages/core/src/wallet/contract/index.ts"
},
{
"name": "SignedMoneyAmountSchema",
"file": "packages/core/src/contracts/schemas/common.ts"
Expand Down Expand Up @@ -1563,6 +1570,10 @@
"name": "VerifyPasswordResetOtpInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "WalletAutoWithdrawalConfigSchema",
"file": "packages/core/src/wallet/contract/index.ts"
},
{
"name": "WalletBalanceSchema",
"file": "packages/core/src/wallet/contract/index.ts"
Expand Down Expand Up @@ -1715,6 +1726,7 @@
"GET /players/{playerId}",
"GET /profile",
"GET /tag-rule",
"GET /wallet/auto-withdrawal-config",
"GET /wallet/auto-withdrawal-rules/{userId}",
"GET /wallet/balance",
"GET /wallet/transactions",
Expand Down Expand Up @@ -1790,6 +1802,7 @@
"PUT /compliance/players/{userId}/limits",
"PUT /iam/roles/{roleId}/permissions",
"PUT /tag-rule/{tagKey}",
"PUT /wallet/auto-withdrawal-config",
"PUT /wallet/auto-withdrawal-rules/{userId}"
]
}
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@
"import": "./dist/wallet/contract/index.js",
"default": "./dist/wallet/contract/index.js"
},
"./wallet/seed": {
"types": "./dist/wallet/seed/index.d.ts",
"import": "./dist/wallet/seed/index.js",
"default": "./dist/wallet/seed/index.js"
},
"./audit/migrate": {
"types": "./dist/audit/migrate.d.ts",
"import": "./dist/audit/migrate.js",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/contracts/adapters/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export type DirectAuditAction =
| 'compliance.kyc.bulk_approve'
| 'wallet.withdrawal.auto_approved'
| 'wallet.auto_withdrawal_rule.set'
| 'wallet.auto_withdrawal_rule.deleted';
| 'wallet.auto_withdrawal_rule.deleted'
| 'wallet.auto_withdrawal_config.set';

// Every value the audit `action` column legitimately holds: a cross-module domain
// event topic (recorded by the audit plugin's subscriptions) or a direct admin
Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/contracts/schemas/platform-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ export const KycConfigSchema = z
export const AutoWithdrawalConfigSchema = z
.object({
enabled: z.boolean().default(false),
/** Fiat-rail auto-approval ceiling; a per-player rule overrides it. */
fiatThreshold: MoneyAmountSchema.optional(),
cryptoThreshold: MoneyAmountSchema.optional(),
/** Active tag keys that veto auto-approval and force manual review. */
excludeRiskFlags: z.array(TagKeySchema).default([]),
/** Amount cap on auto-approved payouts per player per trailing 24h. Absent = no cap. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/server/auth/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const statement = {
'tag-rule': ['view', 'update'] as const,
tag: ['view', 'create', 'delete'] as const,
'chat-room': ['view', 'create', 'update', 'delete'] as const,
'auto-withdrawal-config': ['view', 'update'] as const,
} as const;

export const ac = createAccessControl(statement);
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/wallet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ Queue `riskTags` are DB-backed heuristics, not a risk engine: `large_amount` (>=

After the hold commits, `maybeAutoApprove` decides WHO approves - system or manual queue - never whether the request succeeds. Strictly fail-closed: it NEVER throws out of `withdraw()`; any error or ambiguous branch leaves the row `pending`.

- Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `fiatThreshold`/`cryptoThreshold`) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting `excludeRiskFlags` (via `PLAYER_TAGS`; port unbound while flags are configured => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded.
- Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `wallet_auto_withdrawal_config` singleton row - DB-backed, Super-Admin-editable at runtime via `autoWithdrawalConfig.set`, no redeploy needed, BF-211) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting `excludeRiskFlags` (via `PLAYER_TAGS`; port unbound while flags are configured => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded.
- Only the daily-cap check runs under the per-user advisory lock (atomic with the `processing` flip); the other gates run before it.
- System-actor marker: `reviewedBy = null`, `reviewReason = 'auto-approved'`. Reuses `flipToProcessing`/`settleApproved` - the same two-phase sequence as manual approve.
- Every auto-approval writes an `AUDIT_WRITER` entry (`actorType: 'system'`) capturing the full rationale BEFORE the PSP call, so the AML/SAR trail survives a PSP failure.
- Crypto uses the SAME mechanism and gates as fiat (BF-211), safe because `cryptoThreshold` is absent by default - unset/zero resolves to "never auto-approve", so an upgrade never silently activates it. The per-player rule is a single global column, not rail-aware.
- Crypto uses the SAME mechanism and gates as fiat (BF-211), safe because `wallet_auto_withdrawal_config.cryptoThreshold` defaults to `'0'` via the seed (same for `fiatThreshold`) - zero resolves to "never auto-approve", so an upgrade never silently activates it. The per-player rule is a single global column, not rail-aware.
- `wallet_auto_withdrawal_config` is a true singleton (`singletonKey` DB-unique, always `'global'`), normally created by `seedAutoWithdrawalConfig()`. The READ path (`getAutoWithdrawalConfig()`, used by `resolveAutoThreshold`/`maybeAutoApprove`) throws `AutoWithdrawalConfigNotFoundError` if the row is somehow absent rather than silently defaulting - `maybeAutoApprove`'s existing outer try/catch treats that the same as any other unexpected error (fail closed to pending); a withdrawal must never silently create config. The admin WRITE path (`setAutoWithdrawalConfig`, `PUT /wallet/auto-withdrawal-config`) is an upsert (`onConflictDoUpdate` on `singletonKey`), so a Super Admin can self-heal a missing row on an unseeded install through the existing route with zero new surface; `GET`/`.getAutoWithdrawalConfig()` never creates it, only `.set`/`PUT` does.

## Idempotency and races

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { call, ORPCError } from '@orpc/server';
import type { AdminGuard } from '@openora/core/server';
import type {
AdminPlayerSummary,
AdminUserDirectory,
PaymentAdapter,
PaymentWebhookVerifier,
PlatformConfig,
} from '@openora/core/contracts';
import { createTestDb, type TestDb } from '@openora/core/testing';
import {
mock,
makeEventBus,
testContext,
makeAuditWriter,
makeAdminGuard,
NO_CLIENT_META,
} from '../../testing/mock.js';
import { migrate } from '../migrate.js';
import { wallet, walletTransaction, walletAutoWithdrawalConfig } from '../schema/index.js';
import { createWalletRouter } from '../router/index.js';
import { WalletService } from '../service/wallet.service.js';

const CTX = testContext();
const CALLER_ID = '9a2f7c11-0000-4000-8000-0000000000bb';

let db: TestDb;

beforeAll(async () => {
db = await createTestDb([migrate]);
});

afterAll(async () => {
await db.drop();
});

beforeEach(async () => {
await db.drizzle.db.delete(walletTransaction);
await db.drizzle.db.delete(wallet);
await db.drizzle.db.delete(walletAutoWithdrawalConfig);
await db.drizzle.db
.insert(walletAutoWithdrawalConfig)
.values({ singletonKey: 'global', fiatThreshold: '0', cryptoThreshold: '0' });
});

const superAdminGuard = () =>
makeAdminGuard({ caller: { userId: CALLER_ID, role: 'super-admin' } });

// The 'auto-withdrawal-config' resource is granted only to super-admin (see
// permissions.ts statement + default-admin-roles.ts) - both `admin` and
// `payments-manager` (which only has `withdrawal` RW, a different resource)
// must be denied here.
const adminDenyingGuard = () =>
makeAdminGuard({
deny: ['auto-withdrawal-config:view', 'auto-withdrawal-config:update'],
caller: { userId: CALLER_ID, role: 'admin' },
});

const paymentsManagerDenyingGuard = () =>
makeAdminGuard({
deny: ['auto-withdrawal-config:view', 'auto-withdrawal-config:update'],
caller: { userId: CALLER_ID, role: 'payments-manager' },
});

function routerWith(adminGuard: AdminGuard, platformConfig?: Partial<PlatformConfig>) {
const audit = makeAuditWriter();
const directory = mock<AdminUserDirectory>({
lookupPlayers: vi.fn(async (ids: string[]) =>
ids.map((userId) =>
mock<AdminPlayerSummary>({ userId, username: 'player', kycStatus: 'verified' }),
),
),
});
const service = new WalletService({
drizzle: db.drizzle,
events: makeEventBus(),
payment: mock<PaymentAdapter>({
processWithdrawal: vi.fn(async () => ({
externalId: randomUUID(),
status: 'completed' as const,
})),
}),
audit,
directory,
platformConfig: platformConfig ? mock<PlatformConfig>(platformConfig) : undefined,
});
const router = createWalletRouter(
service,
adminGuard,
audit,
mock<PaymentAdapter>({}),
mock<PaymentWebhookVerifier>({ verify: vi.fn().mockReturnValue(false) }),
);
return { router, audit, service };
}

async function seedPlayerWallet(overrides: Partial<typeof wallet.$inferInsert> = {}) {
const [row] = await db.drizzle.db
.insert(wallet)
.values({ userId: randomUUID(), balance: '100000', currency: 'USD', ...overrides })
.returning();
return row!;
}

describe('wallet auto-withdrawal-config routes', () => {
it('get: returns the seeded singleton row for an authorized (super-admin) caller', async () => {
const { router } = routerWith(superAdminGuard());

const result = await call(router.autoWithdrawalConfig.get, {}, { context: CTX });

expect(result).toMatchObject({ fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000' });
});

it('get: rejects payments-manager', async () => {
const { router } = routerWith(paymentsManagerDenyingGuard());

await expect(
call(router.autoWithdrawalConfig.get, {}, { context: CTX }),
).rejects.toBeInstanceOf(ORPCError);
});

it('get: rejects plain admin', async () => {
const { router } = routerWith(adminDenyingGuard());

await expect(
call(router.autoWithdrawalConfig.get, {}, { context: CTX }),
).rejects.toBeInstanceOf(ORPCError);
});

it('set: rejects payments-manager and writes nothing', async () => {
const { router, audit } = routerWith(paymentsManagerDenyingGuard());

await expect(
call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '500', cryptoThreshold: '1' },
{ context: CTX },
),
).rejects.toBeInstanceOf(ORPCError);
expect(audit.record).not.toHaveBeenCalled();
});

it('set: rejects plain admin and writes nothing', async () => {
const { router, audit } = routerWith(adminDenyingGuard());

await expect(
call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '500', cryptoThreshold: '1' },
{ context: CTX },
),
).rejects.toBeInstanceOf(ORPCError);
expect(audit.record).not.toHaveBeenCalled();
});

it('set: rejects a negative fiat threshold', async () => {
const { router } = routerWith(superAdminGuard());

await expect(
call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '-1', cryptoThreshold: '1' },
{ context: CTX },
),
).rejects.toThrow();
});

it('set: rejects a negative crypto threshold', async () => {
const { router } = routerWith(superAdminGuard());

await expect(
call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '1', cryptoThreshold: '-1' },
{ context: CTX },
),
).rejects.toThrow();
});

it('set: super-admin updates both thresholds, GET reflects immediately, and writes an admin audit entry with before/after', async () => {
const { router, audit } = routerWith(superAdminGuard());

const result = await call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '500', cryptoThreshold: '1' },
{ context: CTX },
);

expect(result).toMatchObject({
fiatThreshold: '500.00000000',
cryptoThreshold: '1.00000000',
updatedBy: CALLER_ID,
});
const fetched = await call(router.autoWithdrawalConfig.get, {}, { context: CTX });
expect(fetched).toMatchObject({
fiatThreshold: '500.00000000',
cryptoThreshold: '1.00000000',
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
actorId: CALLER_ID,
actorType: 'admin',
action: 'wallet.auto_withdrawal_config.set',
resourceType: 'auto_withdrawal_config',
before: { fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000' },
after: { fiatThreshold: '500.00000000', cryptoThreshold: '1.00000000' },
}),
);
});

it('end-to-end: after a super-admin sets the fiat threshold, a withdrawal below it auto-approves and one above it stays pending, both leaving an audit trail', async () => {
const { router, audit, service } = routerWith(superAdminGuard(), {
autoWithdrawal: { enabled: true, excludeRiskFlags: [] },
});

await call(
router.autoWithdrawalConfig.set,
{ fiatThreshold: '100', cryptoThreshold: '0' },
{ context: CTX },
);

const below = await seedPlayerWallet();
const belowResult = await service.withdraw({
userId: below.userId,
amount: '40',
currency: 'USD',
...NO_CLIENT_META,
});
expect(belowResult.status).toBe('completed');

const above = await seedPlayerWallet();
const aboveResult = await service.withdraw({
userId: above.userId,
amount: '400',
currency: 'USD',
...NO_CLIENT_META,
});
expect(aboveResult.status).toBe('pending');

expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'wallet.auto_withdrawal_config.set' }),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'wallet.withdrawal.auto_approved',
resourceId: belowResult.transactionId,
}),
);
});
});
Loading
Loading