diff --git a/.env.example b/.env.example index 972bc8e..7a3bde0 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,7 @@ LIVEMODE= # Anonymous usage telemetry (opt-in via setup/settings; off by default). # Set to 0 to force disable. # ZONELESS_TELEMETRY= + +# Flashnet Orchestra (optional). Server key only (fn_...). Leave unset for simulated rails in test mode. +# ORCHESTRA_API_URL=https://orchestration.flashnet.xyz +# ORCHESTRA_API_KEY= diff --git a/.env.production.example b/.env.production.example index 784ed0d..06b6953 100644 --- a/.env.production.example +++ b/.env.production.example @@ -68,4 +68,8 @@ LIVEMODE=true # Anonymous usage telemetry (opt-in via setup/settings; off by default). # Set to 0 to force disable. -# ZONELESS_TELEMETRY= \ No newline at end of file +# ZONELESS_TELEMETRY= + +# Flashnet Orchestra (optional). Server key only (fn_...). Leave unset for simulated rails in test mode. +# ORCHESTRA_API_URL=https://orchestration.flashnet.xyz +# ORCHESTRA_API_KEY= diff --git a/README.md b/README.md index ac49238..b1d0553 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ zoneless/ └── nx.json ``` +Optional Cash App and other-chain stables (settling to USDC on Solana) are documented in [docs/orchestra.md](./docs/orchestra.md). Native `solana:USDC` is unchanged. + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup, style guidelines, and the pull request process. diff --git a/apps/api/src/__tests__/ExternalWallet.spec.ts b/apps/api/src/__tests__/ExternalWallet.spec.ts index 7c7e31b..49aa052 100644 --- a/apps/api/src/__tests__/ExternalWallet.spec.ts +++ b/apps/api/src/__tests__/ExternalWallet.spec.ts @@ -105,6 +105,38 @@ describe('ExternalWalletModule', () => { }) ).rejects.toThrow('Account not found'); }); + + it('accepts USDT on Base', async () => { + mockDb.Get = jest.fn().mockResolvedValue({ + id: 'acct_z_1', + platform_account: 'acct_z_platform', + }); + + const wallet = await module.CreateExternalWallet('acct_z_1', { + wallet_address: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', + network: 'base', + currency: 'usdt', + }); + + expect(wallet.network).toBe('base'); + expect(wallet.currency).toBe('usdt'); + }); + + it('accepts USDT on Solana', async () => { + mockDb.Get = jest.fn().mockResolvedValue({ + id: 'acct_z_1', + platform_account: 'acct_z_platform', + }); + + const wallet = await module.CreateExternalWallet('acct_z_1', { + wallet_address: VALID_WALLET, + network: 'solana', + currency: 'usdt', + }); + + expect(wallet.network).toBe('solana'); + expect(wallet.currency).toBe('usdt'); + }); }); describe('GetExternalWallet', () => { diff --git a/apps/api/src/__tests__/Orchestra.spec.ts b/apps/api/src/__tests__/Orchestra.spec.ts new file mode 100644 index 0000000..3b1bb05 --- /dev/null +++ b/apps/api/src/__tests__/Orchestra.spec.ts @@ -0,0 +1,604 @@ +import { CheckoutPaymentModule } from '../modules/CheckoutPayment'; +import { CheckoutSessionModule } from '../modules/CheckoutSession'; +import { ChargeModule } from '../modules/Charge'; +import { Database } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { ExternalWalletModule } from '../modules/ExternalWallet'; +import { PaymentIntentModule } from '../modules/PaymentIntent'; +import { ProductModule } from '../modules/Product'; +import { OrchestraClient } from '../modules/orchestra/OrchestraClient'; +import { OrchestraModule } from '../modules/orchestra/OrchestraModule'; +import { + CentsToFiatUsd, + CentsToUsdcSmallest, + DeriveOrchestraStableSources, + IsNativeSolanaUsdc, + IsOrchestraPayoutDest, + ListOrchestraPayinSources, + ResetOrchestraRouteCache, + UsdcSmallestToCents, +} from '../modules/orchestra/OrchestraRails'; +import { + CheckoutSession, + CheckoutSessionLineItem, + ExternalWallet, + Payout, + Price, +} from '@zoneless/shared-types'; +import { + CreateMockDatabase, + DeterministicId, + DeterministicUrlSlug, + GetFixedTimestamp, + ResetIdCounter, +} from './Setup'; + +jest.mock('../modules/Database'); +jest.mock('../utils/IdGenerator', () => ({ + GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)), + GenerateUrlSlug: jest.fn(() => DeterministicUrlSlug()), +})); +jest.mock('../utils/Timestamp', () => ({ + Now: jest.fn(() => GetFixedTimestamp()), +})); +jest.mock('../modules/AppConfig', () => ({ + GetAppConfig: jest.fn(() => ({ + dashboardUrl: 'http://localhost:4200', + checkoutUrl: 'http://localhost:4200', + paymentLinkUrl: 'http://localhost:4200', + livemode: false, + appSecret: 'test-secret', + settlement_rail: 'simulated', + orchestraApiUrl: '', + orchestraApiKey: '', + })), + IsCheckoutFeeSponsored: jest.fn(() => false), + IsOrchestraLive: jest.fn(() => false), +})); + +function BuildPrice(overrides: Partial = {}): Price { + return { + id: 'price_z_1', + active: true, + currency: 'usdc', + metadata: {}, + nickname: null, + product: 'prod_z_1', + recurring: null, + tax_behavior: 'unspecified', + type: 'one_time', + unit_amount: 1000, + object: 'price', + billing_scheme: 'per_unit', + currency_options: null, + created: 1700000000, + custom_unit_amount: null, + livemode: false, + lookup_key: null, + tiers: null, + tiers_mode: null, + transform_quantity: null, + unit_amount_decimal: '1000', + platform_account: 'acct_z_platform', + subscription_plan_pda: null, + ...overrides, + }; +} + +function BuildLineItem( + overrides: Partial = {} +): CheckoutSessionLineItem { + return { + id: 'li_z_1', + object: 'item', + amount_discount: 0, + amount_subtotal: 1000, + amount_tax: 0, + amount_total: 1000, + currency: 'usdc', + description: 'Test Product', + discounts: null, + metadata: {}, + price: BuildPrice(), + quantity: 1, + taxes: null, + ...overrides, + }; +} + +describe('Orchestra', () => { + let mockDb: jest.Mocked; + let checkoutSessionModule: CheckoutSessionModule; + let checkoutPaymentModule: CheckoutPaymentModule; + let orchestraModule: OrchestraModule; + let mockExternalWalletModule: jest.Mocked< + Pick + >; + + const platformWallet = { + id: 'ew_z_platform', + object: 'wallet' as const, + account: 'acct_z_platform', + wallet_address: 'MerchantWallet111111111111111111111111111', + network: 'solana', + currency: 'usdc', + default_for_currency: true, + status: 'verified' as const, + created: 1700000000, + metadata: {}, + platform_account: 'acct_z_platform', + } as unknown as ExternalWallet; + + beforeEach(() => { + jest.clearAllMocks(); + ResetIdCounter(); + mockDb = CreateMockDatabase(); + + const eventService = { + Emit: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + const productModule = new ProductModule(mockDb); + const paymentIntentModule = new PaymentIntentModule(mockDb, eventService); + const chargeModule = new ChargeModule(mockDb, eventService); + checkoutSessionModule = new CheckoutSessionModule( + mockDb, + eventService, + undefined, + productModule, + undefined, + paymentIntentModule + ); + + mockExternalWalletModule = { + GetDefaultWallet: jest.fn().mockResolvedValue(platformWallet), + GetExternalWallet: jest.fn().mockResolvedValue(platformWallet), + }; + + checkoutPaymentModule = new CheckoutPaymentModule( + mockDb, + checkoutSessionModule, + mockExternalWalletModule as unknown as ExternalWalletModule, + productModule, + paymentIntentModule, + chargeModule + ); + + orchestraModule = new OrchestraModule( + mockDb, + checkoutSessionModule, + mockExternalWalletModule as unknown as ExternalWalletModule, + checkoutPaymentModule + ); + }); + + function BuildOpenSession( + overrides: Partial = {} + ): CheckoutSession { + return { + ...checkoutSessionModule.CheckoutSessionObject( + 'acct_z_platform', + { mode: 'payment', success_url: 'https://example.com/success' }, + [BuildLineItem()] + ), + payment_intent: 'pi_z_1', + ...overrides, + }; + } + + describe('Cents conversion', () => { + it('converts cents to 6-decimal USDC units', () => { + expect(CentsToUsdcSmallest(1000)).toBe('10000000'); + expect(UsdcSmallestToCents('10000000')).toBe(1000); + expect(CentsToFiatUsd(1000)).toBe('10.00'); + expect(CentsToFiatUsd(1)).toBe('0.01'); + }); + + it('floors partial smallest units when converting back to cents', () => { + expect(UsdcSmallestToCents('10009999')).toBe(1000); + }); + }); + + describe('Rails', () => { + afterEach(() => { + ResetOrchestraRouteCache(); + }); + + it('uses Flashnet pairs that route both ways with solana:USDC', () => { + const sources = ListOrchestraPayinSources(); + expect( + sources.some( + (source) => source.chain === 'base' && source.asset === 'usdt' + ) + ).toBe(true); + expect( + sources.some( + (source) => source.chain === 'tron' && source.asset === 'usdt' + ) + ).toBe(true); + expect( + sources.some( + (source) => source.chain === 'optimism' && source.asset === 'usdt' + ) + ).toBe(false); + expect(IsOrchestraPayoutDest('base', 'usdt')).toBe(true); + expect(IsOrchestraPayoutDest('optimism', 'usdt')).toBe(false); + expect(IsOrchestraPayoutDest('solana', 'usdt')).toBe(true); + }); + + it('drops stables that do not route to solana:USDC', () => { + const sources = DeriveOrchestraStableSources([ + { + sourceChain: 'optimism', + sourceAsset: 'USDT', + destinationChain: 'lightning', + destinationAsset: 'BTC', + source: { + decimals: 6, + chainId: '10', + contractAddress: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', + chainDisplayName: 'Optimism', + }, + }, + { + sourceChain: 'base', + sourceAsset: 'USDT', + destinationChain: 'solana', + destinationAsset: 'USDC', + source: { + decimals: 6, + chainId: '8453', + contractAddress: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + chainDisplayName: 'Base', + }, + }, + { + sourceChain: 'solana', + sourceAsset: 'USDC', + destinationChain: 'base', + destinationAsset: 'USDT', + destination: { + decimals: 6, + chainId: '8453', + contractAddress: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + chainDisplayName: 'Base', + }, + }, + { + sourceChain: 'bsc', + sourceAsset: 'USDT', + destinationChain: 'solana', + destinationAsset: 'USDC', + source: { + decimals: 18, + chainId: '56', + contractAddress: '0x55d398326f99059fF775485246999027B3197955', + chainDisplayName: 'BNB', + }, + }, + ]); + + expect(sources).toEqual([ + { chain: 'base', asset: 'usdt', label: 'USDT on Base' }, + ]); + }); + }); + + describe('StartPayin', () => { + it('starts a simulated Cash App intent with a cash_app_url', async () => { + const session = BuildOpenSession(); + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSessionByUrlSlug') + .mockResolvedValue(session); + + const result = await orchestraModule.StartPayin(session.url_slug, { + method: 'cashapp', + }); + + expect(result.object).toBe('orchestra_payin'); + expect(result.checkout_session.id).toBe(session.id); + expect(result.checkout_session.orchestra?.method).toBe('cashapp'); + expect(result.intent.method).toBe('cashapp'); + expect(result.intent.cash_app_url).toBe( + `https://cash.app/launch/lightning/sim-${session.id}` + ); + expect(result.intent.deposit_address).toBe('lnbc1sim'); + expect(result.intent.status).toBe('awaiting_deposit'); + expect(mockDb.Update).toHaveBeenCalledWith( + 'CheckoutSessions', + session.id, + expect.objectContaining({ + orchestra: expect.objectContaining({ + method: 'cashapp', + cash_app_url: result.intent.cash_app_url, + }), + }) + ); + }); + + it('starts a simulated deposit intent with a deposit_address', async () => { + const session = BuildOpenSession(); + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSessionByUrlSlug') + .mockResolvedValue(session); + + const result = await orchestraModule.StartPayin(session.url_slug, { + method: 'deposit', + source_chain: 'base', + source_asset: 'usdc', + }); + + expect(result.intent.method).toBe('deposit'); + expect(result.intent.source_chain).toBe('base'); + expect(result.intent.deposit_address).toBe( + '0xSimulatedOrchestraDeposit0000000000000001' + ); + expect(result.intent.amount_in).toBe('10000000'); + expect(result.intent.status).toBe('awaiting_deposit'); + }); + + it('rejects subscription mode', async () => { + const session = BuildOpenSession({ mode: 'subscription' }); + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSessionByUrlSlug') + .mockResolvedValue(session); + + await expect( + orchestraModule.StartPayin(session.url_slug, { method: 'cashapp' }) + ).rejects.toThrow(/payment-mode/); + }); + + it('always destinations to the platform wallet', async () => { + const session = BuildOpenSession(); + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSessionByUrlSlug') + .mockResolvedValue(session); + + await orchestraModule.StartPayin(session.url_slug, { + method: 'deposit', + source_chain: 'base', + source_asset: 'usdc', + }); + + expect(mockExternalWalletModule.GetDefaultWallet).toHaveBeenCalledWith( + 'acct_z_platform' + ); + expect(mockExternalWalletModule.GetDefaultWallet).toHaveBeenCalledTimes(1); + }); + }); + + describe('ConfirmPayin', () => { + it('completes a simulated pay-in and records the ledger', async () => { + const session = BuildOpenSession({ + orchestra: { + method: 'cashapp', + source_chain: 'lightning', + source_asset: 'usd', + quote_id: null, + operation_id: null, + deposit_address: 'lnbc1sim', + deposit_memo: null, + cash_app_url: 'https://cash.app/launch/lightning/sim-cs', + amount_in: '10000000', + estimated_out: '10000000', + expires_at: null, + status: 'awaiting_deposit', + }, + }); + const completedSession = { + ...session, + status: 'complete' as const, + payment_status: 'paid' as const, + url: null, + payment_details: { + transaction_signature: `orch_sim:${session.id}`, + payer_wallet: 'orchestra:simulated', + }, + }; + + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSessionByUrlSlug') + .mockResolvedValue(session); + jest + .spyOn(checkoutSessionModule, 'GetCheckoutSession') + .mockResolvedValue(completedSession); + jest + .spyOn(checkoutSessionModule, 'CompleteCheckoutSession') + .mockResolvedValue(completedSession); + + mockDb.Get = jest.fn().mockResolvedValue({ + id: 'pi_z_1', + object: 'payment_intent', + status: 'requires_payment_method', + payment_method: null, + description: session.id, + metadata: {}, + receipt_email: null, + application_fee_amount: null, + transfer_data: null, + transfer_group: null, + }); + mockDb.Find = jest.fn().mockResolvedValue([ + { + id: 'bal_z_1', + available: [{ amount: 0, currency: 'usdc' }], + pending: [], + }, + ]); + mockDb.Find2Custom = jest.fn().mockResolvedValue([]); + + const result = await orchestraModule.ConfirmPayin(session.url_slug); + + expect(result.status).toBe('complete'); + expect(checkoutSessionModule.CompleteCheckoutSession).toHaveBeenCalledWith( + session.id, + { + transaction_signature: `orch_sim:${session.id}`, + payer_wallet: 'orchestra:simulated', + } + ); + expect(mockDb.Set).toHaveBeenCalledWith( + 'BalanceTransactions', + expect.any(String), + expect.objectContaining({ + type: 'payment', + source: session.id, + amount: 1000, + }), + expect.anything() + ); + }); + }); + + describe('PreparePayout', () => { + const basePayout = { + id: 'po_z_1', + object: 'payout', + account: 'acct_z_seller', + platform_account: 'acct_z_platform', + amount: 2500, + currency: 'usdc', + destination: 'wa_z_base', + status: 'pending', + metadata: {}, + } as Payout; + + it('prepares a simulated base/usdc payout', async () => { + mockExternalWalletModule.GetExternalWallet = jest.fn().mockResolvedValue({ + id: 'wa_z_base', + wallet_address: '0x1111111111111111111111111111111111111111', + network: 'base', + currency: 'usdc', + }); + + const intent = await orchestraModule.PreparePayout( + 'acct_z_platform', + basePayout + ); + + expect(intent).toEqual( + expect.objectContaining({ + deposit_address: platformWallet.wallet_address, + destination_chain: 'base', + destination_asset: 'usdc', + amount_in: '25000000', + status: 'awaiting_deposit', + }) + ); + expect(mockDb.Update).toHaveBeenCalledWith( + 'Payouts', + 'po_z_1', + expect.objectContaining({ + orchestra: expect.objectContaining({ + destination_chain: 'base', + }), + }) + ); + }); + + it('returns null for native solana/usdc so the existing path is used', async () => { + mockExternalWalletModule.GetExternalWallet = jest.fn().mockResolvedValue({ + id: 'wa_z_sol', + wallet_address: platformWallet.wallet_address, + network: 'solana', + currency: 'usdc', + }); + + const intent = await orchestraModule.PreparePayout('acct_z_platform', { + ...basePayout, + destination: 'wa_z_sol', + }); + + expect(intent).toBeNull(); + expect(IsNativeSolanaUsdc('solana', 'usdc')).toBe(true); + expect(mockDb.Update).not.toHaveBeenCalled(); + }); + + it('rejects an unknown dest chain', async () => { + mockExternalWalletModule.GetExternalWallet = jest.fn().mockResolvedValue({ + id: 'wa_z_unknown', + wallet_address: '0x1111111111111111111111111111111111111111', + network: 'avalanche', + currency: 'usdc', + }); + + await expect( + orchestraModule.PreparePayout('acct_z_platform', { + ...basePayout, + destination: 'wa_z_unknown', + }) + ).rejects.toThrow(/Unsupported Orchestra payout destination/); + }); + }); + + describe('OrchestraClient', () => { + it('maps fetch failure to 502', async () => { + const fetchMock = jest.fn().mockRejectedValue(new Error('network down')); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const client = new OrchestraClient({ + apiUrl: 'https://orchestra.example', + apiKey: 'fn_test', + }); + + await expect( + client.CreateQuote({ + sourceChain: 'base', + sourceAsset: 'USDC', + destinationChain: 'solana', + destinationAsset: 'USDC', + recipientAddress: platformWallet.wallet_address, + amount: '10000000', + amountMode: 'exact_in', + idempotencyKey: 'zoneless:payin:cs_z_1:base:usdc', + }) + ).rejects.toMatchObject({ + message: 'Orchestra is unavailable', + statusCode: 502, + }); + + global.fetch = originalFetch; + }); + + it('posts amountFiatUsd as a decimal string and polls /status', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + orderId: 'ord_1', + quoteId: 'q_1', + status: 'completed', + sourceAddress: '0xpayer', + }), + }); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const client = new OrchestraClient({ + apiUrl: 'https://orchestra.example', + apiKey: 'fn_test', + }); + + await client.CreateOnramp({ + destinationChain: 'solana', + destinationAsset: 'USDC', + recipientAddress: platformWallet.wallet_address, + amountFiatUsd: '10.00', + idempotencyKey: 'zoneless:payin:cs_z_1:cashapp', + }); + const onrampBody = JSON.parse( + (fetchMock.mock.calls[0][1] as RequestInit).body as string + ); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://orchestra.example/v1/orchestration/onramp' + ); + expect(onrampBody.amountFiatUsd).toBe('10.00'); + + await client.GetOrderStatus({ orderId: 'ord_1' }); + expect(fetchMock.mock.calls[1][0]).toBe( + 'https://orchestra.example/v1/orchestration/status?id=ord_1' + ); + + global.fetch = originalFetch; + }); + }); +}); diff --git a/apps/api/src/__tests__/Payout.spec.ts b/apps/api/src/__tests__/Payout.spec.ts index 0a032a9..09fa5f8 100644 --- a/apps/api/src/__tests__/Payout.spec.ts +++ b/apps/api/src/__tests__/Payout.spec.ts @@ -20,7 +20,11 @@ jest.mock('../modules/AppConfig', () => ({ dashboardUrl: 'http://localhost:4200', livemode: false, appSecret: 'test-secret', + settlement_rail: 'simulated', + orchestraApiUrl: '', + orchestraApiKey: '', })), + IsOrchestraLive: jest.fn(() => false), })); jest.mock('../modules/chains/Settlement', () => ({ GetSettlement: () => ({ diff --git a/apps/api/src/middleware/RateLimiter.ts b/apps/api/src/middleware/RateLimiter.ts index 6f5f10b..794cb96 100644 --- a/apps/api/src/middleware/RateLimiter.ts +++ b/apps/api/src/middleware/RateLimiter.ts @@ -14,6 +14,8 @@ interface RateLimitStore { } interface RateLimitOptions { + /** Prefix so auth / API / strict counters do not share one bucket. */ + name: string; windowMs: number; // Time window in milliseconds maxRequests: number; // Max requests per window keyGenerator?: (req: Request) => string; // Custom key generator @@ -38,13 +40,14 @@ setInterval(() => { */ export function RateLimiter(options: RateLimitOptions) { const { + name, windowMs, maxRequests, keyGenerator = (req: Request) => req.ip || 'unknown', } = options; return (req: Request, res: Response, next: NextFunction) => { - const key = keyGenerator(req); + const key = `${name}:${keyGenerator(req)}`; const now = Date.now(); // Initialize or reset if window expired @@ -88,24 +91,28 @@ export function RateLimiter(options: RateLimitOptions) { export const RateLimiters = { // Standard API rate limit: 5000 requests per 15 minutes standard: RateLimiter({ + name: 'standard', windowMs: 15 * 60 * 1000, maxRequests: 5000, }), // Strict rate limit for sensitive operations: 100 requests per minute strict: RateLimiter({ + name: 'strict', windowMs: 60 * 1000, maxRequests: 100, }), // Auth rate limit: 100 attempts per 15 minutes auth: RateLimiter({ + name: 'auth', windowMs: 15 * 60 * 1000, maxRequests: 100, }), // Rate limit by API key instead of IP byApiKey: RateLimiter({ + name: 'apiKey', windowMs: 15 * 60 * 1000, maxRequests: 10000, keyGenerator: (req: Request) => { diff --git a/apps/api/src/modules/AppConfig.ts b/apps/api/src/modules/AppConfig.ts index 1861b26..cb17f2b 100644 --- a/apps/api/src/modules/AppConfig.ts +++ b/apps/api/src/modules/AppConfig.ts @@ -108,6 +108,8 @@ function BuildConfigFromEnv(): AppConfig { appSecret: process.env.APP_SECRET || '', livemode, settlement_rail: ResolveSettlementRail(livemode), + orchestraApiUrl: process.env.ORCHESTRA_API_URL || '', + orchestraApiKey: process.env.ORCHESTRA_API_KEY || '', }; } @@ -155,6 +157,15 @@ export function IsCheckoutFeeSponsored(): boolean { return !!GetCheckoutFeePayerSecretKey(); } +/** + * Live Flashnet Orchestra: server credentials are set. + * Settlement rail is independent — simulated Solana can still use live Cash App. + */ +export function IsOrchestraLive(): boolean { + const { orchestraApiUrl, orchestraApiKey } = GetAppConfig(); + return !!orchestraApiUrl && !!orchestraApiKey; +} + /** * Secret key for on-chain subscription plan ownership and payment pulls. * Required for recurring prices. diff --git a/apps/api/src/modules/CheckoutPayment.ts b/apps/api/src/modules/CheckoutPayment.ts index 2682a8f..15eaa5e 100644 --- a/apps/api/src/modules/CheckoutPayment.ts +++ b/apps/api/src/modules/CheckoutPayment.ts @@ -590,28 +590,57 @@ export class CheckoutPaymentModule { ); } - // Stripe order: charge.succeeded → payment_intent.succeeded → - // checkout.session.completed. - const charge = await this.CreateCheckoutCharge(session, { - amount: verification.amount_cents, + return this.CompleteVerifiedPayment(session, { + amount_cents: verification.amount_cents, signature, - payerAddress: verification.payer_address, + payer_address: verification.payer_address ?? '', + }); + } + + /** + * Success half of a verified one-time payment: charge → PI → complete + * session → ledger. Shared by on-chain confirm and Orchestra pay-in. + */ + async CompleteVerifiedPayment( + session: CheckoutSession, + details: { + amount_cents: number; + signature: string; + payer_address: string; + } + ): Promise { + if (session.status === 'complete') { + if (session.amount_total && details.signature) { + await this.RecordPaymentOnLedger( + session, + details.amount_cents || session.amount_total, + details.payer_address || session.payment_details?.payer_wallet || null, + details.signature + ); + } + return this.SanitizeCheckoutSession(session); + } + + const charge = await this.CreateCheckoutCharge(session, { + amount: details.amount_cents, + signature: details.signature, + payerAddress: details.payer_address, outcome: 'succeeded', }); await this.MarkPaymentIntentSucceeded(session, { - amountReceived: verification.amount_cents, + amountReceived: details.amount_cents, latestCharge: charge?.id ?? null, }); if (this.ShouldCreateCheckoutCustomer(session) && this.customerModule) { - await this.EnsureCheckoutCustomer(session, verification.payer_address); + await this.EnsureCheckoutCustomer(session, details.payer_address); } const completedSession = await this.checkoutSessionModule.CompleteCheckoutSession(session.id, { - transaction_signature: signature, - payer_wallet: verification.payer_address, + transaction_signature: details.signature, + payer_wallet: details.payer_address, }); if (completedSession.payment_link && this.paymentLinkModule) { @@ -622,9 +651,9 @@ export class CheckoutPaymentModule { const balanceTransaction = await this.RecordPaymentOnLedger( completedSession, - verification.amount_cents, - verification.payer_address, - signature + details.amount_cents, + details.payer_address, + details.signature ); if (charge && this.chargeModule) { @@ -636,7 +665,7 @@ export class CheckoutPaymentModule { Logger.info('Checkout session completed via payment', { checkoutSessionId: completedSession.id, - signature, + signature: details.signature, chargeId: charge?.id, }); diff --git a/apps/api/src/modules/ExternalWallet.ts b/apps/api/src/modules/ExternalWallet.ts index 3ddd7bb..edeb517 100644 --- a/apps/api/src/modules/ExternalWallet.ts +++ b/apps/api/src/modules/ExternalWallet.ts @@ -130,14 +130,14 @@ export class ExternalWalletModule { available_payout_methods: ['standard', 'instant'], created: Now(), country: '', - currency: input.currency ?? 'usdc', + currency: (input.currency ?? 'usdc').toLowerCase(), customer: null, default_for_currency: input.default_for_currency ?? null, fingerprint: null, future_requirements: null, last4: walletAddress.slice(-4), metadata: input.metadata ?? null, - network: input.network ?? 'solana', + network: (input.network ?? 'solana').toLowerCase(), requirements: null, status: 'new', wallet_address: walletAddress, diff --git a/apps/api/src/modules/Payout.ts b/apps/api/src/modules/Payout.ts index 47f9309..08e7463 100644 --- a/apps/api/src/modules/Payout.ts +++ b/apps/api/src/modules/Payout.ts @@ -15,7 +15,12 @@ import { ExternalWalletModule } from './ExternalWallet'; import { AccountModule } from './Account'; import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement'; import { GetPlatformAccountId } from './PlatformAccess'; -import { GetAppConfig } from './AppConfig'; +import { GetAppConfig, IsOrchestraLive } from './AppConfig'; +import { OrchestraModule } from './orchestra/OrchestraModule'; +import { + IsNativeSolanaUsdc, + IsOrchestraPayoutDest, +} from './orchestra/OrchestraRails'; import { GenerateId } from '../utils/IdGenerator'; import { Now } from '../utils/Timestamp'; import { AppError } from '../utils/AppError'; @@ -74,10 +79,16 @@ export class PayoutModule { private readonly balanceModule: BalanceModule; private readonly balanceTransactionModule: BalanceTransactionModule; private readonly solana: ReturnType; + private orchestraModule: OrchestraModule | null; - constructor(db: Database, eventService?: EventService) { + constructor( + db: Database, + eventService?: EventService, + orchestraModule?: OrchestraModule + ) { this.db = db; this.eventService = eventService || null; + this.orchestraModule = orchestraModule || null; this.accountModule = new AccountModule(db); this.externalWalletModule = new ExternalWalletModule(db); this.balanceModule = new BalanceModule(db); @@ -205,13 +216,23 @@ export class PayoutModule { wallets.find((w) => w.default_for_currency === true) || wallets[0]; } - // Verify the wallet address is valid on the Solana network - const walletExists = await this.solana.CheckWalletExists( - wallet.wallet_address - ); - if (!walletExists) { + if ( + !IsOrchestraPayoutDest(wallet.network, wallet.currency) && + IsNativeSolanaUsdc(wallet.network, wallet.currency) + ) { + const walletExists = await this.solana.CheckWalletExists( + wallet.wallet_address + ); + if (!walletExists) { + throw new AppError( + 'Destination wallet address is not valid on the Solana network', + 400, + 'invalid_request_error' + ); + } + } else if (!IsOrchestraPayoutDest(wallet.network, wallet.currency)) { throw new AppError( - 'Destination wallet address is not valid on the Solana network', + 'Unsupported payout destination', 400, 'invalid_request_error' ); @@ -637,9 +658,11 @@ export class PayoutModule { payouts.push(payout); } - // Build recipients list from payouts - const recipients: { destinationAddress: string; amountInCents: number }[] = - []; + const destKinds: Array<'native' | 'orchestra'> = []; + const walletsByPayoutId = new Map< + string, + { wallet_address: string; network: string; currency: string } + >(); for (const payout of payouts) { const wallet = await this.externalWalletModule.GetExternalWallet( @@ -654,18 +677,67 @@ export class PayoutModule { ); } + walletsByPayoutId.set(payout.id, wallet); + + if (IsNativeSolanaUsdc(wallet.network, wallet.currency)) { + destKinds.push('native'); + } else if (IsOrchestraPayoutDest(wallet.network, wallet.currency)) { + destKinds.push('orchestra'); + } else { + throw new AppError( + 'Unsupported payout destination', + 400, + 'invalid_request_error' + ); + } + } + + const hasOrchestra = destKinds.includes('orchestra'); + const hasNative = destKinds.includes('native'); + if (hasOrchestra && (payouts.length !== 1 || hasNative)) { + throw new AppError( + 'Orchestra payouts must be built one at a time and cannot be mixed with native solana:USDC dests', + 400, + 'invalid_request_error' + ); + } + + let orchestraIntent = null; + const recipients: { destinationAddress: string; amountInCents: number }[] = + []; + + if (hasOrchestra) { + orchestraIntent = await this.GetOrchestraModule().PreparePayout( + platformAccountId, + payouts[0] + ); + if (!orchestraIntent?.deposit_address) { + throw new AppError( + 'Orchestra did not return a deposit address for this payout', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } recipients.push({ - destinationAddress: wallet.wallet_address, - amountInCents: payout.amount, + destinationAddress: orchestraIntent.deposit_address, + amountInCents: payouts[0].amount, }); + const refreshed = await this.GetPayout(payouts[0].id); + if (refreshed) payouts[0] = refreshed; + } else { + for (const payout of payouts) { + const wallet = walletsByPayoutId.get(payout.id)!; + recipients.push({ + destinationAddress: wallet.wallet_address, + amountInCents: payout.amount, + }); + } } - // Get the platform's wallet public key from ExternalWallet const platformWalletPublicKey = await this.GetPlatformWalletPublicKey( platformAccountId ); - // Build the unsigned transaction const transactionData = await this.solana.BuildBatchPayoutTransaction( platformWalletPublicKey, recipients @@ -682,6 +754,7 @@ export class PayoutModule { payouts, total_amount: totalAmount, recipients_count: transactionData.recipients_count, + ...(orchestraIntent ? { orchestra: orchestraIntent } : {}), }; } @@ -764,21 +837,39 @@ export class PayoutModule { const updatedPayouts: PayoutType[] = []; if (result.status === 'paid') { - // Success: mark all payouts as paid + const hasOrchestra = payouts.some((payout) => !!payout.orchestra); + for (const payout of payouts) { - await this.MarkPayoutPaid(payout, { - network: IsSimulatedSettlement() ? 'simulated' : 'solana', - blockchain_tx: result.signature, - gas_fee: 0, // Fee was paid by platform, not deducted from payout - gas_fee_currency: 'sol', - viewer_url: result.viewer_url, - }); + if (payout.orchestra) { + await this.MarkPayoutFundingInTransit(payout, { + network: payout.orchestra.destination_chain, + blockchain_tx: result.signature, + viewer_url: result.viewer_url, + }); + } else { + await this.MarkPayoutPaid(payout, { + network: IsSimulatedSettlement() ? 'simulated' : 'solana', + blockchain_tx: result.signature, + gas_fee: 0, + gas_fee_currency: 'sol', + viewer_url: result.viewer_url, + }); + } const updatedPayout = await this.GetPayout(payout.id); if (updatedPayout) { updatedPayouts.push(updatedPayout); } } + + return { + object: 'payout_batch_broadcast', + signature: result.signature, + status: hasOrchestra ? 'in_transit' : 'paid', + viewer_url: result.viewer_url, + payouts: updatedPayouts, + failure_message: result.failure_message, + }; } else { // Failed: mark all payouts as failed and refund balances for (const payout of payouts) { @@ -805,6 +896,134 @@ export class PayoutModule { }; } + /** + * Refresh an Orchestra payout after the funding tx. Native dests are a no-op. + */ + async SyncPayout( + platformAccountId: string, + payoutId: string + ): Promise { + const payout = await this.RequirePlatformPayout(platformAccountId, payoutId); + + if (!payout.orchestra) { + return payout; + } + + if (payout.status === 'paid' || payout.status === 'canceled') { + return payout; + } + + if (!IsOrchestraLive()) { + await this.MarkPayoutPaid(payout, { + network: payout.orchestra.destination_chain, + blockchain_tx: + typeof payout.metadata?.blockchain_tx === 'string' + ? payout.metadata.blockchain_tx + : `orch_sim:${payout.id}`, + gas_fee: 0, + gas_fee_currency: 'sol', + viewer_url: + typeof payout.metadata?.viewer_url === 'string' + ? payout.metadata.viewer_url + : '', + }); + return (await this.GetPayout(payout.id)) ?? payout; + } + + const order = await this.GetOrchestraModule().RefreshPayoutStatus(payout); + if (!order) { + return payout; + } + + if (order.status === 'completed') { + await this.MarkPayoutPaid(payout, { + network: payout.orchestra.destination_chain, + blockchain_tx: + typeof payout.metadata?.blockchain_tx === 'string' + ? payout.metadata.blockchain_tx + : order.id ?? payout.id, + gas_fee: 0, + gas_fee_currency: 'sol', + viewer_url: + typeof payout.metadata?.viewer_url === 'string' + ? payout.metadata.viewer_url + : '', + }); + } else if (order.status === 'failed' || order.status === 'refunded') { + await this.MarkPayoutFailed( + payout, + 'blockchain_error', + 'Orchestra payout failed' + ); + } + + return (await this.GetPayout(payout.id)) ?? payout; + } + + private GetOrchestraModule(): OrchestraModule { + if (!this.orchestraModule) { + this.orchestraModule = new OrchestraModule(this.db); + } + return this.orchestraModule; + } + + private async RequirePlatformPayout( + platformAccountId: string, + payoutId: string + ): Promise { + const payout = await this.GetPayout(payoutId); + if (!payout) { + throw new AppError( + ERRORS.PAYOUT_NOT_FOUND.message, + ERRORS.PAYOUT_NOT_FOUND.status, + ERRORS.PAYOUT_NOT_FOUND.type + ); + } + + const payoutAccount = await this.accountModule.GetAccount(payout.account); + if (!payoutAccount) { + throw new AppError( + ERRORS.ACCOUNT_NOT_FOUND.message, + ERRORS.ACCOUNT_NOT_FOUND.status, + ERRORS.ACCOUNT_NOT_FOUND.type + ); + } + + if (GetPlatformAccountId(payoutAccount) !== platformAccountId) { + throw new AppError( + `Payout ${payoutId} does not belong to your platform`, + 403, + 'permission_denied' + ); + } + + return payout; + } + + /** + * Funding tx landed; Orchestra swap is still in flight. + */ + private async MarkPayoutFundingInTransit( + payout: PayoutType, + response: { + network: string; + blockchain_tx: string; + viewer_url: string; + } + ): Promise { + const orchestra = payout.orchestra + ? { ...payout.orchestra, status: 'processing' } + : payout.orchestra; + + await this.db.Update('Payouts', payout.id, { + status: 'in_transit', + orchestra, + 'metadata.network': response.network, + 'metadata.blockchain_tx': response.blockchain_tx, + 'metadata.viewer_url': response.viewer_url, + }); + } + /** * Mark a payout as paid and emit the payout.paid event. */ diff --git a/apps/api/src/modules/orchestra/OrchestraClient.ts b/apps/api/src/modules/orchestra/OrchestraClient.ts new file mode 100644 index 0000000..ec11560 --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraClient.ts @@ -0,0 +1,290 @@ +/** + * @fileOverview Thin Flashnet Orchestra HTTP client. + * Zoneless holds a server key (`fn_`) only. Never expose this to checkout FE. + * + * @module OrchestraClient + */ + +import { GetAppConfig, IsOrchestraLive } from '../AppConfig'; +import { AppError } from '../../utils/AppError'; +import { ERRORS } from '../../utils/Errors'; +import { Logger } from '../../utils/Logger'; + +export { IsOrchestraLive }; + +const REQUEST_TIMEOUT_MS = 15_000; + +export interface OrchestraOnrampInput { + destinationChain: string; + destinationAsset: string; + recipientAddress: string; + /** Decimal USD string Flashnet requires, e.g. `"10.00"`. */ + amountFiatUsd: string; + idempotencyKey: string; +} + +export interface OrchestraQuoteInput { + sourceChain: string; + sourceAsset: string; + destinationChain: string; + destinationAsset: string; + recipientAddress: string; + amount: string; + amountMode: string; + idempotencyKey: string; +} + +export interface OrchestraStatusInput { + orderId?: string; + quoteId?: string; +} + +export interface OrchestraRouteEndpoint { + chain?: string; + asset?: string; + assetDisplayName?: string; + chainDisplayName?: string; + contractAddress?: string | null; + decimals?: number; + chainId?: string | number | null; +} + +export interface OrchestraRoute { + sourceChain: string; + sourceAsset: string; + destinationChain: string; + destinationAsset: string; + source?: OrchestraRouteEndpoint; + destination?: OrchestraRouteEndpoint; +} + +/** Normalized partner order — Flashnet field names stay inside this client. */ +export interface OrchestraPartnerOrder { + id: string | null; + quoteId: string | null; + depositAddress: string | null; + depositMemo: string | null; + cashAppUrl: string | null; + amountIn: string | null; + estimatedOut: string | null; + amountOut: string | null; + destinationAddress: string | null; + sourceAddress: string | null; + expiresAt: string | null; + status: string | null; +} + +function ReadString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value; + } + return null; +} + +function AsRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function NormalizeOrder(payload: unknown): OrchestraPartnerOrder { + const data = AsRecord(payload) ?? {}; + const paymentLinks = + AsRecord(data.paymentLinks) ?? AsRecord(data.payment_links) ?? {}; + + return { + id: ReadString(data.orderId, data.order_id, data.id), + quoteId: ReadString(data.quoteId, data.quote_id), + depositAddress: ReadString(data.depositAddress, data.deposit_address), + depositMemo: ReadString(data.depositMemo, data.deposit_memo), + cashAppUrl: ReadString( + paymentLinks.cashApp, + paymentLinks.cash_app, + data.cashAppUrl, + data.cash_app_url + ), + amountIn: ReadString(data.amountIn, data.amount_in), + estimatedOut: ReadString(data.estimatedOut, data.estimated_out), + amountOut: ReadString(data.amountOut, data.amount_out), + destinationAddress: ReadString( + data.destinationAddress, + data.destination_address + ), + sourceAddress: ReadString(data.sourceAddress, data.source_address), + expiresAt: ReadString(data.expiresAt, data.expires_at), + status: ReadString(data.status)?.toLowerCase() ?? null, + }; +} + +function Unavailable(): AppError { + return new AppError( + ERRORS.ORCHESTRA_UNAVAILABLE.message, + ERRORS.ORCHESTRA_UNAVAILABLE.status, + ERRORS.ORCHESTRA_UNAVAILABLE.type + ); +} + +function ReadPartnerError(body: string): string | null { + try { + const parsed = JSON.parse(body) as { + error?: { message?: string }; + message?: string; + }; + return parsed.error?.message || parsed.message || null; + } catch { + return null; + } +} + +export class OrchestraClient { + private readonly apiUrl: string; + private readonly apiKey: string; + + constructor(options?: { apiUrl?: string; apiKey?: string }) { + const config = GetAppConfig(); + this.apiUrl = (options?.apiUrl ?? config.orchestraApiUrl ?? '').replace( + /\/$/, + '' + ); + this.apiKey = options?.apiKey ?? config.orchestraApiKey ?? ''; + } + + IsOrchestraLive(): boolean { + return IsOrchestraLive(); + } + + async CreateOnramp( + input: OrchestraOnrampInput + ): Promise { + return this.PostJson( + '/v1/orchestration/onramp', + { + destinationChain: input.destinationChain, + destinationAsset: input.destinationAsset, + recipientAddress: input.recipientAddress, + amountFiatUsd: input.amountFiatUsd, + }, + input.idempotencyKey + ); + } + + async CreateQuote(input: OrchestraQuoteInput): Promise { + return this.PostJson( + '/v1/orchestration/quote', + { + sourceChain: input.sourceChain, + sourceAsset: input.sourceAsset, + destinationChain: input.destinationChain, + destinationAsset: input.destinationAsset, + recipientAddress: input.recipientAddress, + amount: input.amount, + amountMode: input.amountMode, + }, + input.idempotencyKey + ); + } + + async ListRoutes(): Promise { + const payload = await this.RequestJson('/v1/orchestration/routes', { + method: 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }); + const data = AsRecord(payload); + const routes = data?.routes; + if (!Array.isArray(routes)) return []; + return routes as OrchestraRoute[]; + } + + async GetOrderStatus( + input: OrchestraStatusInput + ): Promise { + const attempts: string[] = []; + if (input.orderId) { + attempts.push(`id=${encodeURIComponent(input.orderId)}`); + } + if (input.quoteId) { + attempts.push(`quoteId=${encodeURIComponent(input.quoteId)}`); + } + if (attempts.length === 0) { + throw new AppError( + 'An Orchestra order or quote id is required', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + let lastError: unknown = null; + for (const query of attempts) { + try { + return await this.GetJson(`/v1/orchestration/status?${query}`); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof AppError ? lastError : Unavailable(); + } + + private async PostJson( + path: string, + body: Record, + idempotencyKey: string + ): Promise { + return NormalizeOrder( + await this.RequestJson(path, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'X-Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }) + ); + } + + private async GetJson(path: string): Promise { + return NormalizeOrder( + await this.RequestJson(path, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }) + ); + } + + private async RequestJson( + path: string, + init: RequestInit + ): Promise { + if (!this.apiUrl || !this.apiKey) { + throw Unavailable(); + } + + try { + const response = await fetch(`${this.apiUrl}${path}`, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + const body = await response.text(); + Logger.warn('Orchestra request failed', { + url: `${this.apiUrl}${path}`, + status: response.status, + body: body.slice(0, 500), + }); + const partnerMessage = ReadPartnerError(body); + throw new AppError( + partnerMessage || ERRORS.ORCHESTRA_UNAVAILABLE.message, + ERRORS.ORCHESTRA_UNAVAILABLE.status, + ERRORS.ORCHESTRA_UNAVAILABLE.type + ); + } + return await response.json(); + } catch (error) { + if (error instanceof AppError) throw error; + throw Unavailable(); + } + } +} diff --git a/apps/api/src/modules/orchestra/OrchestraModule.ts b/apps/api/src/modules/orchestra/OrchestraModule.ts new file mode 100644 index 0000000..b88396a --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraModule.ts @@ -0,0 +1,542 @@ +/** + * @fileOverview Flashnet Orchestra adapter for Cash App / deposit pay-in + * and cross-chain payouts. Additive to native solana:USDC settlement. + * + * @module Orchestra + */ + +import { + CheckoutSession, + ExternalWallet, + OrchestraIntent, + OrchestraPayinStartResponse, + OrchestraPayoutIntent, + Payout, +} from '@zoneless/shared-types'; +import { StartOrchestraPayinInput } from '@zoneless/shared-schemas'; +import { Database } from '../Database'; +import { CheckoutSessionModule } from '../CheckoutSession'; +import { CheckoutPaymentModule } from '../CheckoutPayment'; +import { ExternalWalletModule } from '../ExternalWallet'; +import { IsOrchestraLive } from '../AppConfig'; +import { AppError } from '../../utils/AppError'; +import { ERRORS } from '../../utils/Errors'; +import { Now } from '../../utils/Timestamp'; +import { Logger } from '../../utils/Logger'; +import { OrchestraClient, OrchestraPartnerOrder } from './OrchestraClient'; +import { + CentsToFiatUsd, + CentsToUsdcSmallest, + IsNativeSolanaUsdc, + IsOrchestraPayinSource, + IsOrchestraPayoutDest, + NormalizeAsset, + NormalizeChain, + RefreshOrchestraRoutes, + SimulatedDepositAddress, + ToFlashnetAsset, + UsdcSmallestToCents, +} from './OrchestraRails'; + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'refunded']); + +export class OrchestraModule { + private readonly db: Database; + private readonly checkoutSessionModule: CheckoutSessionModule; + private readonly externalWalletModule: ExternalWalletModule; + private readonly checkoutPaymentModule: CheckoutPaymentModule | null; + private readonly client: OrchestraClient; + + constructor( + db: Database, + checkoutSessionModule?: CheckoutSessionModule, + externalWalletModule?: ExternalWalletModule, + checkoutPaymentModule?: CheckoutPaymentModule, + client?: OrchestraClient + ) { + this.db = db; + this.checkoutSessionModule = + checkoutSessionModule ?? new CheckoutSessionModule(db); + this.externalWalletModule = + externalWalletModule ?? new ExternalWalletModule(db); + this.checkoutPaymentModule = checkoutPaymentModule ?? null; + this.client = client ?? new OrchestraClient(); + } + + /** + * Start a Cash App or deposit pay-in. Destination is always the platform + * solana USDC wallet — never taken from the client. + */ + async StartPayin( + urlSlug: string, + input: StartOrchestraPayinInput + ): Promise { + const session = await this.RequirePayablePaymentSession(urlSlug); + const platformWallet = await this.RequirePlatformWallet( + session.platform_account + ); + + const method = input.method; + await RefreshOrchestraRoutes(this.client); + const sourceChain = + method === 'deposit' + ? NormalizeChain(input.source_chain || '') + : 'lightning'; + const sourceAsset = + method === 'deposit' + ? NormalizeAsset(input.source_asset || '') + : 'usd'; + + if (method === 'deposit' && !IsOrchestraPayinSource(sourceChain, sourceAsset)) { + throw new AppError( + 'Unsupported Orchestra deposit source', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + const existing = session.orchestra; + if ( + existing && + existing.method === method && + (method === 'cashapp' || + (existing.source_chain === sourceChain && + existing.source_asset === sourceAsset)) && + existing.status && + !TERMINAL_STATUSES.has(existing.status) + ) { + return this.PayinResponse(session, existing); + } + + const amountIn = CentsToUsdcSmallest(session.amount_total!); + let intent: OrchestraIntent; + + if (!IsOrchestraLive()) { + intent = + method === 'cashapp' + ? { + method, + source_chain: sourceChain, + source_asset: sourceAsset, + quote_id: null, + operation_id: null, + deposit_address: 'lnbc1sim', + deposit_memo: null, + cash_app_url: `https://cash.app/launch/lightning/sim-${session.id}`, + amount_in: amountIn, + estimated_out: amountIn, + expires_at: null, + status: 'awaiting_deposit', + } + : { + method, + source_chain: sourceChain, + source_asset: sourceAsset, + quote_id: null, + operation_id: null, + deposit_address: SimulatedDepositAddress(sourceChain), + deposit_memo: null, + cash_app_url: null, + amount_in: amountIn, + estimated_out: amountIn, + expires_at: null, + status: 'awaiting_deposit', + }; + } else if (method === 'cashapp') { + if (session.amount_total! < 100) { + throw new AppError( + 'Cash App pay-in requires at least $1.00', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + const order = await this.client.CreateOnramp({ + destinationChain: 'solana', + destinationAsset: 'USDC', + recipientAddress: platformWallet.wallet_address, + amountFiatUsd: CentsToFiatUsd(session.amount_total!), + idempotencyKey: `zoneless:payin:${session.id}:cashapp`, + }); + intent = this.IntentFromOrder(method, sourceChain, sourceAsset, order); + } else { + const order = await this.client.CreateQuote({ + sourceChain, + sourceAsset: ToFlashnetAsset(sourceAsset), + destinationChain: 'solana', + destinationAsset: 'USDC', + recipientAddress: platformWallet.wallet_address, + amount: amountIn, + amountMode: 'exact_in', + idempotencyKey: `zoneless:payin:${session.id}:${sourceChain}:${sourceAsset}`, + }); + intent = this.IntentFromOrder(method, sourceChain, sourceAsset, order); + } + + await this.db.Update('CheckoutSessions', session.id, { + orchestra: intent, + }); + + Logger.info('Started Orchestra pay-in', { + checkoutSessionId: session.id, + method, + sourceChain, + sourceAsset, + }); + + return this.PayinResponse(session, intent); + } + + /** + * Refresh a live pay-in if we have a quote/operation id; return current intent. + */ + async GetPayin(urlSlug: string): Promise { + const session = await this.RequireSession(urlSlug); + if (!session.orchestra) { + throw new AppError( + 'This Checkout Session has no Orchestra pay-in', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + const intent = await this.RefreshPayinIntent(session); + return this.PayinResponse(session, intent); + } + + /** + * Complete checkout after Orchestra settles. Simulated treats the session + * as paid; live requires partner status `completed` for the full amount. + */ + async ConfirmPayin(urlSlug: string): Promise { + if (!this.checkoutPaymentModule) { + throw new AppError( + ERRORS.INTERNAL_ERROR.message, + ERRORS.INTERNAL_ERROR.status, + ERRORS.INTERNAL_ERROR.type + ); + } + + const session = await this.RequireSession(urlSlug); + if (session.status === 'complete') { + return this.checkoutPaymentModule.CompleteVerifiedPayment(session, { + amount_cents: session.amount_total ?? 0, + signature: + session.payment_details?.transaction_signature ?? + `orch_sim:${session.id}`, + payer_address: + session.payment_details?.payer_wallet ?? 'orchestra:simulated', + }); + } + + this.AssertPayablePaymentSession(session); + if (!session.orchestra) { + throw new AppError( + 'This Checkout Session has no Orchestra pay-in', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + let amountCents = session.amount_total!; + let signature = `orch_sim:${session.id}`; + let payerAddress = 'orchestra:simulated'; + + if (IsOrchestraLive()) { + const order = await this.RequireLiveOrderStatus(session.orchestra); + if (order.status !== 'completed') { + throw new AppError( + 'Payment has not settled for the full amount yet', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + if (order.amountOut) { + amountCents = UsdcSmallestToCents(order.amountOut); + } + if (amountCents < session.amount_total!) { + throw new AppError( + 'Payment has not settled for the full amount yet', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + signature = `orch:${order.id ?? session.orchestra.operation_id ?? session.id}`; + payerAddress = order.sourceAddress + ? `orchestra:${order.sourceAddress}` + : `orchestra:${order.id ?? 'live'}`; + } + + const intent: OrchestraIntent = { + ...session.orchestra, + status: 'completed', + }; + await this.db.Update('CheckoutSessions', session.id, { + orchestra: intent, + }); + + return this.checkoutPaymentModule.CompleteVerifiedPayment(session, { + amount_cents: amountCents, + signature, + payer_address: payerAddress, + }); + } + + /** + * Quote solana USDC → seller dest. Native solana/usdc returns null so + * the caller keeps the existing payout path. + */ + async PreparePayout( + platformAccountId: string, + payout: Payout + ): Promise { + const destWallet = await this.externalWalletModule.GetExternalWallet( + payout.destination + ); + if (!destWallet) { + throw new AppError( + `External wallet ${payout.destination} not found for payout ${payout.id}`, + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + if (IsNativeSolanaUsdc(destWallet.network, destWallet.currency)) { + return null; + } + + await RefreshOrchestraRoutes(this.client); + + if (!IsOrchestraPayoutDest(destWallet.network, destWallet.currency)) { + throw new AppError( + 'Unsupported Orchestra payout destination', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + const destinationChain = NormalizeChain(destWallet.network); + const destinationAsset = NormalizeAsset(destWallet.currency); + const amountIn = CentsToUsdcSmallest(payout.amount); + + if ( + payout.orchestra?.deposit_address && + payout.orchestra.destination_chain === destinationChain && + payout.orchestra.destination_asset === destinationAsset && + payout.orchestra.status && + !TERMINAL_STATUSES.has(payout.orchestra.status) + ) { + return payout.orchestra; + } + + let intent: OrchestraPayoutIntent; + + if (!IsOrchestraLive()) { + const platformWallet = await this.RequirePlatformWallet(platformAccountId); + intent = { + quote_id: null, + operation_id: null, + deposit_address: platformWallet.wallet_address, + amount_in: amountIn, + estimated_out: amountIn, + destination_chain: destinationChain, + destination_asset: destinationAsset, + status: 'awaiting_deposit', + }; + } else { + const order = await this.client.CreateQuote({ + sourceChain: 'solana', + sourceAsset: 'USDC', + destinationChain, + destinationAsset: ToFlashnetAsset(destinationAsset), + recipientAddress: destWallet.wallet_address, + amount: amountIn, + amountMode: 'exact_in', + idempotencyKey: `zoneless:payout:${payout.id}`, + }); + intent = { + quote_id: order.quoteId, + operation_id: order.id, + deposit_address: order.depositAddress, + amount_in: order.amountIn ?? amountIn, + estimated_out: order.estimatedOut, + destination_chain: destinationChain, + destination_asset: destinationAsset, + status: order.status ?? 'quoted', + }; + } + + await this.db.Update('Payouts', payout.id, { + orchestra: intent, + 'metadata.network': destinationChain, + } as Partial); + + return intent; + } + + async RefreshPayoutStatus( + payout: Payout + ): Promise { + if (!payout.orchestra) return null; + if (!IsOrchestraLive()) return null; + return this.RequireLiveOrderStatus(payout.orchestra); + } + + private async RefreshPayinIntent( + session: CheckoutSession + ): Promise { + const current = session.orchestra!; + if ( + !IsOrchestraLive() || + (!current.quote_id && !current.operation_id) + ) { + return current; + } + + const order = await this.client.GetOrderStatus({ + orderId: current.operation_id ?? undefined, + quoteId: current.quote_id ?? undefined, + }); + const intent: OrchestraIntent = { + ...current, + status: this.MapPartnerStatus(order.status) ?? current.status, + estimated_out: order.estimatedOut ?? order.amountOut ?? current.estimated_out, + deposit_address: order.depositAddress ?? current.deposit_address, + cash_app_url: order.cashAppUrl ?? current.cash_app_url, + }; + await this.db.Update('CheckoutSessions', session.id, { + orchestra: intent, + }); + return intent; + } + + private async RequireLiveOrderStatus(intent: { + quote_id?: string | null; + operation_id?: string | null; + }): Promise { + if (!intent.operation_id && !intent.quote_id) { + throw new AppError( + 'This Orchestra intent is missing a partner id', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + return this.client.GetOrderStatus({ + orderId: intent.operation_id ?? undefined, + quoteId: intent.quote_id ?? undefined, + }); + } + + private IntentFromOrder( + method: OrchestraIntent['method'], + sourceChain: string, + sourceAsset: string, + order: OrchestraPartnerOrder + ): OrchestraIntent { + return { + method, + source_chain: sourceChain, + source_asset: sourceAsset, + quote_id: order.quoteId, + operation_id: order.id, + deposit_address: order.depositAddress, + deposit_memo: order.depositMemo, + cash_app_url: order.cashAppUrl, + amount_in: order.amountIn, + estimated_out: order.estimatedOut, + expires_at: order.expiresAt, + status: this.MapPartnerStatus(order.status) ?? 'awaiting_deposit', + }; + } + + private MapPartnerStatus(status: string | null): string | null { + if (!status) return null; + if (status === 'refunded') return 'failed'; + if (status === 'quoted') return 'quoted'; + if (status === 'completed' || status === 'processing' || status === 'failed') { + return status; + } + return 'processing'; + } + + private PayinResponse( + session: CheckoutSession, + intent: OrchestraIntent + ): OrchestraPayinStartResponse { + return { + object: 'orchestra_payin', + checkout_session: { ...session, orchestra: intent }, + intent, + }; + } + + private async RequireSession(urlSlug: string): Promise { + const session = + await this.checkoutSessionModule.GetCheckoutSessionByUrlSlug(urlSlug); + if (!session) { + throw new AppError( + ERRORS.CHECKOUT_SESSION_NOT_FOUND.message, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.status, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.type + ); + } + return session; + } + + private async RequirePayablePaymentSession( + urlSlug: string + ): Promise { + const session = await this.RequireSession(urlSlug); + this.AssertPayablePaymentSession(session); + return session; + } + + private AssertPayablePaymentSession(session: CheckoutSession): void { + if (session.mode !== 'payment') { + throw new AppError( + 'Orchestra pay-in is only available for payment-mode Checkout Sessions', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + if (session.status !== 'open' || session.payment_status !== 'unpaid') { + throw new AppError( + 'This Checkout Session is no longer accepting payments', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + if (session.expires_at && session.expires_at < Now()) { + throw new AppError( + 'This Checkout Session has expired', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + + if (!session.amount_total || session.amount_total <= 0) { + throw new AppError( + 'This Checkout Session has no amount due', + ERRORS.INVALID_REQUEST.status, + ERRORS.INVALID_REQUEST.type + ); + } + } + + private async RequirePlatformWallet( + platformAccountId: string + ): Promise { + const wallet = await this.externalWalletModule.GetDefaultWallet( + platformAccountId + ); + if (!wallet) { + throw new AppError( + 'The merchant has no wallet configured to receive payments', + ERRORS.VALIDATION_ERROR.status, + 'no_wallet_configured' + ); + } + return wallet; + } +} diff --git a/apps/api/src/modules/orchestra/OrchestraRails.ts b/apps/api/src/modules/orchestra/OrchestraRails.ts new file mode 100644 index 0000000..86b30b5 --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraRails.ts @@ -0,0 +1,276 @@ +/** + * @fileOverview Orchestra stables from Flashnet's live route table. + * Books stay USDC.sol; convert only at the Flashnet edges. + * + * @module OrchestraRails + */ + +import { OrchestraSource } from '@zoneless/shared-types'; +import { AppError } from '../../utils/AppError'; +import { ERRORS } from '../../utils/Errors'; +import { IsOrchestraLive } from '../AppConfig'; +import { + OrchestraClient, + OrchestraRoute, + OrchestraRouteEndpoint, +} from './OrchestraClient'; + +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +const LEDGER_CHAIN = 'solana'; +const LEDGER_ASSET = 'usdc'; +const STABLE_ASSETS = new Set(['usdc', 'usdt']); +const REQUIRED_DECIMALS = 6; +const ROUTE_CACHE_MS = 10 * 60 * 1000; + +/** + * Offline / simulated snapshot of 6-decimal USDC/USDT pairs that actually + * route both ways with solana:USDC. Pulled from GET /v1/orchestration/routes. + * Optimism USDT exists on Flashnet only as BTC/Lightning/Spark, not Solana. + */ +const FALLBACK_SOURCES: OrchestraSource[] = [ + { chain: 'arbitrum', asset: 'usdc', label: 'USDC on Arbitrum' }, + { chain: 'arbitrum', asset: 'usdt', label: 'USDT on Arbitrum' }, + { chain: 'base', asset: 'usdc', label: 'USDC on Base' }, + { chain: 'base', asset: 'usdt', label: 'USDT on Base' }, + { chain: 'ethereum', asset: 'usdc', label: 'USDC on Ethereum' }, + { chain: 'ethereum', asset: 'usdt', label: 'USDT on Ethereum' }, + { chain: 'hyperevm', asset: 'usdc', label: 'USDC on HyperEVM' }, + { chain: 'polygon', asset: 'usdc', label: 'USDC on Polygon' }, + { chain: 'polygon', asset: 'usdt', label: 'USDT on Polygon' }, + { chain: 'solana', asset: 'usdt', label: 'USDT on Solana' }, + { chain: 'tron', asset: 'usdt', label: 'USDT on Tron' }, +]; + +let cachedSources: OrchestraSource[] = FALLBACK_SOURCES; +let cachedAt = 0; +let cacheIsLive = false; +let refreshInFlight: Promise | null = null; + +/** 1 USDC = 1e6 smallest units = 100 cents → 1 cent = 10_000 smallest units. */ +const SMALLEST_PER_CENT = 10_000; + +export function ListOrchestraPayinSources(): OrchestraSource[] { + return [...cachedSources]; +} + +export function IsNativeSolanaUsdc( + network?: string | null, + currency?: string | null +): boolean { + return ( + NormalizeChain(network || 'solana') === LEDGER_CHAIN && + NormalizeAsset(currency || 'usdc') === LEDGER_ASSET + ); +} + +export function IsOrchestraPayoutDest( + network?: string | null, + currency?: string | null +): boolean { + const chain = NormalizeChain(network || ''); + const asset = NormalizeAsset(currency || ''); + return cachedSources.some( + (source) => source.chain === chain && source.asset === asset + ); +} + +export function IsOrchestraPayinSource(chain: string, asset: string): boolean { + const normalizedChain = NormalizeChain(chain); + const normalizedAsset = NormalizeAsset(asset); + return cachedSources.some( + (source) => + source.chain === normalizedChain && source.asset === normalizedAsset + ); +} + +/** Lowercase internal chain id (base, arbitrum, ethereum, …). */ +export function NormalizeChain(network: string): string { + return network.trim().toLowerCase(); +} + +/** + * Lowercase for Zoneless storage (usdc). Uppercase at the Flashnet boundary (USDC). + */ +export function NormalizeAsset(currency: string): string { + return currency.trim().toLowerCase(); +} + +export function ToFlashnetAsset(currency: string): string { + return NormalizeAsset(currency).toUpperCase(); +} + +export function CentsToUsdcSmallest(cents: number): string { + return String(Math.trunc(cents) * SMALLEST_PER_CENT); +} + +/** Flashnet `amountFiatUsd` is a decimal string, e.g. `"10.00"`. */ +export function CentsToFiatUsd(cents: number): string { + return (Math.trunc(cents) / 100).toFixed(2); +} + +export function UsdcSmallestToCents(amount: string): number { + const parsed = Number(amount); + if (!Number.isFinite(parsed)) return 0; + return Math.floor(parsed / SMALLEST_PER_CENT); +} + +export function DeriveOrchestraStableSources( + routes: OrchestraRoute[] +): OrchestraSource[] { + const payin = new Map(); + const payout = new Set(); + + for (const route of routes) { + const sourceChain = NormalizeChain(route.sourceChain || ''); + const sourceAsset = NormalizeAsset(route.sourceAsset || ''); + const destChain = NormalizeChain(route.destinationChain || ''); + const destAsset = NormalizeAsset(route.destinationAsset || ''); + + if ( + destChain === LEDGER_CHAIN && + destAsset === LEDGER_ASSET && + STABLE_ASSETS.has(sourceAsset) && + !IsNativeSolanaUsdc(sourceChain, sourceAsset) && + HasRequiredDecimals(route.source) && + IsAddressableStable(sourceChain, route.source) + ) { + const key = `${sourceChain}:${sourceAsset}`; + payin.set(key, { + chain: sourceChain, + asset: sourceAsset, + label: SourceLabel(sourceChain, sourceAsset, route.source), + }); + } + + if ( + sourceChain === LEDGER_CHAIN && + sourceAsset === LEDGER_ASSET && + STABLE_ASSETS.has(destAsset) && + !IsNativeSolanaUsdc(destChain, destAsset) && + HasRequiredDecimals(route.destination) && + IsAddressableStable(destChain, route.destination) + ) { + payout.add(`${destChain}:${destAsset}`); + } + } + + return [...payin.values()] + .filter((source) => payout.has(`${source.chain}:${source.asset}`)) + .sort( + (left, right) => + left.chain.localeCompare(right.chain) || + left.asset.localeCompare(right.asset) + ); +} + +export function ApplyOrchestraRoutes(routes: OrchestraRoute[]): OrchestraSource[] { + const derived = DeriveOrchestraStableSources(routes); + if (derived.length > 0) { + cachedSources = derived; + cachedAt = Date.now(); + cacheIsLive = true; + } + return [...cachedSources]; +} + +export function ResetOrchestraRouteCache(): void { + cachedSources = FALLBACK_SOURCES; + cachedAt = 0; + cacheIsLive = false; +} + +export async function RefreshOrchestraRoutes( + client?: OrchestraClient +): Promise { + if (!IsOrchestraLive()) return; + if (cacheIsLive && Date.now() - cachedAt < ROUTE_CACHE_MS) return; + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + try { + const orchestra = client ?? new OrchestraClient(); + ApplyOrchestraRoutes(await orchestra.ListRoutes()); + } catch { + // Keep the last good list (fallback or previous live pull). + } finally { + refreshInFlight = null; + } + })(); + + return refreshInFlight; +} + +export function ValidateWalletAddress(network: string, address: string): void { + const chain = NormalizeChain(network); + const value = address.trim(); + + if (chain === 'solana') { + if (value.length < 32 || value.length > 44 || !BASE58_RE.test(value)) { + throw new AppError( + 'Wallet address must be a valid base58 Solana address', + ERRORS.VALIDATION_ERROR.status, + ERRORS.VALIDATION_ERROR.type + ); + } + return; + } + + if (chain === 'tron') { + if (!value.startsWith('T') || !BASE58_RE.test(value)) { + throw new AppError( + 'Wallet address must be a valid Tron address', + ERRORS.VALIDATION_ERROR.status, + ERRORS.VALIDATION_ERROR.type + ); + } + return; + } + + if (EVM_ADDRESS_RE.test(value)) { + return; + } + + throw new AppError( + 'Wallet address must be a valid EVM address', + ERRORS.VALIDATION_ERROR.status, + ERRORS.VALIDATION_ERROR.type + ); +} + +export function SimulatedDepositAddress(chain: string): string { + const normalized = NormalizeChain(chain); + if (normalized === 'tron') { + return 'TSimu1atedOrchestraDepositAddr111'; + } + if (normalized === 'solana') { + return 'Sim1rchDeposit111111111111111111111111111'; + } + return '0xSimulatedOrchestraDeposit0000000000000001'; +} + +function HasRequiredDecimals(endpoint?: OrchestraRouteEndpoint): boolean { + return (endpoint?.decimals ?? REQUIRED_DECIMALS) === REQUIRED_DECIMALS; +} + +function IsAddressableStable( + chain: string, + endpoint?: OrchestraRouteEndpoint +): boolean { + if (chain === 'solana' || chain === 'tron') return true; + const chainId = String(endpoint?.chainId ?? ''); + const contract = endpoint?.contractAddress ?? ''; + return /^\d+$/.test(chainId) && EVM_ADDRESS_RE.test(contract); +} + +function SourceLabel( + chain: string, + asset: string, + endpoint?: OrchestraRouteEndpoint +): string { + const chainLabel = + endpoint?.chainDisplayName?.trim() || + chain.charAt(0).toUpperCase() + chain.slice(1); + return `${asset.toUpperCase()} on ${chainLabel}`; +} diff --git a/apps/api/src/routes/config.routes.ts b/apps/api/src/routes/config.routes.ts index 835ed8e..4be9c33 100644 --- a/apps/api/src/routes/config.routes.ts +++ b/apps/api/src/routes/config.routes.ts @@ -20,8 +20,16 @@ import { AsyncHandler } from '../utils/AsyncHandler'; import { AppError } from '../utils/AppError'; import { ERRORS } from '../utils/Errors'; import { VerifyToken } from '../utils/Token'; -import { GetJwtSecret, GetAppConfig } from '../modules/AppConfig'; +import { + GetJwtSecret, + GetAppConfig, + IsOrchestraLive, +} from '../modules/AppConfig'; import { SolanaExplorerUrl } from '../modules/chains/Solana'; +import { + ListOrchestraPayinSources, + RefreshOrchestraRoutes, +} from '../modules/orchestra/OrchestraRails'; const router = express.Router(); @@ -33,9 +41,18 @@ const apiKeyModule = new ApiKeyModule(db); /** * Helper to build PublicConfig from a platform account. */ -function BuildPublicConfig(platformAccount: Account | null): PublicConfig { +async function BuildPublicConfig( + platformAccount: Account | null +): Promise { + await RefreshOrchestraRoutes(); const { livemode, settlement_rail } = GetAppConfig(); const settlement = settlement_rail ?? 'simulated'; + const orchestraLive = IsOrchestraLive(); + const orchestra = { + enabled: orchestraLive || settlement === 'simulated', + live: orchestraLive, + sources: ListOrchestraPayinSources(), + }; if (!platformAccount) { return { @@ -46,6 +63,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { privacy_url: '', livemode, settlement, + orchestra, }; } @@ -60,6 +78,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { privacy_url: platformAccount.settings?.privacy_url || '', livemode, settlement, + orchestra, }; } @@ -139,7 +158,7 @@ router.get( } } - res.json(BuildPublicConfig(platformAccount)); + res.json(await BuildPublicConfig(platformAccount)); }) ); diff --git a/apps/api/src/routes/paymentPages.routes.ts b/apps/api/src/routes/paymentPages.routes.ts index 9bb9c39..c3133c0 100644 --- a/apps/api/src/routes/paymentPages.routes.ts +++ b/apps/api/src/routes/paymentPages.routes.ts @@ -16,8 +16,12 @@ import { InvoiceItemModule } from '../modules/InvoiceItem'; import { InvoiceModule } from '../modules/Invoice'; import { SubscriptionModule } from '../modules/Subscription'; +import { OrchestraModule } from '../modules/orchestra/OrchestraModule'; import { ValidateRequest } from '../middleware/ValidateRequest'; -import { PrepareCheckoutPaymentSchema } from '@zoneless/shared-schemas'; +import { + PrepareCheckoutPaymentSchema, + StartOrchestraPayinSchema, +} from '@zoneless/shared-schemas'; const router = express.Router(); @@ -81,6 +85,12 @@ const checkoutPaymentModule = new CheckoutPaymentModule( customerModule, subscriptionModule ); +const orchestraModule = new OrchestraModule( + db, + checkoutSessionModule, + externalWalletModule, + checkoutPaymentModule +); /** * POST /v1/payment_pages/from_payment_link/:urlSlug @@ -96,6 +106,47 @@ router.post( }) ); +/** + * POST /v1/payment_pages/:urlSlug/orchestra + * Start a Cash App or deposit pay-in. Public: url_slug is the credential. + */ +router.post( + '/:urlSlug/orchestra', + ValidateRequest(StartOrchestraPayinSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const result = await orchestraModule.StartPayin(req.params.urlSlug, { + method: req.body.method, + source_chain: req.body.source_chain, + source_asset: req.body.source_asset, + }); + res.json(result); + }) +); + +/** + * GET /v1/payment_pages/:urlSlug/orchestra + * Current Orchestra pay-in intent, refreshing live status when configured. + */ +router.get( + '/:urlSlug/orchestra', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const result = await orchestraModule.GetPayin(req.params.urlSlug); + res.json(result); + }) +); + +/** + * POST /v1/payment_pages/:urlSlug/orchestra/confirm + * Complete checkout after Orchestra reports the pay-in settled. + */ +router.post( + '/:urlSlug/orchestra/confirm', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const session = await orchestraModule.ConfirmPayin(req.params.urlSlug); + res.json(session); + }) +); + /** * GET /v1/payment_pages/:urlSlug * Public bootstrap endpoint for the hosted checkout page, mirroring Stripe's diff --git a/apps/api/src/routes/payouts.routes.ts b/apps/api/src/routes/payouts.routes.ts index f9aa3c1..36514c6 100644 --- a/apps/api/src/routes/payouts.routes.ts +++ b/apps/api/src/routes/payouts.routes.ts @@ -210,6 +210,24 @@ router.post( }) ); +/** + * POST /v1/payouts/:id/sync + * Refresh an Orchestra payout after the funding transaction. Native dests + * are a no-op. Platform authentication is required. + */ +router.post( + '/:id/sync', + RequirePlatform(), + requirePayoutOwnership, + AsyncHandler(async (req: express.Request, res: express.Response) => { + const payout = await payoutModule.SyncPayout( + req.user.account, + req.params.id + ); + res.json(payout); + }) +); + /** * POST /v1/payouts/:id * Update a payout's metadata. diff --git a/apps/api/src/utils/Errors.ts b/apps/api/src/utils/Errors.ts index 94d3194..8f13cd4 100644 --- a/apps/api/src/utils/Errors.ts +++ b/apps/api/src/utils/Errors.ts @@ -222,6 +222,11 @@ export const ERRORS = { status: 500, type: 'internal_server_error', }, + ORCHESTRA_UNAVAILABLE: { + message: 'Orchestra is unavailable', + status: 502, + type: 'api_error', + }, } as const; export type ErrorCode = keyof typeof ERRORS; diff --git a/apps/web/src/app/data/services/checkout-session.service.ts b/apps/web/src/app/data/services/checkout-session.service.ts index 7b60d5d..57092c7 100644 --- a/apps/web/src/app/data/services/checkout-session.service.ts +++ b/apps/web/src/app/data/services/checkout-session.service.ts @@ -6,6 +6,34 @@ import { UpdateCheckoutSessionInput, } from '@zoneless/shared-schemas'; +export type OrchestraCheckoutMethod = 'cashapp' | 'deposit'; + +/** Orchestra fields on a hosted checkout session. Local until shared-types lands. */ +export interface CheckoutSessionOrchestra { + method: OrchestraCheckoutMethod; + source_chain: string; + source_asset: string; + quote_id: string | null; + operation_id: string | null; + deposit_address: string | null; + deposit_memo: string | null; + cash_app_url: string | null; + amount_in: string | null; + estimated_out: string | null; + expires_at: string | null; + status: string | null; +} + +export type OrchestraCheckoutSession = CheckoutSession & { + orchestra?: CheckoutSessionOrchestra; +}; + +export interface OrchestraCheckoutResponse { + object: string; + checkout_session: OrchestraCheckoutSession; + intent?: unknown; +} + /** Unsigned payment transaction returned by the public prepare endpoint. */ export interface CheckoutPaymentTransaction { object: 'checkout.payment_transaction'; @@ -190,4 +218,35 @@ export class CheckoutSessionService { payload ); } + + async StartOrchestraPayment( + urlSlug: string, + payload: { + method: OrchestraCheckoutMethod; + source_chain?: string; + source_asset?: string; + } + ): Promise { + return this.api.Call( + 'POST', + `payment_pages/${urlSlug}/orchestra`, + payload + ); + } + + async GetOrchestraPayment( + urlSlug: string + ): Promise { + return this.api.Call( + 'GET', + `payment_pages/${urlSlug}/orchestra` + ); + } + + async ConfirmOrchestraPayment(urlSlug: string): Promise { + return this.api.Call( + 'POST', + `payment_pages/${urlSlug}/orchestra/confirm` + ); + } } diff --git a/apps/web/src/app/data/services/config.service.ts b/apps/web/src/app/data/services/config.service.ts index 4b65038..5a020aa 100644 --- a/apps/web/src/app/data/services/config.service.ts +++ b/apps/web/src/app/data/services/config.service.ts @@ -2,6 +2,23 @@ import { Injectable, inject, signal, WritableSignal } from '@angular/core'; import { ApiService } from '../../core'; import { PublicConfig, SetupStatus } from '@zoneless/shared-types'; +/** Orchestra rails advertised by GET /v1/config. Local until shared-types lands. */ +export interface OrchestraSource { + chain: string; + asset: string; + label: string; +} + +export interface OrchestraPublicConfig { + enabled: boolean; + live?: boolean; + sources: OrchestraSource[]; +} + +type PublicConfigWithOrchestra = PublicConfig & { + orchestra?: OrchestraPublicConfig; +}; + @Injectable({ providedIn: 'root', }) @@ -150,6 +167,26 @@ export class ConfigService { return this.config()?.settlement === 'simulated'; } + /** + * True in simulated test mode (picker always shows locally) or when + * Orchestra is configured on the instance. + */ + OrchestraEnabled(): boolean { + return this.OrchestraConfig()?.enabled === true; + } + + OrchestraLive(): boolean { + return this.OrchestraConfig()?.live === true; + } + + OrchestraSources(): OrchestraSource[] { + return this.OrchestraConfig()?.sources ?? []; + } + + private OrchestraConfig(): OrchestraPublicConfig | undefined { + return (this.config() as PublicConfigWithOrchestra | null)?.orchestra; + } + /** * Clear the cached config. Useful when switching contexts. */ diff --git a/apps/web/src/app/data/services/external-wallet.service.ts b/apps/web/src/app/data/services/external-wallet.service.ts index bb2ee27..34ab474 100644 --- a/apps/web/src/app/data/services/external-wallet.service.ts +++ b/apps/web/src/app/data/services/external-wallet.service.ts @@ -8,6 +8,7 @@ import { import { ApiService } from '../../core'; import { ExternalWallet } from '@zoneless/shared-types'; import { SettingsCardRow } from '../../shared'; +import { FormatAssetLabel, FormatNetworkLabel } from '../../utils'; /** * Input type for creating an external wallet. @@ -106,14 +107,11 @@ export class ExternalWalletService { } private GetNetworkDisplay(): string { - const wallet = this.wallet(); - if (!wallet?.network) return 'Solana'; - return wallet.network.charAt(0).toUpperCase() + wallet.network.slice(1); + return FormatNetworkLabel(this.wallet()?.network); } private GetCurrencyDisplay(): string { - const wallet = this.wallet(); - return wallet?.currency?.toUpperCase() || 'USDC'; + return FormatAssetLabel(this.wallet()?.currency); } GetSettingsCardRows(): SettingsCardRow[] { @@ -122,12 +120,12 @@ export class ExternalWalletService { return [ { - label: 'Network', + label: 'Chain', value: this.GetNetworkDisplay(), type: 'text', }, { - label: 'Currency', + label: 'Asset', value: this.GetCurrencyDisplay(), type: 'text', }, diff --git a/apps/web/src/app/data/services/payout.service.ts b/apps/web/src/app/data/services/payout.service.ts index c1cabca..6746b4f 100644 --- a/apps/web/src/app/data/services/payout.service.ts +++ b/apps/web/src/app/data/services/payout.service.ts @@ -50,4 +50,8 @@ export class PayoutService { async CancelPayout(payoutId: string): Promise { return this.api.Call('POST', `payouts/${payoutId}/cancel`); } + + async SyncPayout(payoutId: string): Promise { + return this.api.Call('POST', `payouts/${payoutId}/sync`); + } } diff --git a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html index bb15127..efba1fd 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html +++ b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html @@ -8,7 +8,7 @@ (submitted)="actions.ConfirmPayout()" (closed)="actions.ClosePayout()" > -
+
@if (!hasBalance()) {
No balance available
} @if (actions.activeAccount() && !actions.activeAccount()?.payouts_enabled) @@ -40,12 +40,20 @@
Send to
-
+
{{ walletLabel() }} - @if (actions.GetDefaultWallet()?.default_for_currency) { - (Default for USDC) + @if (actions.GetDefaultWallet()?.last4) { + + •••• {{ actions.GetDefaultWallet()?.last4 }} }
+ @if (needsConversionNote()) { +
+ Payout converts from your Solana USDC balance. Network/conversion cost + is paid from the sent amount. +
+ }
@if (!isSimulatedSettlement()) { @@ -137,7 +145,7 @@
@if (actions.payoutError()) { -
{{ actions.payoutError() }}
+
{{ actions.payoutError() }}
}
diff --git a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts index b16ea0d..1aedf3b 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts +++ b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts @@ -37,6 +37,10 @@ export class PayoutModalComponent { this.actions.FormatWalletLabel(this.actions.GetDefaultWallet()) ); + readonly needsConversionNote = computed(() => + this.actions.NeedsPayoutConversionNote(this.actions.GetDefaultWallet()) + ); + readonly connectedSignerAddress = computed(() => this.actions.solanaWalletService.GetAddress() ); diff --git a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts index 149afe1..d92aed8 100644 --- a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts +++ b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts @@ -12,6 +12,8 @@ import type { Balance, ExternalWallet, LoginLink, + Payout, + PayoutBatchBroadcastResponse, PayoutBatchBuildResponse, } from '@zoneless/shared-types'; import type { CreateAccountInput } from '@zoneless/shared-schemas'; @@ -28,7 +30,18 @@ import { TransferService, } from '../../../../data'; import { SolanaWalletService } from '../../../../core'; -import { GetCountryName } from '../../../../utils'; +import { + FormatDestinationLabel, + GetCountryName, + IsSolanaUsdcDestination, +} from '../../../../utils'; + +type OrchestraBroadcastResponse = Omit< + PayoutBatchBroadcastResponse, + 'status' +> & { + status: 'paid' | 'failed' | 'in_transit'; +}; export type CreateConnectedAccountStep = 'summary' | 'edit-details' | 'success'; @@ -381,10 +394,14 @@ export class ConnectedAccountActionsService { FormatWalletLabel(wallet: ExternalWallet | null): string { if (!wallet) return 'No external wallet'; - const network = wallet.network - ? wallet.network.charAt(0).toUpperCase() + wallet.network.slice(1) - : 'Solana'; - return `${network} wallet •••• ${wallet.last4}`; + return FormatDestinationLabel(wallet.network, wallet.currency); + } + + NeedsPayoutConversionNote( + wallet: ExternalWallet | null = this.GetDefaultWallet() + ): boolean { + if (!wallet) return false; + return !IsSolanaUsdcDestination(wallet.network, wallet.currency); } // ── Add funds ──────────────────────────────────────────────────────────── @@ -575,6 +592,18 @@ export class ConnectedAccountActionsService { return; } + if (result.status === 'in_transit') { + const payoutId = this.payoutCreatedId() || result.payouts[0]?.id || ''; + const synced = await this.WaitForOrchestraPayout(payoutId); + if (synced.status === 'failed') { + this.ClearPayoutRetryState(); + this.payoutError.set( + synced.failure_message || 'Failed to process payout.' + ); + return; + } + } + this.ClearPayoutRetryState(); await this.ClosePayout(); } catch (err) { @@ -705,7 +734,32 @@ export class ConnectedAccountActionsService { payouts: buildResult.payouts.map((payout) => payout.id), blockhash: buildResult.blockhash, last_valid_block_height: buildResult.last_valid_block_height, - }); + }) as Promise; + } + + private async WaitForOrchestraPayout(payoutId: string): Promise { + if (!payoutId) { + throw new Error('Payout is converting but no payout id was returned.'); + } + + const deadline = Date.now() + 60_000; + let payout = await this.payoutService.SyncPayout(payoutId); + while ( + payout.status !== 'paid' && + payout.status !== 'failed' && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + payout = await this.payoutService.SyncPayout(payoutId); + } + + if (payout.status === 'paid' || payout.status === 'failed') { + return payout; + } + + throw new Error( + 'Payout is still converting. Check this payout again in a minute.' + ); } private ResetPayoutSigner(): void { diff --git a/apps/web/src/app/features/checkout/checkout.component.html b/apps/web/src/app/features/checkout/checkout.component.html index 2db610b..3538564 100644 --- a/apps/web/src/app/features/checkout/checkout.component.html +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -126,6 +126,98 @@

+ } @else if (IsAwaitingDeposit()) { +
+ @if (!ShowCashAppQr()) { +
+ +
+ } +

+ {{ WaitingTitle() }} +

+

{{ WaitingSubtitle() }}

+ + @if (selectedMethod() === 'cashapp') { + @if (ShowCashAppQr()) { +
+ Scan to pay with Cash App + +
+ } @if (OrchestraCashAppUrl(); as cashAppUrl) { + + Open in Cash App + + } } @if (selectedMethod() === 'deposit') { +
+ Send + {{ + OrchestraAmountLabel() + }} + {{ + OrchestraSourceLabel() + }} +
+ } @if (OrchestraDepositAddress(); as depositAddress) { +
+ Deposit address + {{ + ShortAddress(depositAddress) + }} + +
+ } @if (OrchestraDepositMemo(); as memo) { +
+ Memo + {{ memo }} + +
+ } @if (!OrchestraLive()) { + + } + + +
} @else if (IsComplete()) {
+ + @if (ShowCashAppMethod()) { + + } @if (ShowDepositMethod()) { + + }
{{ MethodDetailLabel() }}
+ @if (selectedMethod() === 'deposit') { + + }
{{ MethodHelpText() }}
diff --git a/apps/web/src/app/features/checkout/checkout.component.scss b/apps/web/src/app/features/checkout/checkout.component.scss index 165832c..50cf386 100644 --- a/apps/web/src/app/features/checkout/checkout.component.scss +++ b/apps/web/src/app/features/checkout/checkout.component.scss @@ -352,6 +352,104 @@ font-size: $font-size-small; } +.checkout-waiting { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: $spacing; +} + +.waiting-state { + padding: $spacing-extra-large 0; +} + +.waiting-animation { + display: flex; + justify-content: center; +} + +.waiting-title { + margin: 0; + font-family: $title-font; + font-size: $font-size-large; + font-weight: $title-weight; + color: $checkout-heading; +} + +.waiting-subtitle { + max-width: 360px; + margin: 0; + color: $checkout-label; + font-size: $font-size; + line-height: 1.5; +} + +.cashapp-qr { + position: relative; + width: 220px; + height: 220px; + margin: $spacing-small 0 0; +} + +.cashapp-qr-code { + display: block; + width: 100%; + height: 100%; + border-radius: $checkout-field-radius; +} + +.cashapp-qr-logo { + position: absolute; + top: 50%; + left: 50%; + width: 48px; + height: 48px; + transform: translate(-50%, -50%); + border-radius: 11px; + box-shadow: 0 0 0 6px #fff; +} + +.waiting-address { + width: 100%; + display: flex; + flex-direction: column; + gap: $spacing-extra-small; + padding: $spacing; + border: 1px solid $checkout-muted-border; + border-radius: $checkout-field-radius; + background-color: $background-color; + text-align: left; +} + +.waiting-address-label { + font-size: $font-size-small; + color: $checkout-label; +} + +.waiting-address-value { + font-family: monospace; + font-size: $font-size; + color: $checkout-heading; + overflow-wrap: anywhere; +} + +.checkout-copy-button { + align-self: flex-start; + margin-top: $spacing-extra-small; + background: none; + border: none; + padding: 0; + color: $checkout-label; + font-size: $font-size-small; + cursor: pointer; + + &:hover { + color: $checkout-heading; + } +} + // ----------------------------------------------------------------------------- // Mobile // ----------------------------------------------------------------------------- diff --git a/apps/web/src/app/features/checkout/checkout.component.spec.ts b/apps/web/src/app/features/checkout/checkout.component.spec.ts index 993864f..e4bc8f5 100644 --- a/apps/web/src/app/features/checkout/checkout.component.spec.ts +++ b/apps/web/src/app/features/checkout/checkout.component.spec.ts @@ -10,6 +10,10 @@ import { import { ConfigService } from '../../data/services/config.service'; import { CheckoutComponent } from './checkout.component'; +jest.mock('./util/cashapp-qr', () => ({ + BuildCashAppQrDataUrl: jest.fn(async () => 'data:image/png;base64,qr'), +})); + describe('CheckoutComponent mobile wallet handoff', () => { const signMobileTransaction = jest.fn(); const signAndSendMobileTransaction = jest.fn(); @@ -27,10 +31,16 @@ describe('CheckoutComponent mobile wallet handoff', () => { const configService = { IsSimulatedSettlement: jest.fn(() => false), LoadConfig: jest.fn().mockResolvedValue({}), + OrchestraEnabled: jest.fn(() => false), + OrchestraLive: jest.fn(() => false), + OrchestraSources: jest.fn(() => []), }; const checkoutSessionService = { PreparePayment: jest.fn(), ConfirmPayment: jest.fn(), + StartOrchestraPayment: jest.fn(), + GetOrchestraPayment: jest.fn(), + ConfirmOrchestraPayment: jest.fn(), }; const checkoutSession = { id: 'cs_test', @@ -108,6 +118,7 @@ describe('CheckoutComponent mobile wallet handoff', () => { }); afterEach(() => { + component.ngOnDestroy(); jest.restoreAllMocks(); jest.clearAllMocks(); }); @@ -310,4 +321,42 @@ describe('CheckoutComponent mobile wallet handoff', () => { expect(walletService.Connect).not.toHaveBeenCalled(); expect(component.NeedsMobileWalletHandoff()).toBe(false); }); + + it('starts Cash App orchestra without connecting a Solana wallet', async () => { + configService.OrchestraEnabled.mockReturnValue(true); + component.selectedMethod.set('cashapp'); + checkoutSessionService.StartOrchestraPayment.mockResolvedValue({ + object: 'checkout.orchestra', + checkout_session: { + ...checkoutSession, + orchestra: { + method: 'cashapp', + source_chain: 'bitcoin', + source_asset: 'btc', + quote_id: null, + operation_id: null, + deposit_address: 'cashapp-address', + deposit_memo: null, + cash_app_url: 'https://cash.app/pay', + amount_in: '1000000', + estimated_out: '1000000', + expires_at: null, + status: 'pending', + }, + }, + }); + + await component.Pay(); + + expect(walletService.Connect).not.toHaveBeenCalled(); + expect(checkoutSessionService.StartOrchestraPayment).toHaveBeenCalledWith( + checkoutSession.url_slug, + { method: 'cashapp' } + ); + expect(component.paymentPhase()).toBe('awaiting_deposit'); + expect(component.IsBusy()).toBe(true); + expect(component.ShowCashAppQr()).toBe(true); + expect(component.cashAppQrDataUrl()).toBe('data:image/png;base64,qr'); + expect(component.OrchestraDepositAddress()).toBeNull(); + }); }); diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts index e38ba29..8c5803c 100644 --- a/apps/web/src/app/features/checkout/checkout.component.ts +++ b/apps/web/src/app/features/checkout/checkout.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, + OnDestroy, OnInit, signal, WritableSignal, @@ -17,9 +18,15 @@ import { } from '../../core'; import { CheckoutPaymentTransaction, + CheckoutSessionOrchestra, CheckoutSessionService, + OrchestraCheckoutResponse, + OrchestraCheckoutSession, } from '../../data/services/checkout-session.service'; -import { ConfigService } from '../../data/services/config.service'; +import { + ConfigService, + OrchestraSource, +} from '../../data/services/config.service'; import { LoaderComponent, PageLoaderComponent } from '../../shared'; import { ISO_CODES } from '../../utils'; import { @@ -43,9 +50,11 @@ import { HasCheckoutConfirmationDetails, } from './util/checkout-completion'; import { + FormatStableAmount, FormatUsdcAmount, GetCheckoutSubmitLabel, } from './util/checkout-format'; +import { BuildCashAppQrDataUrl } from './util/cashapp-qr'; import { BuildMobileWalletOptions, IsMobileBrowser, @@ -53,7 +62,14 @@ import { } from './util/mobile-wallet'; import { TEST_WALLET_DATA } from '../../utils/constants/test-data'; -type PaymentPhase = 'idle' | 'awaiting_wallet' | 'processing' | 'complete'; +type PaymentPhase = + | 'idle' + | 'awaiting_wallet' + | 'awaiting_deposit' + | 'processing' + | 'complete'; + +type CheckoutMethod = 'solana' | 'cashapp' | 'deposit'; type AddressFormValue = { name: string; @@ -114,7 +130,7 @@ function HasAddressDetails(form: AddressFormValue): boolean { styleUrl: './checkout.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CheckoutComponent implements OnInit { +export class CheckoutComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly checkoutSessionService = inject(CheckoutSessionService); private readonly configService = inject(ConfigService); @@ -125,6 +141,12 @@ export class CheckoutComponent implements OnInit { loading: WritableSignal = signal(true); paymentPhase: WritableSignal = signal('idle'); paymentError: WritableSignal = signal(null); + selectedMethod: WritableSignal = signal('solana'); + selectedSource: WritableSignal = signal(null); + orchestraPayment: WritableSignal = + signal(null); + cashAppQrDataUrl: WritableSignal = signal(null); + copiedField: WritableSignal = signal(null); simulatedWalletOpen: WritableSignal = signal(false); mobileWalletHandoffRequested: WritableSignal = signal(false); confirmationExpanded: WritableSignal = signal(false); @@ -146,6 +168,10 @@ export class CheckoutComponent implements OnInit { termsAccepted = false; customFieldValues: Record = {}; + private orchestraPoll: ReturnType | null = null; + private orchestraPollGeneration = 0; + private orchestraConfirming = false; + async ngOnInit(): Promise { const urlSlug = this.route.snapshot.paramMap.get('checkoutSessionId'); if (!urlSlug) return; @@ -153,6 +179,11 @@ export class CheckoutComponent implements OnInit { this.LoadCheckoutSession(urlSlug), this.configService.LoadConfig().catch(() => undefined), ]); + this.EnsureSelectedSource(); + } + + ngOnDestroy(): void { + this.StopOrchestraPoll(); } private async LoadCheckoutSession(urlSlug: string): Promise { @@ -195,6 +226,8 @@ export class CheckoutComponent implements OnInit { if (checkoutSession.status === 'complete') { this.paymentPhase.set('complete'); this.HandleAfterCompletion(checkoutSession); + } else { + this.ResumeOrchestraIfNeeded(checkoutSession); } } finally { this.loading.set(false); @@ -278,7 +311,11 @@ export class CheckoutComponent implements OnInit { IsBusy(): boolean { const phase = this.paymentPhase(); - return phase === 'awaiting_wallet' || phase === 'processing'; + return ( + phase === 'awaiting_wallet' || + phase === 'awaiting_deposit' || + phase === 'processing' + ); } IsComplete(): boolean { @@ -286,6 +323,7 @@ export class CheckoutComponent implements OnInit { } NeedsMobileWalletHandoff(): boolean { + if (this.selectedMethod() !== 'solana') return false; if (this.IsSimulatedSettlement()) return false; if (typeof navigator === 'undefined') return false; return ( @@ -328,6 +366,11 @@ export class CheckoutComponent implements OnInit { this.paymentError.set(null); this.mobileWalletHandoffRequested.set(false); + if (this.selectedMethod() !== 'solana') { + await this.PayWithOrchestra(session); + return; + } + if (this.IsSimulatedSettlement()) { this.simulatedWalletOpen.set(true); return; @@ -388,6 +431,286 @@ export class CheckoutComponent implements OnInit { this.paymentError.set('Payment was declined'); } + OrchestraEnabled(): boolean { + return this.configService.OrchestraEnabled(); + } + + OrchestraLive(): boolean { + return this.configService.OrchestraLive(); + } + + OrchestraSources(): OrchestraSource[] { + return this.configService.OrchestraSources(); + } + + ShowCashAppMethod(): boolean { + return this.OrchestraEnabled() && !this.IsSubscription(); + } + + ShowDepositMethod(): boolean { + return this.ShowCashAppMethod() && this.OrchestraSources().length > 0; + } + + SelectMethod(method: CheckoutMethod): void { + this.selectedMethod.set(method); + if (method === 'deposit') { + this.EnsureSelectedSource(); + } + } + + SelectedSourceKey(): string { + const source = this.selectedSource(); + return source ? this.SourceKey(source) : ''; + } + + OnSourceChange(key: string): void { + const source = this.OrchestraSources().find( + (item) => this.SourceKey(item) === key + ); + this.selectedSource.set(source ?? null); + } + + IsAwaitingDeposit(): boolean { + return this.paymentPhase() === 'awaiting_deposit'; + } + + OrchestraCashAppUrl(): string | null { + return this.orchestraPayment()?.cash_app_url ?? null; + } + + ShowCashAppQr(): boolean { + return this.selectedMethod() === 'cashapp' && !!this.cashAppQrDataUrl(); + } + + OrchestraDepositAddress(): string | null { + if (this.selectedMethod() === 'cashapp') return null; + return this.orchestraPayment()?.deposit_address ?? null; + } + + OrchestraDepositMemo(): string | null { + return this.orchestraPayment()?.deposit_memo ?? null; + } + + OrchestraAmountLabel(): string { + return this.FormatStableAmount(this.orchestraPayment()?.amount_in); + } + + ShortAddress(address: string | null): string { + if (!address) return ''; + if (address.length <= 16) return address; + return `${address.slice(0, 8)}...${address.slice(-8)}`; + } + + OrchestraSourceLabel(): string { + const payment = this.orchestraPayment(); + const selected = this.selectedSource(); + return this.SourceDisplayLabel( + payment?.source_chain || selected?.chain, + payment?.source_asset || selected?.asset + ); + } + + WaitingTitle(): string { + return this.selectedMethod() === 'cashapp' + ? 'Waiting for Cash App' + : 'Waiting for your deposit'; + } + + WaitingSubtitle(): string { + if (this.selectedMethod() === 'cashapp') { + return this.ShowCashAppQr() + ? 'Scan this code with your phone to pay in Cash App. We will complete checkout once it arrives.' + : 'Open Cash App to approve this payment. We will complete checkout once it arrives.'; + } + return 'Send the amount below to the deposit address. We will complete checkout once it arrives.'; + } + + async CopyDepositField(value: string, field: string): Promise { + try { + await navigator.clipboard.writeText(value); + this.copiedField.set(field); + window.setTimeout(() => this.copiedField.set(null), 2000); + } catch { + this.paymentError.set('Could not copy to clipboard.'); + } + } + + async SimulateOrchestraPayment(): Promise { + await this.FinishOrchestraPayment(); + } + + CancelOrchestraWait(): void { + this.StopOrchestraPoll(); + this.orchestraConfirming = false; + this.cashAppQrDataUrl.set(null); + this.paymentPhase.set('idle'); + this.paymentError.set(null); + } + + private async PayWithOrchestra(session: CheckoutSession): Promise { + if (this.selectedMethod() === 'deposit' && !this.selectedSource()) { + this.paymentError.set('Select a source chain to continue'); + return; + } + + this.paymentPhase.set('processing'); + try { + const source = this.selectedSource(); + const result = await this.checkoutSessionService.StartOrchestraPayment( + session.url_slug, + this.selectedMethod() === 'deposit' + ? { + method: 'deposit', + source_chain: source?.chain, + source_asset: source?.asset, + } + : { method: 'cashapp' } + ); + this.ApplyOrchestraResult(result); + await this.RefreshCashAppQr(); + this.paymentPhase.set('awaiting_deposit'); + this.StartOrchestraPoll(); + } catch (error) { + this.HandlePaymentError(error, 'idle'); + } + } + + private ResumeOrchestraIfNeeded(session: CheckoutSession): void { + const orchestra = (session as OrchestraCheckoutSession).orchestra; + if (!orchestra || this.IsSubscription()) return; + this.orchestraPayment.set(orchestra); + this.selectedMethod.set( + orchestra.method === 'cashapp' ? 'cashapp' : 'deposit' + ); + const match = this.OrchestraSources().find( + (source) => + source.chain === orchestra.source_chain && + source.asset === orchestra.source_asset + ); + if (match) this.selectedSource.set(match); + this.paymentPhase.set('awaiting_deposit'); + this.StartOrchestraPoll(); + void this.RefreshCashAppQr(); + } + + private ApplyOrchestraResult(result: OrchestraCheckoutResponse): void { + const session = result.checkout_session; + const intent = session.orchestra ?? null; + this.checkoutSession.set({ ...session, orchestra: intent }); + this.orchestraPayment.set(intent); + } + + private IsMobileCheckout(): boolean { + if (typeof navigator === 'undefined') return false; + return IsMobileBrowser(navigator.userAgent, navigator.maxTouchPoints); + } + + private async RefreshCashAppQr(): Promise { + const url = this.OrchestraCashAppUrl(); + if (!url || this.IsMobileCheckout()) { + this.cashAppQrDataUrl.set(null); + return; + } + try { + this.cashAppQrDataUrl.set(await BuildCashAppQrDataUrl(url)); + } catch { + this.cashAppQrDataUrl.set(null); + } + } + + private StartOrchestraPoll(): void { + this.StopOrchestraPoll(); + const generation = ++this.orchestraPollGeneration; + this.orchestraPoll = setInterval(() => { + void this.PollOrchestraStatus(generation); + }, 3000); + } + + private StopOrchestraPoll(): void { + if (this.orchestraPoll) { + clearInterval(this.orchestraPoll); + this.orchestraPoll = null; + } + this.orchestraPollGeneration += 1; + } + + private async PollOrchestraStatus(generation: number): Promise { + const session = this.checkoutSession(); + if (!session || generation !== this.orchestraPollGeneration) return; + try { + const result = await this.checkoutSessionService.GetOrchestraPayment( + session.url_slug + ); + if (generation !== this.orchestraPollGeneration) return; + this.ApplyOrchestraResult(result); + const status = result.checkout_session.orchestra?.status ?? null; + if (this.IsOrchestraFailed(status)) { + this.StopOrchestraPoll(); + this.paymentError.set('This payment expired or failed. Try again.'); + this.paymentPhase.set('idle'); + return; + } + if (!this.OrchestraLive()) return; + if (this.IsOrchestraComplete(status)) { + await this.FinishOrchestraPayment(); + } + } catch { + // Keep waiting; the next poll will retry. + } + } + + private async FinishOrchestraPayment(): Promise { + const session = this.checkoutSession(); + if (!session || this.orchestraConfirming) return; + this.orchestraConfirming = true; + this.StopOrchestraPoll(); + this.paymentPhase.set('processing'); + try { + const completed = + await this.checkoutSessionService.ConfirmOrchestraPayment( + session.url_slug + ); + this.CompletePayment(completed); + } catch (error) { + this.orchestraConfirming = false; + this.HandlePaymentError(error, 'awaiting_deposit'); + this.StartOrchestraPoll(); + } + } + + private IsOrchestraComplete(status: string | null): boolean { + return /^(completed|complete|succeeded|paid|settled)$/i.test(status ?? ''); + } + + private IsOrchestraFailed(status: string | null): boolean { + return /^(failed|expired|canceled|cancelled)$/i.test(status ?? ''); + } + + private EnsureSelectedSource(): void { + if (this.selectedSource()) return; + const first = this.OrchestraSources()[0]; + if (first) this.selectedSource.set(first); + } + + private SourceKey(source: OrchestraSource): string { + return `${source.chain}:${source.asset}`; + } + + private SourceDisplayLabel( + chain?: string | null, + asset?: string | null + ): string { + const match = this.OrchestraSources().find( + (source) => source.chain === chain && source.asset === asset + ); + if (match) return match.label; + if (!chain && !asset) return 'another chain'; + const network = chain + ? chain.charAt(0).toUpperCase() + chain.slice(1) + : 'chain'; + return `${(asset || 'usdc').toUpperCase()} on ${network}`; + } + private PayWithMobileWallet( session: CheckoutSession ): Promise { @@ -809,6 +1132,7 @@ export class CheckoutComponent implements OnInit { } readonly FormatAmount = FormatUsdcAmount; + readonly FormatStableAmount = FormatStableAmount; DiscountAmount(): number { return this.checkoutSession()?.total_details?.amount_discount ?? 0; @@ -839,6 +1163,10 @@ export class CheckoutComponent implements OnInit { switch (this.paymentPhase()) { case 'awaiting_wallet': return 'Confirm in wallet'; + case 'awaiting_deposit': + return this.selectedMethod() === 'cashapp' + ? 'Waiting for Cash App' + : 'Waiting for deposit'; default: return 'Processing'; } @@ -851,6 +1179,17 @@ export class CheckoutComponent implements OnInit { } MethodDetailLabel(): string { + if (this.selectedMethod() === 'cashapp') { + return this.OrchestraLive() + ? 'Paying with Cash App' + : 'Paying with test Cash App'; + } + if (this.selectedMethod() === 'deposit') { + return `Paying with ${this.SourceDisplayLabel( + this.selectedSource()?.chain, + this.selectedSource()?.asset + )}`; + } if (this.IsSimulatedSettlement()) { return this.IsSubscription() ? 'Subscribing with test USDC' @@ -863,6 +1202,12 @@ export class CheckoutComponent implements OnInit { MethodHelpText(): string { const amount = this.FormatAmount(this.checkoutSession()?.amount_total); + if (this.selectedMethod() === 'cashapp') { + return `Open Cash App to pay ${amount}. No Solana wallet required.`; + } + if (this.selectedMethod() === 'deposit') { + return `Send ${amount} from another chain. We convert it to USDC on Solana for the merchant.`; + } if (this.IsSimulatedSettlement()) { return this.IsSubscription() ? `Approve in the test wallet to start a ${amount} USDC subscription. No real wallet required.` diff --git a/apps/web/src/app/features/checkout/util/cashapp-qr.ts b/apps/web/src/app/features/checkout/util/cashapp-qr.ts new file mode 100644 index 0000000..0a9938b --- /dev/null +++ b/apps/web/src/app/features/checkout/util/cashapp-qr.ts @@ -0,0 +1,10 @@ +import { toDataURL } from 'qrcode'; + +export async function BuildCashAppQrDataUrl(url: string): Promise { + return toDataURL(url, { + errorCorrectionLevel: 'H', + margin: 1, + width: 280, + color: { dark: '#111111', light: '#ffffff' }, + }); +} diff --git a/apps/web/src/app/features/checkout/util/checkout-format.ts b/apps/web/src/app/features/checkout/util/checkout-format.ts index 8c990b7..bcaa761 100644 --- a/apps/web/src/app/features/checkout/util/checkout-format.ts +++ b/apps/web/src/app/features/checkout/util/checkout-format.ts @@ -6,6 +6,28 @@ export function FormatUsdcAmount(cents: number | null | undefined): string { return `US$${((cents ?? 0) / 100).toFixed(2)}`; } +/** Format a smallest-units amount (e.g. 6-decimal stables) as dollars. */ +export function FormatStableAmount( + amount: string | null | undefined, + decimals = 6 +): string { + if (!amount) return 'US$0.00'; + try { + const raw = BigInt(amount); + const base = 10n ** BigInt(decimals); + const whole = raw / base; + const frac = raw % base; + const dollars = Number(whole) + Number(frac) / Number(base); + if (Number.isFinite(dollars)) { + return `US$${dollars.toFixed(2)}`; + } + const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, ''); + return fracStr ? `${whole}.${fracStr}` : `${whole}`; + } catch { + return amount; + } +} + type SubmitType = | PaymentLink['submit_type'] | CheckoutSession['submit_type'] diff --git a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.html b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.html index 132d32d..38c5c5f 100644 --- a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.html +++ b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.html @@ -3,7 +3,7 @@ @if (mode === 'onboard' && configService.IsTestMode()) { } @@ -12,12 +12,13 @@ @if (mode === 'onboard') {

Add your payout wallet

- Enter your Solana wallet address to receive USDC payouts. USDC is a - stablecoin pegged 1:1 to the US dollar. + Enter the wallet address where you want to receive payouts. USDC and USDT + are stablecoins pegged 1:1 to the US dollar.

} + @if (IsSolanaNetwork()) {
Add your payout wallet
- } + } }
Network: - - Solana +
Currency: - - USDC +
-
Solana Wallet Address
+
{{ AddressFieldTitle() }}

