diff --git a/docs/catalog.json b/docs/catalog.json index 17dfdb18..2a068ab8 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -380,6 +380,7 @@ "tables": [ "auto_withdrawal_rule", "wallet", + "wallet_asset", "wallet_auto_withdrawal_config", "wallet_balance", "wallet_deposit_address", @@ -388,6 +389,8 @@ ], "routes": [ "wallet.approve", + "wallet.create", + "wallet.delete", "wallet.delete", "wallet.deposit", "wallet.get", @@ -396,12 +399,15 @@ "wallet.getBalance", "wallet.getBalances", "wallet.list", + "wallet.list", + "wallet.listAssets", "wallet.listPlayerTransactions", "wallet.listTransactions", "wallet.reject", "wallet.set", "wallet.set", "wallet.setActiveCurrency", + "wallet.update", "wallet.webhook", "wallet.withdraw" ] @@ -802,6 +808,15 @@ "packages/core/src/wallet/plugin.ts" ] }, + { + "category": "wallet-asset-catalog", + "interface": "WalletAsset", + "token": "WALLET_ASSET_CATALOG", + "status": "wired", + "boundIn": [ + "packages/core/src/wallet/plugin.ts" + ] + }, { "category": "wallet-commands", "interface": "WalletDebitArgs", @@ -1221,6 +1236,10 @@ "name": "createTagSchema", "file": "packages/core/src/contracts/schemas/tag.ts" }, + { + "name": "CreateWalletAssetInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "CurrencyCodeSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" @@ -1765,6 +1784,10 @@ "name": "ProviderSelectionSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" }, + { + "name": "PublicWalletAssetSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "RainCommandMetadataSchema", "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" @@ -1973,6 +1996,10 @@ "name": "updateTagSchema", "file": "packages/core/src/contracts/schemas/tag.ts" }, + { + "name": "UpdateWalletAssetInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "UpsertLimitInputSchema", "file": "packages/core/src/compliance/contract/limits.ts" @@ -2009,6 +2036,14 @@ "name": "VerifyPasswordResetOtpInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "WalletAssetKeySchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, + { + "name": "WalletAssetSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "WalletAutoWithdrawalConfigSchema", "file": "packages/core/src/wallet/contract/index.ts" diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 71e9fc02..37b58a53 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -120,9 +120,17 @@ export { KycCheckResultSchema, } from './kyc.js'; -export type { PaymentAdapter, PaymentWebhookEvent, PaymentWebhookVerifier } from './payment.js'; +export type { + PaymentAdapter, + PaymentWebhookEvent, + PaymentWebhookVerifier, + CustodyBalance, +} from './payment.js'; export { PAYMENT_ADAPTER, PAYMENT_WEBHOOK_VERIFIER } from './payment.js'; +export type { WalletAsset, WalletAssetCatalog } from './wallet-asset-catalog.js'; +export { WALLET_ASSET_CATALOG } from './wallet-asset-catalog.js'; + export type { GeoIpAdapter } from './geo-ip.js'; export { GEO_IP_ADAPTER } from './geo-ip.js'; diff --git a/packages/core/src/contracts/adapters/payment.ts b/packages/core/src/contracts/adapters/payment.ts index 6afa6f1e..26fd6e31 100644 --- a/packages/core/src/contracts/adapters/payment.ts +++ b/packages/core/src/contracts/adapters/payment.ts @@ -36,6 +36,20 @@ export type PaymentWebhookEvent = txHash?: string; }; +/** + * A balance sitting in a per-player custody container that is not yet in the pooled + * account withdrawals are paid from. Produced by `PaymentAdapter.listSweepableBalances` + * and handed back to `sweepToPool` unchanged. + */ +export type CustodyBalance = { + userId: string; + currency: string; + network: string; + amount: string; + /** Current network cost to move it, same units as `amount`. */ + estimatedFee: string; +}; + export type PaymentAdapter = { processDeposit( amount: string, @@ -80,6 +94,49 @@ export type PaymentAdapter = { rawBody: string, headers: Record, ): PaymentWebhookEvent | null; + + /** + * Whether this adapter can actually serve the given asset. The asset catalog is + * operator-editable at runtime, so an admin can name a (currency, network) pair the + * bound vendor has never heard of; the catalog's write path calls this first and + * rejects rather than letting the pair reach a player's deposit screen. Optional - + * an adapter that omits it is assumed to accept anything the operator configures. + */ + supportsAsset?(currency: string, network: string): boolean; + + /** + * Per-player balances the vendor holds that are not yet in the pooled account. + * Only meaningful for a custody vendor whose per-player deposit containers are + * distinct from the account withdrawals are paid out of - a synchronous PSP, which + * never holds a per-player balance, omits it. + */ + listSweepableBalances?(): Promise; + + /** + * Move one player's balance into the pooled account. Implemented alongside + * `listSweepableBalances`; the caller owns the policy (dust floor, fee thresholds) + * and this only performs the transfer it is handed. + */ + sweepToPool?(balance: CustodyBalance): Promise<{ externalId: string }>; + + /** + * Vendor transactions in a window, normalized into the same events `parseWebhook` + * produces - reconciliation is that same normalization, polled instead of pushed. + * Implemented by a vendor whose ledger can be listed after the fact; a PSP that only + * pushes webhooks omits it. + */ + listTransactions?(range: { since: Date; until: Date }): Promise; + + /** + * Targeted status lookup for a single withdrawal, or null when the vendor has no + * record of it. Not redundant with `listTransactions`: a withdrawal stuck in + * `processing` for days falls outside any sane reconciliation window, so finalizing + * it needs a direct lookup by `externalId`. + */ + getWithdrawalStatus?(externalId: string): Promise<{ + status: 'processing' | 'completed' | 'failed'; + txHash?: string; + } | null>; }; export const PAYMENT_ADAPTER: Token = createToken('PAYMENT_ADAPTER'); diff --git a/packages/core/src/contracts/adapters/wallet-asset-catalog.ts b/packages/core/src/contracts/adapters/wallet-asset-catalog.ts new file mode 100644 index 00000000..fc79b22f --- /dev/null +++ b/packages/core/src/contracts/adapters/wallet-asset-catalog.ts @@ -0,0 +1,37 @@ +import { createToken } from './token.js'; + +/** + * One operator-configured (currency, network) pair the platform accepts deposits for or + * pays withdrawals out of. Rows are editable at runtime from the admin surface, so this + * is config rather than code: adding a currency is not a deploy. + */ +export type WalletAsset = { + currency: string; + network: string; + /** + * The bound payment vendor's own identifier for this asset (eg a custody vendor's + * `USDT_ERC20`). Opaque - the wallet module stores and returns it, never parses it, + * so a different vendor's identifier scheme needs no core change. + */ + providerAssetId: string; + minDeposit: string; + minWithdrawal: string; + withdrawalFee: string; + depositEnabled: boolean; + withdrawalEnabled: boolean; +}; + +/** + * Read side of the asset catalog, for an adapter that needs the operator's asset table + * without importing wallet internals. + * + * Unlike most ports here, the wallet module binds a DB-backed default implementation to + * this token itself (as with `CACHE`/`RATE_LIMITER`), so an operator gets a working + * catalog with no wiring; rebinding it is possible but not required. + */ +export type WalletAssetCatalog = { + list(): Promise; + get(currency: string, network: string): Promise; +}; + +export const WALLET_ASSET_CATALOG = createToken('WALLET_ASSET_CATALOG'); diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index ead86c93..df0c5921 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -21,6 +21,7 @@ export const statement = { tag: ['view', 'create', 'delete'] as const, 'chat-room': ['view', 'create', 'update', 'delete'] as const, 'auto-withdrawal-config': ['view', 'update'] as const, + 'wallet-asset': ['view', 'create', 'update', 'delete'] as const, 'chat-command': ['view', 'update'] as const, 'chat-moderation': ['view', 'moderate'] as const, } as const; @@ -48,6 +49,7 @@ export const adminRole = ac.newRole({ tag: ['view', 'create', 'delete'], 'chat-room': ['view', 'create', 'update', 'delete'], 'auto-withdrawal-config': ['view', 'update'], + 'wallet-asset': ['view', 'create', 'update', 'delete'], 'chat-command': ['view', 'update'], 'chat-moderation': ['view', 'moderate'], }); diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index 44640e2c..2e43a752 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -46,6 +46,7 @@ import { SMS_ADAPTER, SOCIAL_COMMANDS, TAG_EVALUATION_COMMANDS, + WALLET_ASSET_CATALOG, WALLET_COMMANDS, WALLET_READER, } from '@openora/core/contracts'; @@ -104,6 +105,7 @@ const coreTokenCatalog = { SMS_ADAPTER, SOCIAL_COMMANDS, TAG_EVALUATION_COMMANDS, + WALLET_ASSET_CATALOG, WALLET_COMMANDS, WALLET_READER, } satisfies TokenCatalog; diff --git a/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts new file mode 100644 index 00000000..4c10b598 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { findOneOrThrow } from '@openora/core/server'; +import { randomUUID } from 'node:crypto'; +import { call, ORPCError } from '@orpc/server'; +import type { AdminGuard } from '@openora/core/server'; +import type { PaymentAdapter, PaymentWebhookVerifier } from '@openora/core/contracts'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { + mock, + makeEventBus, + testContext, + makeAuditWriter, + makeAdminGuard, + makeIdentityReader, +} from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { wallet, walletBalance, walletTransaction, walletAsset } 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-0000000000cc'; + +const USDT_ERC20 = { + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '10', + minWithdrawal: '20', + withdrawalFee: '5', +} as const; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.delete(walletTransaction); + await db.drizzle.db.delete(walletBalance); + await db.drizzle.db.delete(wallet); + await db.drizzle.db.delete(walletAsset); +}); + +const adminGuard = () => makeAdminGuard({ caller: { userId: CALLER_ID, role: 'admin' } }); + +const denyingGuard = () => + makeAdminGuard({ + deny: [ + 'wallet-asset:view', + 'wallet-asset:create', + 'wallet-asset:update', + 'wallet-asset:delete', + ], + caller: { userId: CALLER_ID, role: 'support' }, + }); + +function routerWith(guard: AdminGuard, payment?: Partial) { + const audit = makeAuditWriter(); + const service = new WalletService({ + drizzle: db.drizzle, + events: makeEventBus(), + payment: mock(payment ?? {}), + audit, + identityReader: makeIdentityReader(), + }); + const router = createWalletRouter( + service, + guard, + audit, + mock({}), + mock({ verify: vi.fn().mockReturnValue(false) }), + ); + return { router, audit, service }; +} + +async function seedBalance(currency: string, amount: string) { + const row = findOneOrThrow( + await db.drizzle.db.insert(wallet).values({ userId: randomUUID(), currency }).returning(), + new Error('seedBalance: query returned no row'), + ); + await db.drizzle.db.insert(walletBalance).values({ walletId: row.id, currency, amount }); + return row; +} + +describe('wallet asset catalog routes', () => { + it('create: persists a row, normalizes casing, and audits', async () => { + const { router, audit } = routerWith(adminGuard()); + + const created = await call( + router.assets.create, + { ...USDT_ERC20, currency: 'usdt', network: 'erc20' }, + { context: CTX }, + ); + + expect(created).toMatchObject({ + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + depositEnabled: true, + withdrawalEnabled: true, + }); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.created' }), + ); + }); + + it('create: rejects a duplicate (currency, network)', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + await expect( + call(router.assets.create, { ...USDT_ERC20, providerAssetId: 'OTHER' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + }); + + it('create: allows the same currency on a second network', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const bep20 = await call( + router.assets.create, + { ...USDT_ERC20, network: 'BEP20', providerAssetId: 'USDT_BSC' }, + { context: CTX }, + ); + + expect(bep20).toMatchObject({ currency: 'USDT', network: 'BEP20' }); + }); + + it('create: rejects a pair the bound adapter cannot serve', async () => { + const { router } = routerWith(adminGuard(), { supportsAsset: () => false }); + + await expect(call(router.assets.create, { ...USDT_ERC20 }, { context: CTX })).rejects.toThrow( + ORPCError, + ); + }); + + it('listAssets: is public and hides fully-disabled pairs and the vendor id', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await call( + router.assets.create, + { + ...USDT_ERC20, + network: 'TRC20', + providerAssetId: 'USDT_TRX', + depositEnabled: false, + withdrawalEnabled: false, + }, + { context: CTX }, + ); + + const listed = await call(router.listAssets, {}, { context: CTX }); + + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ currency: 'USDT', network: 'ERC20' }); + expect(listed[0]).not.toHaveProperty('providerAssetId'); + }); + + it('listAssets: keeps a pair enabled on only one side', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20, withdrawalEnabled: false }, { context: CTX }); + + const listed = await call(router.listAssets, {}, { context: CTX }); + + expect(listed).toMatchObject([{ depositEnabled: true, withdrawalEnabled: false }]); + }); + + it('list: returns disabled rows and the vendor id for an admin', async () => { + const { router } = routerWith(adminGuard()); + await call( + router.assets.create, + { ...USDT_ERC20, depositEnabled: false, withdrawalEnabled: false }, + { context: CTX }, + ); + + const listed = await call(router.assets.list, {}, { context: CTX }); + + expect(listed).toMatchObject([{ providerAssetId: 'USDT_ERC20', depositEnabled: false }]); + }); + + it('update: applies a partial change without restating amounts', async () => { + const { router, audit } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const updated = await call( + router.assets.update, + { currency: 'USDT', network: 'ERC20', withdrawalEnabled: false }, + { context: CTX }, + ); + + expect(updated).toMatchObject({ withdrawalEnabled: false, depositEnabled: true }); + expect(updated.minWithdrawal).toBe('20.000000000000000000'); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.updated' }), + ); + }); + + it('update: 404s on a pair that does not exist', async () => { + const { router } = routerWith(adminGuard()); + + await expect( + call( + router.assets.update, + { currency: 'USDT', network: 'ERC20', withdrawalFee: '1' }, + { context: CTX }, + ), + ).rejects.toThrow(ORPCError); + }); + + it('delete: removes a pair no player holds', async () => { + const { router, audit } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const deleted = await call( + router.assets.delete, + { currency: 'USDT', network: 'ERC20' }, + { context: CTX }, + ); + + expect(deleted).toBe(true); + expect(await call(router.assets.list, {}, { context: CTX })).toEqual([]); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.deleted' }), + ); + }); + + it('delete: is blocked while a player still holds that currency', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await seedBalance('USDT', '5'); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + expect(await call(router.assets.list, {}, { context: CTX })).toHaveLength(1); + }); + + it('delete: allows removal once the held balance is zero', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await seedBalance('USDT', '0'); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).resolves.toBe(true); + }); + + it('delete: reports false for a pair that was never configured', async () => { + const { router } = routerWith(adminGuard()); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).resolves.toBe(false); + }); + + it('admin routes reject a caller without the wallet-asset resource', async () => { + const { router } = routerWith(denyingGuard()); + + await expect(call(router.assets.list, {}, { context: CTX })).rejects.toThrow(ORPCError); + await expect(call(router.assets.create, { ...USDT_ERC20 }, { context: CTX })).rejects.toThrow( + ORPCError, + ); + }); +}); diff --git a/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts b/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts new file mode 100644 index 00000000..95f6d70f --- /dev/null +++ b/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts @@ -0,0 +1,39 @@ +import { DrizzleService } from '@openora/core/server'; +import { type WalletAsset, type WalletAssetCatalog } from '@openora/core/contracts'; +import { and, asc, eq } from 'drizzle-orm'; +import { walletAsset } from '../schema/index.js'; + +const ASSET_COLUMNS = { + currency: walletAsset.currency, + network: walletAsset.network, + providerAssetId: walletAsset.providerAssetId, + minDeposit: walletAsset.minDeposit, + minWithdrawal: walletAsset.minWithdrawal, + withdrawalFee: walletAsset.withdrawalFee, + depositEnabled: walletAsset.depositEnabled, + withdrawalEnabled: walletAsset.withdrawalEnabled, +}; + +/** + * Default DB-backed catalog, bound by the wallet plugin. Reads every row, enabled or not: + * a payment adapter resolving an in-flight transaction still needs the asset it was + * created with. + */ +export class WalletAssetCatalogService implements WalletAssetCatalog { + constructor(private readonly drizzle: DrizzleService) {} + + list(): Promise { + return this.drizzle.db + .select(ASSET_COLUMNS) + .from(walletAsset) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + } + + async get(currency: string, network: string): Promise { + const [row] = await this.drizzle.db + .select(ASSET_COLUMNS) + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + return row ?? null; + } +} diff --git a/packages/core/src/wallet/contract/index.ts b/packages/core/src/wallet/contract/index.ts index 7c389395..cbbff519 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -3,6 +3,8 @@ import * as z from 'zod'; import { KycStatusSchema, MoneyAmountSchema, + MONEY_PRECISION, + MONEY_SCALE, TagKeySchema, TimestampSchema, UuidSchema, @@ -197,6 +199,70 @@ export const DepositAddressSchema = z.object({ tag: z.string().optional(), }); +// Free-form, not an enum: each payment vendor spells chains its own way (ERC20 vs +// ETHEREUM vs eth-mainnet). Uppercased so casing alone can't duplicate a row. +const WalletNetworkInputSchema = z + .string() + .trim() + .min(1) + .max(32) + .transform((n) => n.toUpperCase()); + +// Bounded to the column's integer-digit budget so an oversized value is a 4xx at the +// contract boundary instead of a DB overflow 500. +const WalletAssetAmountSchema = MoneyAmountSchema.refine( + (v) => (v.split('.').at(0) ?? '').length <= MONEY_PRECISION - MONEY_SCALE, + { message: `must have at most ${MONEY_PRECISION - MONEY_SCALE} integer digits` }, +); + +export const PublicWalletAssetSchema = z.object({ + currency: WalletCurrencyCodeSchema, + network: z.string(), + minDeposit: MoneyAmountSchema, + minWithdrawal: MoneyAmountSchema, + withdrawalFee: MoneyAmountSchema, + depositEnabled: z.boolean(), + withdrawalEnabled: z.boolean(), +}); +export type PublicWalletAsset = z.infer; + +export const WalletAssetSchema = PublicWalletAssetSchema.extend({ + id: UuidSchema, + providerAssetId: z.string(), + createdAt: TimestampSchema, + updatedAt: TimestampSchema, +}); +export type WalletAsset = z.infer; + +export const WalletAssetKeySchema = z.object({ + currency: WalletCurrencyInputSchema, + network: WalletNetworkInputSchema, +}); + +export const CreateWalletAssetInputSchema = z.object({ + currency: WalletCurrencyInputSchema, + network: WalletNetworkInputSchema, + providerAssetId: z.string().trim().min(1), + minDeposit: WalletAssetAmountSchema, + minWithdrawal: WalletAssetAmountSchema, + withdrawalFee: WalletAssetAmountSchema, + depositEnabled: z.boolean().default(true), + withdrawalEnabled: z.boolean().default(true), +}); +export type CreateWalletAssetInput = z.infer; + +// The (currency, network) key is not mutable: renaming a pair is a delete plus a create, +// so an in-flight vendor reference can't be rewritten out from under a pending transaction. +export const UpdateWalletAssetInputSchema = WalletAssetKeySchema.extend({ + providerAssetId: z.string().trim().min(1).optional(), + minDeposit: WalletAssetAmountSchema.optional(), + minWithdrawal: WalletAssetAmountSchema.optional(), + withdrawalFee: WalletAssetAmountSchema.optional(), + depositEnabled: z.boolean().optional(), + withdrawalEnabled: z.boolean().optional(), +}); +export type UpdateWalletAssetInput = z.infer; + export const walletContract = { getBalance: oc.route({ method: 'GET', path: '/wallet/balance' }).output(WalletBalanceSchema), @@ -277,6 +343,32 @@ export const walletContract = { .output(WalletAutoWithdrawalConfigSchema), }, + // Unauthenticated by design - which assets exist is not secret. + listAssets: oc + .route({ method: 'GET', path: '/wallet/assets' }) + .output(z.array(PublicWalletAssetSchema)), + + assets: { + list: oc + .route({ method: 'GET', path: '/wallet/admin/assets' }) + .output(z.array(WalletAssetSchema)), + + create: oc + .route({ method: 'POST', path: '/wallet/admin/assets' }) + .input(CreateWalletAssetInputSchema) + .output(WalletAssetSchema), + + update: oc + .route({ method: 'PUT', path: '/wallet/admin/assets/{currency}/{network}' }) + .input(UpdateWalletAssetInputSchema) + .output(WalletAssetSchema), + + delete: oc + .route({ method: 'DELETE', path: '/wallet/admin/assets/{currency}/{network}' }) + .input(WalletAssetKeySchema) + .output(z.boolean()), + }, + deposits: { getAddress: oc .route({ method: 'POST', path: '/wallet/deposits/address' }) diff --git a/packages/core/src/wallet/drizzle/migrations/0010_mixed_mongu.sql b/packages/core/src/wallet/drizzle/migrations/0010_mixed_mongu.sql new file mode 100644 index 00000000..5471dbd2 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0010_mixed_mongu.sql @@ -0,0 +1,15 @@ +CREATE TABLE "wallet_asset" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "currency" text NOT NULL, + "network" text NOT NULL, + "provider_asset_id" text NOT NULL, + "min_deposit" numeric(38, 18) NOT NULL, + "min_withdrawal" numeric(38, 18) NOT NULL, + "withdrawal_fee" numeric(38, 18) NOT NULL, + "deposit_enabled" boolean DEFAULT true NOT NULL, + "withdrawal_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_asset_currency_network_idx" ON "wallet_asset" USING btree ("currency","network"); \ No newline at end of file diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..85ed31b2 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,1001 @@ +{ + "id": "48b38945-d272-4fe6-baf9-6e604577d5a1", + "prevId": "1d88a54e-042f-4628-81c7-0959aa0cacc5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_asset": { + "name": "wallet_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_asset_id": { + "name": "provider_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_withdrawal": { + "name": "min_withdrawal", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "withdrawal_fee": { + "name": "withdrawal_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "deposit_enabled": { + "name": "deposit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "withdrawal_enabled": { + "name": "withdrawal_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_asset_currency_network_idx": { + "name": "wallet_asset_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_auto_withdrawal_config": { + "name": "wallet_auto_withdrawal_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "fiat_threshold": { + "name": "fiat_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "crypto_threshold": { + "name": "crypto_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "exclude_risk_flags": { + "name": "exclude_risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_auto_withdrawal_config_singletonKey_unique": { + "name": "wallet_auto_withdrawal_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_balance": { + "name": "wallet_balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_balance_wallet_id_currency_idx": { + "name": "wallet_balance_wallet_id_currency_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_balance_wallet_id_wallet_id_fk": { + "name": "wallet_balance_wallet_id_wallet_id_fk", + "tableFrom": "wallet_balance", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_network_idx": { + "name": "wallet_deposit_address_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_tag_idx": { + "name": "wallet_deposit_address_address_tag_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_network_currency_idx": { + "name": "wallet_deposit_address_address_network_currency_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NULL AND \"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_provider_vault": { + "name": "wallet_provider_vault", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_provider_vault_user_id_provider_name_idx": { + "name": "wallet_provider_vault_user_id_provider_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_provider_vault_provider_name_vault_account_id_idx": { + "name": "wallet_provider_vault_provider_name_vault_account_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vault_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": ["deposit", "withdrawal", "bet", "win", "loss", "bonus", "tip", "gift", "rain"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index c8e9d861..d9f1f628 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1787059873921, "tag": "0009_smooth_sue_storm", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1787257749911, + "tag": "0010_mixed_mongu", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index e3e22ce7..07527775 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -9,6 +9,7 @@ import { PAYMENT_WEBHOOK_VERIFIER, WALLET_COMMANDS, WALLET_READER, + WALLET_ASSET_CATALOG, PLATFORM_CONFIG, RATE_LIMITER, PLAYER_TAGS, @@ -19,6 +20,7 @@ import { import { WalletService } from './service/wallet.service.js'; import { WalletCommandsService } from './service/wallet-commands.service.js'; import { WalletReaderService } from './adapters/wallet-reader.service.js'; +import { WalletAssetCatalogService } from './adapters/wallet-asset-catalog.service.js'; import { DrizzleAdminWalletReporting } from './admin-reporting.js'; import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; @@ -53,6 +55,9 @@ export default { // Read-only queries for cross-module consumers (eg tag evaluation). Never exposes wallet internals. ctx.provide(WALLET_READER, (c) => new WalletReaderService(c.get(DRIZZLE))); ctx.provide(ADMIN_WALLET_REPORTING, (c) => new DrizzleAdminWalletReporting(c.get(DRIZZLE))); + // Operator-editable currency/network config, readable by a payment adapter without + // importing wallet tables. Overlay-rebindable, but bound here so it always works. + ctx.provide(WALLET_ASSET_CATALOG, (c) => new WalletAssetCatalogService(c.get(DRIZZLE))); ctx.routers.add('wallet', (c) => createWalletRouter( new WalletService({ diff --git a/packages/core/src/wallet/router/index.ts b/packages/core/src/wallet/router/index.ts index 591132f4..e6b3d56a 100644 --- a/packages/core/src/wallet/router/index.ts +++ b/packages/core/src/wallet/router/index.ts @@ -18,6 +18,10 @@ import { DepositAddressUnsupportedError, DestinationAddressRequiredError, AutoWithdrawalConfigNotFoundError, + WalletAssetNotFoundError, + WalletAssetAlreadyExistsError, + WalletAssetUnsupportedError, + WalletAssetInUseError, } from '../service/wallet.service.js'; export function createWalletRouter( @@ -202,6 +206,53 @@ export function createWalletRouter( }), }, + listAssets: os.listAssets.handler(() => wallet.listEnabledWalletAssets()), + + assets: { + list: os.assets.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-asset', 'view'); + return wallet.listWalletAssets(); + }), + + create: os.assets.create.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'create'); + return mapErrors( + { CONFLICT: [WalletAssetAlreadyExistsError, WalletAssetUnsupportedError] }, + () => wallet.createWalletAsset(adminId, input, { ip, userAgent }), + ); + }), + + update: os.assets.update.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'update'); + return mapErrors( + { + NOT_FOUND: WalletAssetNotFoundError, + CONFLICT: WalletAssetUnsupportedError, + }, + () => wallet.updateWalletAsset(adminId, input, { ip, userAgent }), + ); + }), + + delete: os.assets.delete.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'delete'); + return mapErrors({ CONFLICT: WalletAssetInUseError }, () => + wallet.deleteWalletAsset(adminId, input.currency, input.network, { ip, userAgent }), + ); + }), + }, + deposits: { getAddress: os.deposits.getAddress.handler(({ input, context }) => mapErrors({ CONFLICT: DepositAddressUnsupportedError }, () => diff --git a/packages/core/src/wallet/schema/index.ts b/packages/core/src/wallet/schema/index.ts index ec19661c..27a5e9d6 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -8,6 +8,7 @@ import { pgEnum, index, uniqueIndex, + boolean, } from 'drizzle-orm/pg-core'; import { WALLET_RAILS, @@ -196,8 +197,33 @@ export const walletAutoWithdrawalConfig = pgTable('wallet_auto_withdrawal_config createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }); +// Chain-level behaviour (how the vendor hands out addresses) is deliberately NOT here - +// that belongs to the bound payment adapter, which owns its own vendor vocabulary. +export const walletAsset = pgTable( + 'wallet_asset', + { + id: uuid().primaryKey().defaultRandom(), + currency: text().notNull(), + network: text().notNull(), + providerAssetId: text().notNull(), + minDeposit: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + minWithdrawal: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + withdrawalFee: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + // Independent: an asset can take deposits before it can pay out. + depositEnabled: boolean().notNull().default(true), + withdrawalEnabled: boolean().notNull().default(true), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (t) => [uniqueIndex('wallet_asset_currency_network_idx').on(t.currency, t.network)], +); + export type Wallet = typeof wallet.$inferSelect; export type WalletTransaction = typeof walletTransaction.$inferSelect; export type AutoWithdrawalRule = typeof autoWithdrawalRule.$inferSelect; export type WalletDepositAddress = typeof walletDepositAddress.$inferSelect; export type WalletAutoWithdrawalConfig = typeof walletAutoWithdrawalConfig.$inferSelect; +export type WalletAssetRow = typeof walletAsset.$inferSelect; diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index e9e4541f..dc15b187 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -3,6 +3,7 @@ import { type DrizzleDb, type DrizzleTx, makeNotFoundError, + serializeRow, makeConflictError, createDomainError, DrizzleService, @@ -42,11 +43,13 @@ import { autoWithdrawalRule, walletAutoWithdrawalConfig, walletDepositAddress, + walletAsset, type Wallet, type WalletDepositAddress, type WalletTransaction, type AutoWithdrawalRule as AutoWithdrawalRuleRow, type WalletAutoWithdrawalConfig as WalletAutoWithdrawalConfigRow, + type WalletAssetRow, } from '../schema/index.js'; import type { TransactionResult, @@ -55,6 +58,10 @@ import type { AutoWithdrawalRule, WalletAutoWithdrawalConfig, WalletTransactionSortBy, + WalletAsset, + PublicWalletAsset, + CreateWalletAssetInput, + UpdateWalletAssetInput, } from '../contract/index.js'; const logger = createLogger('wallet'); @@ -93,6 +100,23 @@ export const DestinationAddressRequiredError = makeConflictError( 'A destination address is required for a crypto-rail withdrawal', ); +export const WalletAssetNotFoundError = makeNotFoundError('WalletAsset'); + +export const WalletAssetAlreadyExistsError = makeConflictError( + 'WalletAssetAlreadyExistsError', + 'An asset is already configured for this currency and network', +); + +export const WalletAssetUnsupportedError = makeConflictError( + 'WalletAssetUnsupportedError', + 'The bound payment adapter cannot serve this currency and network', +); + +export const WalletAssetInUseError = makeConflictError( + 'WalletAssetInUseError', + 'Players still hold a balance in this currency', +); + const KYC_PASS_STATUSES: ReadonlySet = new Set(['approved', 'manually_overridden']); export const CurrencyMismatchError = createDomainError( @@ -274,6 +298,20 @@ function toAutoWithdrawalConfigDto(row: WalletAutoWithdrawalConfigRow): WalletAu }; } +const PUBLIC_ASSET_COLUMNS = { + currency: walletAsset.currency, + network: walletAsset.network, + minDeposit: walletAsset.minDeposit, + minWithdrawal: walletAsset.minWithdrawal, + withdrawalFee: walletAsset.withdrawalFee, + depositEnabled: walletAsset.depositEnabled, + withdrawalEnabled: walletAsset.withdrawalEnabled, +}; + +function toWalletAssetDto(row: WalletAssetRow): WalletAsset { + return serializeRow(row, { dateFields: ['createdAt', 'updatedAt'], decimalFields: [] }); +} + // The concrete settlement provider recorded per transaction: the crypto rail settles // through Fireblocks, the fiat rail through a PSP. function providerNameFor(rail: WalletRail | null): string { @@ -1289,6 +1327,143 @@ export class WalletService { }); } + async listWalletAssets(): Promise { + const rows = await this.drizzle.db + .select() + .from(walletAsset) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + return rows.map(toWalletAssetDto); + } + + listEnabledWalletAssets(): Promise { + return this.drizzle.db + .select(PUBLIC_ASSET_COLUMNS) + .from(walletAsset) + .where(or(eq(walletAsset.depositEnabled, true), eq(walletAsset.withdrawalEnabled, true))) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + } + + async getWalletAsset(currency: string, network: string): Promise { + const [row] = await this.drizzle.db + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + return row ? toWalletAssetDto(row) : null; + } + + // The catalog is operator-editable, so an admin can name a pair the bound vendor has + // never heard of. Reject at write time rather than at a player's deposit request. + private assertAdapterSupports(currency: string, network: string) { + if (this.payment.supportsAsset && !this.payment.supportsAsset(currency, network)) { + throw new WalletAssetUnsupportedError(); + } + } + + async createWalletAsset( + adminId: User['id'], + input: CreateWalletAssetInput, + meta?: ClientMeta, + ): Promise { + this.assertAdapterSupports(input.currency, input.network); + return this.drizzle.db.transaction(async (txn) => { + const rows = await txn.insert(walletAsset).values(input).onConflictDoNothing().returning(); + // Empty => the (currency, network) unique index rejected it. + const asset = toWalletAssetDto(findOneOrThrow(rows, new WalletAssetAlreadyExistsError())); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.created', + resourceType: 'wallet_asset', + resourceId: asset.id, + before: null, + after: asset, + ...meta, + }); + return asset; + }); + } + + // Deliberately touches only the catalog row: a withdrawal already pending or processing + // in this currency keeps its own terms and is never cancelled by disabling the pair. + async updateWalletAsset( + adminId: User['id'], + { currency, network, ...changes }: UpdateWalletAssetInput, + meta?: ClientMeta, + ): Promise { + if (changes.providerAssetId !== undefined) { + this.assertAdapterSupports(currency, network); + } + return this.drizzle.db.transaction(async (txn) => { + const [before] = await txn + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + if (!before) { + throw new WalletAssetNotFoundError(`${currency}/${network}`); + } + const rows = await txn + .update(walletAsset) + .set(changes) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))) + .returning(); + const asset = toWalletAssetDto( + findOneOrThrow(rows, new WalletAssetNotFoundError(`${currency}/${network}`)), + ); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.updated', + resourceType: 'wallet_asset', + resourceId: asset.id, + before: toWalletAssetDto(before), + after: asset, + ...meta, + }); + return asset; + }); + } + + async deleteWalletAsset( + adminId: User['id'], + currency: string, + network: string, + meta?: ClientMeta, + ): Promise { + return this.drizzle.db.transaction(async (txn) => { + const [before] = await txn + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + if (!before) { + return false; + } + // wallet_balance is keyed by currency only (no network column), so this guard is + // necessarily currency-wide: removing one network of a currency players still hold + // is blocked even if their balance arrived over another network. Fails safe. + const [held] = await txn + .select({ n: count() }) + .from(walletBalance) + .where(and(eq(walletBalance.currency, currency), sql`${walletBalance.amount} > 0`)); + if ((held?.n ?? 0) > 0) { + throw new WalletAssetInUseError(); + } + await txn + .delete(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.deleted', + resourceType: 'wallet_asset', + resourceId: before.id, + before: toWalletAssetDto(before), + after: null, + ...meta, + }); + return true; + }); + } + private async autoApprovalKycStatus(userId: User['id']): Promise { // No directory bound => cannot verify KYC => fail closed. if (!this.directory) {