- Make sure this address supports USDC on the Solana network. + {{ AddressHelpText() }}

@if (validationStatus() === 'valid') { diff --git a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.scss b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.scss index 8830de4..19e6d0d 100644 --- a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.scss +++ b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.scss @@ -24,6 +24,7 @@ .info-label { font-size: $font-size-med; opacity: $dimmed; + min-width: 76px; } .info-logo { @@ -38,6 +39,11 @@ } } +.wallet-select { + flex: 1; + min-width: 0; +} + .field-with-status { display: flex; align-items: center; diff --git a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.ts b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.ts index de93b8f..8a3a599 100644 --- a/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.ts +++ b/apps/web/src/app/shared/forms/external-wallet-form/external-wallet-form.component.ts @@ -18,11 +18,13 @@ import { TestModeBannerComponent } from '../../ui'; import { ConfigService } from '../../../data'; import { - ValidateSolanaAddress, - GetSolanaAddressError, - SOLANA_NETWORK, + GetWalletAddressError, SOLANA_CURRENCY, + SOLANA_NETWORK, TEST_WALLET_DATA, + ValidateWalletAddress, + CurrencyOptionsForNetwork, + WALLET_NETWORKS, } from '../../../utils'; export type ExternalWalletFormMode = 'onboard' | 'edit'; @@ -53,14 +55,13 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { @Output() validationChange = new EventEmitter(); walletAddress: WritableSignal = signal(''); + network: WritableSignal = signal(SOLANA_NETWORK); + currency: WritableSignal = signal(SOLANA_CURRENCY); walletAddressError: WritableSignal = signal(''); validationStatus: WritableSignal<'none' | 'valid' | 'invalid'> = signal('none'); showWalletGuide: WritableSignal = signal(false); - readonly network = SOLANA_NETWORK; - readonly currency = SOLANA_CURRENCY; - ngOnInit(): void { this.InitializeForm(); } @@ -75,8 +76,14 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { InitializeForm(): void { if (this.wallet) { this.walletAddress.set(this.wallet.wallet_address || ''); + this.network.set((this.wallet.network || SOLANA_NETWORK).toLowerCase()); + this.currency.set( + (this.wallet.currency || SOLANA_CURRENCY).toLowerCase() + ); } else { this.walletAddress.set(''); + this.network.set(SOLANA_NETWORK); + this.currency.set(SOLANA_CURRENCY); } if (this.walletAddress()) { @@ -95,6 +102,58 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { this.EmitFormChange(); } + OnNetworkChange(value: string): void { + this.network.set(value.toLowerCase()); + const allowed = this.CurrencyOptions(); + if (!allowed.some((option) => option.value === this.currency())) { + this.currency.set(allowed[0]?.value ?? 'usdc'); + } + if (!this.IsSolanaNetwork()) { + this.showWalletGuide.set(false); + } + this.ValidateWalletAddress(); + this.EmitFormChange(); + } + + CurrencyOptions(): { value: string; label: string }[] { + const fromDestinations = this.PayoutDestinations() + .filter((destination) => destination.chain === this.network()) + .map((destination) => ({ + value: destination.asset, + label: destination.asset.toUpperCase(), + })); + if (fromDestinations.length > 0) return fromDestinations; + return CurrencyOptionsForNetwork(this.network()); + } + + NetworkOptions(): { value: string; label: string }[] { + const dests = this.PayoutDestinations(); + if ( + dests.length <= 1 && + this.configService.OrchestraSources().length === 0 + ) { + return [...WALLET_NETWORKS]; + } + const seen = new Set(); + const options: { value: string; label: string }[] = []; + for (const dest of dests) { + if (seen.has(dest.chain)) continue; + seen.add(dest.chain); + options.push({ + value: dest.chain, + label: + WALLET_NETWORKS.find((option) => option.value === dest.chain) + ?.label ?? dest.chain.charAt(0).toUpperCase() + dest.chain.slice(1), + }); + } + return options; + } + + OnCurrencyChange(value: string): void { + this.currency.set(value.toLowerCase()); + this.EmitFormChange(); + } + ValidateWalletAddress(): void { const address = this.walletAddress(); @@ -104,12 +163,12 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { return; } - const error = GetSolanaAddressError(address); + const error = GetWalletAddressError(address, this.network()); this.walletAddressError.set(error); if (error) { this.validationStatus.set('invalid'); - } else if (ValidateSolanaAddress(address)) { + } else if (ValidateWalletAddress(address, this.network())) { this.validationStatus.set('valid'); } else { this.validationStatus.set('none'); @@ -128,12 +187,14 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { GetFormData(): ExternalWalletFormData { return { walletAddress: this.walletAddress(), - network: this.network, - currency: this.currency, + network: this.network().toLowerCase(), + currency: this.currency().toLowerCase(), }; } FillTestData(): void { + this.network.set(SOLANA_NETWORK); + this.currency.set(SOLANA_CURRENCY); this.walletAddress.set(TEST_WALLET_DATA.walletAddress); this.ValidateWalletAddress(); this.EmitFormChange(); @@ -143,8 +204,61 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { this.showWalletGuide.set(!this.showWalletGuide()); } + IsSolanaNetwork(): boolean { + return this.network() === SOLANA_NETWORK; + } + + NetworkLabel(): string { + return ( + this.NetworkOptions().find((option) => option.value === this.network()) + ?.label ?? 'Solana' + ); + } + + CurrencyLabel(): string { + return this.currency().toUpperCase(); + } + + AddressFieldTitle(): string { + return `${this.NetworkLabel()} wallet address`; + } + + AddressHelpText(): string { + return `Make sure this address supports ${this.CurrencyLabel()} on the ${this.NetworkLabel()} network.`; + } + + AddressPlaceholder(): string { + if (this.IsSolanaNetwork()) { + return 'e.g., 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU'; + } + if (this.network() === 'tron') { + return 'e.g., TXYZopYRdj2D9XRtbG411XZZ3kM5VkCeP'; + } + return 'e.g., 0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + } + private EmitFormChange(): void { this.formChange.emit(this.GetFormData()); this.validationChange.emit(this.IsValid()); } + + private PayoutDestinations(): { + chain: string; + asset: string; + label: string; + }[] { + const destinations = [ + { chain: 'solana', asset: 'usdc', label: 'USDC on Solana' }, + ...this.configService.OrchestraSources(), + ]; + const seen = new Set(); + const unique: { chain: string; asset: string; label: string }[] = []; + for (const destination of destinations) { + const key = `${destination.chain}:${destination.asset}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(destination); + } + return unique; + } } diff --git a/apps/web/src/app/styles/checkout.scss b/apps/web/src/app/styles/checkout.scss index 1c07de1..b27ca3c 100644 --- a/apps/web/src/app/styles/checkout.scss +++ b/apps/web/src/app/styles/checkout.scss @@ -221,6 +221,10 @@ select.checkout-field-control { color: $checkout-heading; cursor: pointer; + + .checkout-method-option { + border-top: 1px solid $checkout-muted-border; + } + input[type='radio'] { width: 16px; height: 16px; @@ -231,6 +235,10 @@ select.checkout-field-control { img { width: 20px; height: 20px; + + &.checkout-cashapp-logo { + border-radius: 22%; + } } } @@ -268,7 +276,16 @@ select.checkout-field-control { flex-shrink: 0; line-height: 1; background-color: $checkout-pay-button-bg; - color: $checkout-pay-button-fg; + color: $checkout-pay-button-fg !important; + text-decoration: none; + + &:link, + &:visited, + &:hover, + &:active { + color: $checkout-pay-button-fg !important; + text-decoration: none; + } cursor: pointer; box-sizing: border-box; diff --git a/apps/web/src/app/utils/validation/index.ts b/apps/web/src/app/utils/validation/index.ts index f2c1b08..036f9f3 100644 --- a/apps/web/src/app/utils/validation/index.ts +++ b/apps/web/src/app/utils/validation/index.ts @@ -1 +1,2 @@ export * from './solana'; +export * from './wallet-address'; diff --git a/apps/web/src/app/utils/validation/wallet-address.ts b/apps/web/src/app/utils/validation/wallet-address.ts new file mode 100644 index 0000000..a381a79 --- /dev/null +++ b/apps/web/src/app/utils/validation/wallet-address.ts @@ -0,0 +1,112 @@ +import { GetSolanaAddressError, ValidateSolanaAddress } from './solana'; + +export const WALLET_NETWORKS = [ + { value: 'solana', label: 'Solana' }, + { value: 'base', label: 'Base' }, + { value: 'arbitrum', label: 'Arbitrum' }, + { value: 'ethereum', label: 'Ethereum' }, + { value: 'polygon', label: 'Polygon' }, + { value: 'hyperevm', label: 'HyperEVM' }, + { value: 'tron', label: 'Tron' }, +] as const; + +export const WALLET_CURRENCIES = [ + { value: 'usdc', label: 'USDC' }, + { value: 'usdt', label: 'USDT' }, +] as const; + +export function CurrencyOptionsForNetwork( + network: string +): { value: string; label: string }[] { + const normalized = network.toLowerCase(); + if (normalized === 'tron') { + return WALLET_CURRENCIES.filter((option) => option.value === 'usdt'); + } + if (normalized === 'hyperevm') { + return WALLET_CURRENCIES.filter((option) => option.value === 'usdc'); + } + return [...WALLET_CURRENCIES]; +} + +const EVM_NETWORKS = new Set([ + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', + 'hyperevm', +]); + +const EVM_ADDRESS = /^0x[a-fA-F0-9]{40}$/; + +export function FormatNetworkLabel(network?: string | null): string { + if (!network) return 'Solana'; + const known = WALLET_NETWORKS.find((item) => item.value === network); + if (known) return known.label; + return network.charAt(0).toUpperCase() + network.slice(1); +} + +export function FormatAssetLabel(currency?: string | null): string { + return (currency || 'usdc').toUpperCase(); +} + +export function FormatDestinationLabel( + network?: string | null, + currency?: string | null +): string { + return `${FormatAssetLabel(currency)} on ${FormatNetworkLabel(network)}`; +} + +export function IsSolanaUsdcDestination( + network?: string | null, + currency?: string | null +): boolean { + return ( + (network || 'solana').toLowerCase() === 'solana' && + (currency || 'usdc').toLowerCase() === 'usdc' + ); +} + +export function ValidateEvmAddress(address: string): boolean { + return EVM_ADDRESS.test(address); +} + +export function ValidateTronAddress(address: string): boolean { + return ( + address.startsWith('T') && address.length >= 33 && address.length <= 35 + ); +} + +export function ValidateWalletAddress( + address: string, + network: string +): boolean { + if (!address) return false; + if (network === 'solana') return ValidateSolanaAddress(address); + if (network === 'tron') return ValidateTronAddress(address); + if (EVM_NETWORKS.has(network)) return ValidateEvmAddress(address); + return false; +} + +export function GetWalletAddressError( + address: string, + network: string +): string { + if (!address || !address.trim()) { + return 'Please enter a wallet address'; + } + if (network === 'solana') { + return GetSolanaAddressError(address); + } + if (network === 'tron') { + return ValidateTronAddress(address) + ? '' + : 'Enter a valid Tron address (starts with T)'; + } + if (EVM_NETWORKS.has(network)) { + return ValidateEvmAddress(address) + ? '' + : 'Enter a valid 0x address for this network'; + } + return 'Unsupported network'; +} diff --git a/apps/web/src/assets/images/logos/cash-app.png b/apps/web/src/assets/images/logos/cash-app.png new file mode 100644 index 0000000..37e7101 Binary files /dev/null and b/apps/web/src/assets/images/logos/cash-app.png differ diff --git a/docs/orchestra.md b/docs/orchestra.md new file mode 100644 index 0000000..90c0c1b --- /dev/null +++ b/docs/orchestra.md @@ -0,0 +1,45 @@ +# Flashnet Orchestra rails + +Optional pay-in and payout rails. Native `solana:USDC` checkout and payouts are unchanged. + +Ledger stays USDC on Solana. Conversion happens only at the Flashnet edge. + +## Demo without Flashnet credentials + +`SETTLEMENT_RAIL=simulated` (the test default). Leave `ORCHESTRA_API_KEY` unset for fake rails, or set a Flashnet server key to use real Cash App / xchain while Solana stays simulated. + +1. Create a **payment-mode** Checkout Session or Payment Link. +2. Open `/c/{slug}`. Methods: Solana wallet, Cash App, Other chain. +3. Choose Cash App or Other chain → Pay → **Simulate payment**. +4. Onboard a seller with Base/USDC (or another listed dest). Payout notes the conversion; simulated sync marks it paid. + +Subscriptions stay Solana-wallet only. + +## Live + +Set both: + +``` +ORCHESTRA_API_URL=https://your-orchestra-host +ORCHESTRA_API_KEY=fn_... +``` + +Use a Flashnet **server** key only. Do not put a client key (`fnp_`) in Zoneless or checkout — client keys can set `recipientAddress`. + +Pay-in destination is always the platform Solana USDC wallet. Checkout never talks to Flashnet. + +Cash App onramps require at least $1.00 (Flashnet's floor). + +## Stables + +Pay-in and payout pairs come from Flashnet `GET /v1/orchestration/routes`, not a hardcoded chain×asset grid. We keep USDC/USDT pairs that route **both ways** with `solana:USDC` and use 6 decimals (the ledger unit). + +That is why Optimism USDT is omitted: Flashnet lists it only against Bitcoin / Lightning / Spark, not Solana. BSC USDT is omitted because it is 18 decimals. + +When Orchestra is live, the API refreshes this list on config and quote. Simulated mode uses the last snapshot of that table. `solana/usdc` payouts stay on the native batch path. + +Xchain quotes are exact-in / variable. Checkout copy is “send X on Base to deliver ~$10”. + +## Payouts + +A non-Solana dest quotes `solana:USDC → dest`. Zoneless signs the existing Solana transfer to `quote.depositAddress`. The payout stays `in_transit` until Orchestra completes, then `POST /v1/payouts/:id/sync`. diff --git a/libs/shared-schemas/src/lib/ExternalWalletSchema.ts b/libs/shared-schemas/src/lib/ExternalWalletSchema.ts index 4b99793..8e8049d 100644 --- a/libs/shared-schemas/src/lib/ExternalWalletSchema.ts +++ b/libs/shared-schemas/src/lib/ExternalWalletSchema.ts @@ -1,29 +1,115 @@ import { z } from 'zod'; +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +const ALLOWED_NETWORKS = new Set([ + 'solana', + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', + 'hyperevm', + 'tron', +]); +const EVM_NETWORKS = new Set([ + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', + 'hyperevm', +]); + +function IsValidWalletAddress(network: string, address: string): boolean { + if (network === 'solana') { + return ( + address.length >= 32 && address.length <= 44 && BASE58_RE.test(address) + ); + } + if (EVM_NETWORKS.has(network)) { + return EVM_ADDRESS_RE.test(address); + } + if (network === 'tron') { + return address.startsWith('T') && BASE58_RE.test(address); + } + return false; +} + /** * Schema for creating an external wallet. * Only wallet_address is required - other fields have sensible defaults. */ -export const CreateExternalWalletSchema = z.object({ - wallet_address: z - .string() - .min(32, 'Wallet address must be at least 32 characters') - .max(44, 'Wallet address must be at most 44 characters') - .regex( - /^[1-9A-HJ-NP-Za-km-z]+$/, - 'Wallet address must be a valid base58 Solana address' - ), - network: z.string().min(1, 'Network is required').optional(), - currency: z - .string() - .min(3, 'Currency must be at least 3 characters') - .max(4, 'Currency must be at most 4 characters') - .optional(), - account_holder_name: z.string().nullable().optional(), - account_holder_type: z.enum(['individual', 'company']).nullable().optional(), - default_for_currency: z.boolean().nullable().optional(), - metadata: z.record(z.string(), z.string()).optional(), -}); +export const CreateExternalWalletSchema = z + .object({ + wallet_address: z + .string() + .min(1, 'Wallet address is required') + .max(64, 'Wallet address must be at most 64 characters'), + network: z.string().min(1, 'Network is required').optional(), + currency: z + .string() + .min(3, 'Currency must be at least 3 characters') + .max(4, 'Currency must be at most 4 characters') + .optional(), + account_holder_name: z.string().nullable().optional(), + account_holder_type: z.enum(['individual', 'company']).nullable().optional(), + default_for_currency: z.boolean().nullable().optional(), + metadata: z.record(z.string(), z.string()).optional(), + }) + .superRefine((data, ctx) => { + const network = (data.network ?? 'solana').toLowerCase(); + const currency = (data.currency ?? 'usdc').toLowerCase(); + + if (!ALLOWED_NETWORKS.has(network)) { + ctx.addIssue({ + code: 'custom', + path: ['network'], + message: 'Unsupported network', + }); + return; + } + + if (currency !== 'usdc' && currency !== 'usdt') { + ctx.addIssue({ + code: 'custom', + path: ['currency'], + message: 'Currency must be usdc or usdt', + }); + return; + } + + if (network === 'tron' && currency !== 'usdt') { + ctx.addIssue({ + code: 'custom', + path: ['currency'], + message: 'Tron wallets must use usdt', + }); + return; + } + + if (network === 'solana' && currency !== 'usdc' && currency !== 'usdt') { + ctx.addIssue({ + code: 'custom', + path: ['currency'], + message: 'Solana wallets must use usdc or usdt', + }); + return; + } + + if (!IsValidWalletAddress(network, data.wallet_address)) { + ctx.addIssue({ + code: 'custom', + path: ['wallet_address'], + message: + network === 'solana' + ? 'Wallet address must be a valid base58 Solana address' + : network === 'tron' + ? 'Wallet address must be a valid Tron address' + : 'Wallet address must be a valid EVM address', + }); + } + }); export type CreateExternalWalletInput = z.infer< typeof CreateExternalWalletSchema diff --git a/libs/shared-schemas/src/lib/OrchestraSchema.ts b/libs/shared-schemas/src/lib/OrchestraSchema.ts new file mode 100644 index 0000000..33e1a76 --- /dev/null +++ b/libs/shared-schemas/src/lib/OrchestraSchema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Body for POST /v1/payment_pages/:urlSlug/orchestra. + * Destination is always the platform solana USDC wallet — never accepted here. + */ +export const StartOrchestraPayinSchema = z + .object({ + method: z.enum(['cashapp', 'deposit']), + source_chain: z.string().min(1).optional(), + source_asset: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.method === 'deposit') { + if (!data.source_chain?.trim()) { + ctx.addIssue({ + code: 'custom', + path: ['source_chain'], + message: 'source_chain is required for deposit', + }); + } + if (!data.source_asset?.trim()) { + ctx.addIssue({ + code: 'custom', + path: ['source_asset'], + message: 'source_asset is required for deposit', + }); + } + } + }); + +export type StartOrchestraPayinInput = z.infer; diff --git a/libs/shared-schemas/src/lib/index.ts b/libs/shared-schemas/src/lib/index.ts index e587f83..7775a54 100644 --- a/libs/shared-schemas/src/lib/index.ts +++ b/libs/shared-schemas/src/lib/index.ts @@ -16,6 +16,7 @@ export * from './InvoiceSchema'; export * from './PaymentIntentSchema'; export * from './PaymentLinkSchema'; +export * from './OrchestraSchema'; export * from './PayoutSchema'; export * from './PersonSchema'; export * from './PriceSchema'; diff --git a/libs/shared-types/src/lib/CheckoutSession.ts b/libs/shared-types/src/lib/CheckoutSession.ts index 56008fc..47e1626 100644 --- a/libs/shared-types/src/lib/CheckoutSession.ts +++ b/libs/shared-types/src/lib/CheckoutSession.ts @@ -1,5 +1,6 @@ import { Price } from './Price'; import { CustomerAddress, CustomerDiscount, CustomerTaxRate } from './Customer'; +import { OrchestraIntent } from './Orchestra'; /** * Stripe-compatible Checkout Session object for Zoneless. @@ -503,6 +504,13 @@ export interface CheckoutSession { privacy_url: string | null; icon_url: string | null; } | null; + + /** + * Flashnet Orchestra pay-in intent for Cash App / deposit rails. + * Native solana:USDC checkout does not use this field. + * @zoneless_extension + */ + orchestra?: OrchestraIntent | null; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/libs/shared-types/src/lib/Config.ts b/libs/shared-types/src/lib/Config.ts index a4cddb4..af1a75e 100644 --- a/libs/shared-types/src/lib/Config.ts +++ b/libs/shared-types/src/lib/Config.ts @@ -1,3 +1,5 @@ +import { OrchestraSource } from './Orchestra'; + /** * Application infrastructure configuration. * Static configuration from environment variables (.env). @@ -37,6 +39,13 @@ export interface AppConfig { * `simulated` (test default) uses fake funds; `onchain` uses Solana. */ settlement_rail: SettlementRail; + /** Flashnet Orchestra host. Empty when unset (simulated rails). */ + orchestraApiUrl: string; + /** + * Flashnet Orchestra server key (`fn_...`). Empty when unset. + * Never log this value. + */ + orchestraApiKey: string; } /** @@ -73,6 +82,16 @@ export interface PublicConfig { livemode: boolean; /** How this instance settles money: fake funds vs a real chain */ settlement: SettlementRail; + /** + * Orchestra pay-in picker. Enabled when live or settlement is simulated + * so hosted checkout can demo Cash App / deposit rails in test. + */ + orchestra: { + enabled: boolean; + /** True when a Flashnet server key is configured (real Cash App / xchain). */ + live: boolean; + sources: OrchestraSource[]; + }; } /** diff --git a/libs/shared-types/src/lib/Orchestra.ts b/libs/shared-types/src/lib/Orchestra.ts new file mode 100644 index 0000000..d828a54 --- /dev/null +++ b/libs/shared-types/src/lib/Orchestra.ts @@ -0,0 +1,41 @@ +import type { CheckoutSession } from './CheckoutSession'; + +export type OrchestraMethod = 'cashapp' | 'deposit'; + +export interface OrchestraSource { + chain: string; // base, arbitrum, ethereum, polygon, hyperevm, tron, solana + asset: string; // usdc | usdt + label: string; // "USDC on Base" +} + +export interface OrchestraIntent { + method: OrchestraMethod; + source_chain: string; + source_asset: string; + quote_id: string | null; + operation_id: string | null; + deposit_address: string | null; + deposit_memo: string | null; + cash_app_url: string | null; + amount_in: string | null; // source smallest units + estimated_out: string | null; // dest smallest units + expires_at: string | null; + status: string | null; // quoted | awaiting_deposit | processing | completed | failed +} + +export interface OrchestraPayinStartResponse { + object: 'orchestra_payin'; + checkout_session: CheckoutSession; + intent: OrchestraIntent; +} + +export interface OrchestraPayoutIntent { + quote_id: string | null; + operation_id: string | null; + deposit_address: string | null; + amount_in: string | null; + estimated_out: string | null; + destination_chain: string; + destination_asset: string; + status: string | null; +} diff --git a/libs/shared-types/src/lib/Payout.ts b/libs/shared-types/src/lib/Payout.ts index cd68fe4..2fbcbd4 100644 --- a/libs/shared-types/src/lib/Payout.ts +++ b/libs/shared-types/src/lib/Payout.ts @@ -1,3 +1,5 @@ +import { OrchestraPayoutIntent } from './Orchestra'; + /** * Payout failure codes * These map to crypto/blockchain-specific failure reasons @@ -119,6 +121,12 @@ export interface Payout { * @zoneless_extension */ platform_account: string; + + /** + * Flashnet Orchestra payout intent when the seller dest is not native solana:USDC. + * @zoneless_extension + */ + orchestra?: OrchestraPayoutIntent | null; } export interface PayoutResponse { @@ -152,6 +160,11 @@ export interface PayoutBatchBuildResponse { total_amount: number; /** Number of recipients in the transaction */ recipients_count: number; + /** + * Present when the batch funds an Orchestra deposit (single orchestra dest). + * The unsigned transaction still sends solana USDC to orchestra.deposit_address. + */ + orchestra?: OrchestraPayoutIntent | null; } /** @@ -162,8 +175,11 @@ export interface PayoutBatchBroadcastResponse { object: 'payout_batch_broadcast'; /** Transaction signature on Solana */ signature: string; - /** Status of the broadcast: 'paid' or 'failed' */ - status: 'paid' | 'failed'; + /** + * Status of the broadcast: native dests are 'paid' or 'failed'. + * Orchestra funding success is 'in_transit' until SyncPayout settles the swap. + */ + status: 'paid' | 'failed' | 'in_transit'; /** URL to view the transaction on Solana Explorer */ viewer_url: string; /** Array of updated payout objects */ diff --git a/libs/shared-types/src/lib/index.ts b/libs/shared-types/src/lib/index.ts index 6ecc77b..a9a7c90 100644 --- a/libs/shared-types/src/lib/index.ts +++ b/libs/shared-types/src/lib/index.ts @@ -6,6 +6,7 @@ export * from './Config'; export * from './Customer'; export * from './LoginLink'; export * from './Operator'; +export * from './Orchestra'; export * from './ApiKey'; export * from './ApiResponse'; export * from './AuthenticatedUser'; diff --git a/package-lock.json b/package-lock.json index ebd9398..2510699 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "jsonwebtoken": "^9.0.3", "libphonenumber-js": "^1.13.10", "mongoose": "^9.0.1", + "qrcode": "^1.5.4", "rxjs": "~7.8.0", "zod": "^4.4.3" }, @@ -64,6 +65,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.10", "@types/node": "20.19.9", + "@types/qrcode": "^1.5.6", "@typescript-eslint/utils": "^8.40.0", "angular-eslint": "^20.3.0", "eslint": "^9.8.0", @@ -329,7 +331,6 @@ "integrity": "sha512-/D84T1Caxll3I2sRihPDR9UaWBhF50M+tAX15PdP6uSh/TxwAlLl9p7Rm1bD0mPjPercqaEKA+h9a9qLP16hug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", @@ -358,7 +359,6 @@ "integrity": "sha512-hdMKY4rUTko8xqeWYGnwwDYDomkeOoLsYsP6SdaHWK7hpGvzWsT6Q/aIv8J8NrCYkLu+M+5nLiKOooweUZu3GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "20.3.13", "jsonc-parser": "3.3.1", @@ -476,7 +476,6 @@ "integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "20.7.0", "eslint-scope": "^9.0.0" @@ -507,7 +506,6 @@ "integrity": "sha512-/5pM3ZS+lLkZgA+n6TMmNV8I6t9Ow1C6Vkj6bXqWeOgFDH5LwnIEZFAKzEDBkCGos0m2gPKPcREcDD5tfp9h4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2003.13", @@ -651,7 +649,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -668,7 +665,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -682,7 +678,6 @@ "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -715,7 +710,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -769,7 +763,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -792,7 +785,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz", "integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -852,7 +844,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -3765,7 +3756,6 @@ "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -3999,6 +3989,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6299,7 +6290,6 @@ "integrity": "sha512-8PFQxtmXc6ukBC4CqGIoc96M2Ly9WVwCPu4Ffvt+K/SB6rGbeFeZoYAwREV1zGNMJ5v5ly6+AHIEOBxNuSnzSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/bridge-react-webpack-plugin": "0.21.6", "@module-federation/cli": "0.21.6", @@ -6385,7 +6375,6 @@ "integrity": "sha512-/u4f+GYRZfHpSvdt5n40lMCS9Cmve7N3JlDreaFXz8xrWDNOp2wvMgiVGpndo5J4iQdtLjpavWStahGQ05B2cQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/enhanced": "0.21.6", "@module-federation/runtime": "0.21.6", @@ -6470,7 +6459,6 @@ "integrity": "sha512-fnP+ZOZTFeBGiTAnxve+axGmiYn2D60h86nUISXjXClK3LUY1krUfPgf6MaD4YDJ4i51OGXZWPekeMe16pkd8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime": "0.21.6", "@module-federation/webpack-bundler-runtime": "0.21.6" @@ -8610,6 +8598,7 @@ "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.2.tgz", "integrity": "sha512-vcX/mBjWAVnWofu7KecotquI2unZ/tITwA7OGdq/mdY/zmGXIEvYhfEYyOQij/LRqi9WAL+iizInTBWnxDhK/Q==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8619,6 +8608,7 @@ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.2.tgz", "integrity": "sha512-xKkudsahUJ1n//55g4fXk5BStVqqmZlz8HQveL45ZxcfDnwvhuYe2GymksQANFsSN+slvrarjrfq8kIxJzbceA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", @@ -8639,13 +8629,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@react-native/codegen/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8655,6 +8647,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8667,6 +8660,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8681,6 +8675,7 @@ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -8697,6 +8692,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -8715,6 +8711,7 @@ "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.2.tgz", "integrity": "sha512-YHXNKoM6Y/HjREySZ5arET2xgiHgg67r1MdwJB//MPJAJ0Xc5g0u6UHxY9VzsHO3Y07dre6s0BinYwjt1SEWvQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/dev-middleware": "0.86.2", "debug": "^4.4.0", @@ -8745,6 +8742,7 @@ "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.2.tgz", "integrity": "sha512-KGS1aV5F6cIqpnoIUhLBXyVzy1oAj8jBFGau6vX4Vy0HXRJN7p+68RU7x6NuyraHvQcR14ccMGT5TkFuNjQ4gA==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8754,6 +8752,7 @@ "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.2.tgz", "integrity": "sha512-/TaVJ2+gGajZPJGrFaObUQmHmlaxAlfmOPZicl6pNKDUjzSgFMpcLkdTOExvb+USYTVdGX1XwxXyvjQdUO2bvg==", "license": "MIT", + "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", @@ -8768,6 +8767,7 @@ "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.2.tgz", "integrity": "sha512-B7L0vKvg+IcEElT7Vpqh1xj5yJAqWUegjbP+bQRaorJMAYnv11GkliTnZV2AdTDfZQJWgOEx8i8LGkHkUg7bnA==", "license": "MIT", + "peer": true, "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.86.2", @@ -8791,6 +8791,7 @@ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", + "peer": true, "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -8807,6 +8808,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -8828,6 +8830,7 @@ "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.2.tgz", "integrity": "sha512-2F6x14NcHMpVmfTTFKfMkpV5dZedZrLiv6PE+c3vgnesV2bjleUBydr4U+NI8VkI7OwW71L0A5qQ76I9LCrfoQ==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8837,6 +8840,7 @@ "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.2.tgz", "integrity": "sha512-bIwNcGBaQ74shB5z1mRkxOpjikimuwsnOCEkZSzL67Z1FTyK1ObpENfyd2QvcvVW9Cjl+tHuw9ynpBnb2jPoJQ==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8845,13 +8849,15 @@ "version": "0.86.2", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.2.tgz", "integrity": "sha512-EzPFc9Y6lzYOWeso2almwXI7f8+qReHxWvT+algsOczb2UhWXIWXDoSvkdwoSfiwwmGt/ijJgKJoeHlzPkLwRg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@react-native/virtualized-lists": { "version": "0.86.2", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.2.tgz", "integrity": "sha512-uO0J72gh3EvE+1/GHRk18QRyBDTRHRB0AraAfojsRjbT7VMuJwKrZYaKGshavoaEud6aw00ZB9/8mTMIKjjcAw==", "license": "MIT", + "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -9343,7 +9349,6 @@ "integrity": "sha512-tkd4nSzTf+pDa9OAE4INi/JEa93HNszjWy5C9+trf4ZCXLLHsHxHQFbzoreuz4Vv2PlCWajgvAdiPMV1vGIkuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.21.6", "@rspack/binding": "1.6.7", @@ -9503,7 +9508,6 @@ "integrity": "sha512-ETJ1budKmrkdxojo5QP6TPr6zQZYGxtWWf8NrX1cBIS851zPCmFkKyhSFLZsoksariYF/LP8ljvm8tlcIzt/XA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "20.3.13", "@angular-devkit/schematics": "20.3.13", @@ -10548,7 +10552,6 @@ "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-6.10.0.tgz", "integrity": "sha512-/WnnQp3uARh2JCFSfAakejTAqwmXVuMVTcRn5r2yDwY2yzZ4R6mt/Cl59VPimVLNSoTyN/KsEwhv9omr3ERazQ==", "license": "MIT", - "peer": true, "dependencies": { "@solana/accounts": "6.10.0", "@solana/addresses": "6.10.0", @@ -12309,6 +12312,7 @@ "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", "license": "MIT", + "peer": true, "dependencies": { "@solana/addresses": "5.5.1", "@solana/codecs-core": "5.5.1", @@ -12334,6 +12338,7 @@ "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", "license": "MIT", + "peer": true, "dependencies": { "@solana/assertions": "5.5.1", "@solana/codecs-core": "5.5.1", @@ -12358,6 +12363,7 @@ "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", "license": "MIT", + "peer": true, "dependencies": { "@solana/errors": "5.5.1" }, @@ -12378,6 +12384,7 @@ "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", "license": "MIT", + "peer": true, "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/codecs-data-structures": "5.5.1", @@ -12402,6 +12409,7 @@ "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", "license": "MIT", + "peer": true, "dependencies": { "@solana/errors": "5.5.1" }, @@ -12422,6 +12430,7 @@ "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", "license": "MIT", + "peer": true, "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/codecs-numbers": "5.5.1", @@ -12444,6 +12453,7 @@ "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", "license": "MIT", + "peer": true, "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/errors": "5.5.1" @@ -12465,6 +12475,7 @@ "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", "license": "MIT", + "peer": true, "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/codecs-numbers": "5.5.1", @@ -12491,6 +12502,7 @@ "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "5.6.2", "commander": "14.0.2" @@ -12515,6 +12527,7 @@ "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.18.0" }, @@ -12532,6 +12545,7 @@ "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", "license": "MIT", + "peer": true, "dependencies": { "@solana/codecs-core": "5.5.1", "@solana/codecs-data-structures": "5.5.1", @@ -12556,6 +12570,7 @@ "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", "license": "MIT", + "peer": true, "dependencies": { "@solana/errors": "5.5.1", "@solana/rpc-spec-types": "5.5.1" @@ -12577,6 +12592,7 @@ "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.18.0" }, @@ -12594,6 +12610,7 @@ "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", "license": "MIT", + "peer": true, "dependencies": { "@solana/addresses": "5.5.1", "@solana/codecs-core": "5.5.1", @@ -12643,6 +12660,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -12655,6 +12673,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -13278,7 +13297,6 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -13436,7 +13454,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.8" @@ -13661,7 +13678,6 @@ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -13907,7 +13923,6 @@ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -14065,7 +14080,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -14087,6 +14101,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -14272,7 +14296,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -14380,7 +14403,6 @@ "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -14456,7 +14478,6 @@ "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", @@ -15155,7 +15176,6 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -15186,6 +15206,7 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", + "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -15211,7 +15232,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -15313,7 +15333,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15409,7 +15428,8 @@ "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -15548,7 +15568,8 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/async": { "version": "3.2.6", @@ -15837,6 +15858,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", "license": "MIT", + "peer": true, "dependencies": { "hermes-parser": "0.36.0" } @@ -16229,7 +16251,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -16346,6 +16367,20 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -16585,6 +16620,7 @@ "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -16613,6 +16649,7 @@ "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -17029,6 +17066,7 @@ "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -17054,6 +17092,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -17063,6 +17102,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -17072,6 +17112,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -17089,13 +17130,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/connect/node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -17108,6 +17151,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -18274,6 +18318,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -18486,6 +18539,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -19060,7 +19119,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -19121,7 +19179,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -19411,6 +19468,7 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -19622,7 +19680,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -19822,6 +19879,7 @@ "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", "license": "(MIT OR Apache-2.0)", + "peer": true, "bin": { "dotslash": "bin/dotslash" }, @@ -20015,7 +20073,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -20060,7 +20117,8 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/follow-redirects": { "version": "1.15.11", @@ -20140,7 +20198,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -20877,19 +20934,22 @@ "version": "250829098.0.16", "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.16.tgz", "integrity": "sha512-xsgzk+mUyvt9t1nUbF8USBlYxajTUtPJhVZ86q85s/SEoMKCF+52YZcudb0ENSnV3T3lV9mgB3s6R7+pH90zgw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hermes-estree": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hermes-parser": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.36.0" } @@ -21522,6 +21582,7 @@ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -22026,7 +22087,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -22049,7 +22109,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -25972,7 +26031,6 @@ "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -26021,7 +26079,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/jsdom": { "version": "20.0.3", @@ -26476,7 +26535,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -26616,6 +26674,7 @@ "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" @@ -26626,6 +26685,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -26634,7 +26694,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lilconfig": { "version": "3.1.3", @@ -26665,7 +26726,6 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -26831,7 +26891,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -26914,7 +26973,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.uniq": { "version": "4.5.0", @@ -27149,6 +27209,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -27254,7 +27315,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -27298,7 +27360,8 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/memory-pager": { "version": "1.5.0", @@ -27345,6 +27408,7 @@ "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", @@ -27398,6 +27462,7 @@ "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", @@ -27413,13 +27478,15 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -27429,6 +27496,7 @@ "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", "license": "MIT", + "peer": true, "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", @@ -27444,6 +27512,7 @@ "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -27456,6 +27525,7 @@ "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", "license": "MIT", + "peer": true, "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", @@ -27475,6 +27545,7 @@ "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", @@ -27489,6 +27560,7 @@ "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", @@ -27509,6 +27581,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -27524,6 +27597,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -27539,6 +27613,7 @@ "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" @@ -27552,6 +27627,7 @@ "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -27564,6 +27640,7 @@ "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" @@ -27577,6 +27654,7 @@ "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", @@ -27597,6 +27675,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -27606,6 +27685,7 @@ "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", @@ -27626,6 +27706,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -27635,6 +27716,7 @@ "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -27652,6 +27734,7 @@ "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -27676,6 +27759,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -27688,25 +27772,29 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro/node_modules/hermes-estree": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -27716,6 +27804,7 @@ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "license": "MIT", + "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -27731,6 +27820,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -27740,6 +27830,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -27755,6 +27846,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -27764,6 +27856,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -27780,6 +27873,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -27789,6 +27883,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -27798,6 +27893,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -27812,6 +27908,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -27827,6 +27924,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -27848,6 +27946,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -28539,6 +28638,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -28902,7 +29013,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/nwsapi": { "version": "2.2.23", @@ -28918,7 +29030,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -29251,6 +29362,7 @@ "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -29512,7 +29624,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -29525,7 +29636,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -29572,7 +29682,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -29819,7 +29928,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -30057,6 +30165,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -30091,7 +30208,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -30846,6 +30962,7 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", "license": "MIT", + "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -30954,6 +31071,113 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -30981,6 +31205,7 @@ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -31063,6 +31288,7 @@ "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -31073,6 +31299,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -31114,6 +31341,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.2.tgz", "integrity": "sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/assets-registry": "0.86.2", "@react-native/codegen": "0.86.2", @@ -31173,6 +31401,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -31181,13 +31410,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-native/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -31197,6 +31428,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -31209,6 +31441,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -31223,6 +31456,7 @@ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -31239,6 +31473,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -31260,6 +31495,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -31366,7 +31602,8 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/regexpu-core": { "version": "6.4.0", @@ -31425,6 +31662,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -31721,7 +31964,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -31758,7 +32000,6 @@ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -31780,7 +32021,6 @@ "integrity": "sha512-l086+s40Z0qP7ckj4T+rI/7tZcwAfcKCG9ah9A808yINWOxZFv0kO0u/UHhR4G9Aimeyax/JNvqh8RE7z1wngg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", @@ -32250,7 +32490,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/schema-utils": { "version": "4.3.3", @@ -32399,6 +32640,7 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -32587,6 +32829,12 @@ "node": ">= 0.8" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -32831,7 +33079,6 @@ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -33067,6 +33314,7 @@ "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -33079,6 +33327,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=8" } @@ -33845,7 +34094,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/thunky": { "version": "1.1.0", @@ -34234,7 +34484,6 @@ "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -34335,8 +34584,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -34354,7 +34602,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -34929,7 +35176,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34944,7 +35190,6 @@ "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.49.0", "@typescript-eslint/parser": "8.49.0", @@ -35188,6 +35433,20 @@ "requires-port": "^1.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -35285,7 +35544,6 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -35376,7 +35634,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", @@ -35457,7 +35716,6 @@ "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -35507,7 +35765,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -35976,7 +36233,8 @@ "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/whatwg-mimetype": { "version": "3.0.0", @@ -36017,6 +36275,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -36045,7 +36309,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -36111,14 +36374,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -36128,7 +36389,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -36165,7 +36425,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -36439,7 +36698,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index da2a81f..cf25b76 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.10", "@types/node": "20.19.9", + "@types/qrcode": "^1.5.6", "@typescript-eslint/utils": "^8.40.0", "angular-eslint": "^20.3.0", "eslint": "^9.8.0", @@ -102,6 +103,7 @@ "jsonwebtoken": "^9.0.3", "libphonenumber-js": "^1.13.10", "mongoose": "^9.0.1", + "qrcode": "^1.5.4", "rxjs": "~7.8.0", "zod": "^4.4.3" }