From a7798527f178568382bd65e9556d6c338ecf4d9f Mon Sep 17 00:00:00 2001 From: Ethan Marcus Date: Thu, 20 Aug 2026 12:02:39 -0700 Subject: [PATCH 1/2] feat: add optional Flashnet Orchestra pay-in and payout rails Keep native solana:USDC intact and convert only at the edges so checkout can take Cash App or other stables and payouts can leave Solana without changing the ledger. Co-authored-by: Cursor --- .env.example | 4 + .env.production.example | 6 +- README.md | 2 + apps/api/src/__tests__/Orchestra.spec.ts | 515 +++++++++++++++++ apps/api/src/__tests__/Payout.spec.ts | 4 + apps/api/src/modules/AppConfig.ts | 15 + apps/api/src/modules/CheckoutPayment.ts | 55 +- apps/api/src/modules/ExternalWallet.ts | 4 +- apps/api/src/modules/Payout.ts | 265 ++++++++- .../src/modules/orchestra/OrchestraClient.ts | 230 ++++++++ .../src/modules/orchestra/OrchestraModule.ts | 538 ++++++++++++++++++ .../src/modules/orchestra/OrchestraRails.ts | 155 +++++ apps/api/src/routes/config.routes.ts | 13 +- apps/api/src/routes/paymentPages.routes.ts | 53 +- apps/api/src/routes/payouts.routes.ts | 18 + apps/api/src/utils/Errors.ts | 5 + .../data/services/checkout-session.service.ts | 59 ++ .../src/app/data/services/config.service.ts | 32 ++ .../data/services/external-wallet.service.ts | 12 +- .../src/app/data/services/payout.service.ts | 4 + .../payout-modal/payout-modal.component.html | 18 +- .../payout-modal/payout-modal.component.ts | 4 + .../connected-account-actions.service.ts | 66 ++- .../features/checkout/checkout.component.html | 131 ++++- .../features/checkout/checkout.component.scss | 73 +++ .../checkout/checkout.component.spec.ts | 41 ++ .../features/checkout/checkout.component.ts | 319 ++++++++++- .../features/checkout/util/checkout-format.ts | 22 + .../external-wallet-form.component.html | 41 +- .../external-wallet-form.component.scss | 6 + .../external-wallet-form.component.ts | 77 ++- apps/web/src/app/styles/checkout.scss | 4 + apps/web/src/app/utils/validation/index.ts | 1 + .../app/utils/validation/wallet-address.ts | 98 ++++ docs/orchestra.md | 43 ++ .../src/lib/ExternalWalletSchema.ts | 124 +++- .../shared-schemas/src/lib/OrchestraSchema.ts | 32 ++ libs/shared-schemas/src/lib/index.ts | 1 + libs/shared-types/src/lib/CheckoutSession.ts | 8 + libs/shared-types/src/lib/Config.ts | 17 + libs/shared-types/src/lib/Orchestra.ts | 41 ++ libs/shared-types/src/lib/Payout.ts | 20 +- libs/shared-types/src/lib/index.ts | 1 + 43 files changed, 3066 insertions(+), 111 deletions(-) create mode 100644 apps/api/src/__tests__/Orchestra.spec.ts create mode 100644 apps/api/src/modules/orchestra/OrchestraClient.ts create mode 100644 apps/api/src/modules/orchestra/OrchestraModule.ts create mode 100644 apps/api/src/modules/orchestra/OrchestraRails.ts create mode 100644 apps/web/src/app/utils/validation/wallet-address.ts create mode 100644 docs/orchestra.md create mode 100644 libs/shared-schemas/src/lib/OrchestraSchema.ts create mode 100644 libs/shared-types/src/lib/Orchestra.ts diff --git a/.env.example b/.env.example index 972bc8e..0b483fd 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://your-orchestra-host +# ORCHESTRA_API_KEY= diff --git a/.env.production.example b/.env.production.example index 784ed0d..8e89d99 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://your-orchestra-host +# ORCHESTRA_API_KEY= \ No newline at end of file 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__/Orchestra.spec.ts b/apps/api/src/__tests__/Orchestra.spec.ts new file mode 100644 index 0000000..031e76f --- /dev/null +++ b/apps/api/src/__tests__/Orchestra.spec.ts @@ -0,0 +1,515 @@ +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, + IsNativeSolanaUsdc, + 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('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/modules/AppConfig.ts b/apps/api/src/modules/AppConfig.ts index 1861b26..d5844f3 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,19 @@ export function IsCheckoutFeeSponsored(): boolean { return !!GetCheckoutFeePayerSecretKey(); } +/** + * Live Flashnet Orchestra: both credentials set and settlement is not simulated. + * When unset or simulated, pay-in/payout use an in-process stand-in. + */ +export function IsOrchestraLive(): boolean { + const { orchestraApiUrl, orchestraApiKey, settlement_rail } = GetAppConfig(); + return ( + !!orchestraApiUrl && + !!orchestraApiKey && + settlement_rail !== 'simulated' + ); +} + /** * 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..c13704c --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraClient.ts @@ -0,0 +1,230 @@ +/** + * @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'; + +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; +} + +/** 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 + ); +} + +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 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 this.Request(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 this.Request(path, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }); + } + + private async Request( + 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) { + throw Unavailable(); + } + return NormalizeOrder(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..c021cae --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraModule.ts @@ -0,0 +1,538 @@ +/** + * @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, + 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; + 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; + } + + 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..fb34e8a --- /dev/null +++ b/apps/api/src/modules/orchestra/OrchestraRails.ts @@ -0,0 +1,155 @@ +/** + * @fileOverview Curated Orchestra stables and unit conversion. + * 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'; + +const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +const EVM_CHAINS = new Set([ + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', +]); + +const PAYIN_SOURCES: OrchestraSource[] = [ + { chain: 'base', asset: 'usdc', label: 'USDC on Base' }, + { chain: 'arbitrum', asset: 'usdc', label: 'USDC on Arbitrum' }, + { chain: 'ethereum', asset: 'usdc', label: 'USDC on Ethereum' }, + { chain: 'optimism', asset: 'usdc', label: 'USDC on Optimism' }, + { chain: 'polygon', asset: 'usdc', label: 'USDC on Polygon' }, + { chain: 'tron', asset: 'usdt', label: 'USDT on Tron' }, +]; + +const ORCHESTRA_PAYOUT_DESTS = new Set( + PAYIN_SOURCES.map((source) => `${source.chain}:${source.asset}`) +); + +/** 1 USDC = 1e6 smallest units = 100 cents → 1 cent = 10_000 smallest units. */ +const SMALLEST_PER_CENT = 10_000; + +export function ListOrchestraPayinSources(): OrchestraSource[] { + return [...PAYIN_SOURCES]; +} + +export function IsNativeSolanaUsdc( + network?: string | null, + currency?: string | null +): boolean { + return ( + NormalizeChain(network || 'solana') === 'solana' && + NormalizeAsset(currency || 'usdc') === 'usdc' + ); +} + +export function IsOrchestraPayoutDest( + network?: string | null, + currency?: string | null +): boolean { + const chain = NormalizeChain(network || ''); + const asset = NormalizeAsset(currency || ''); + return ORCHESTRA_PAYOUT_DESTS.has(`${chain}:${asset}`); +} + +export function IsOrchestraPayinSource(chain: string, asset: string): boolean { + const normalizedChain = NormalizeChain(chain); + const normalizedAsset = NormalizeAsset(asset); + return PAYIN_SOURCES.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 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 (EVM_CHAINS.has(chain)) { + if (!EVM_ADDRESS_RE.test(value)) { + throw new AppError( + 'Wallet address must be a valid EVM 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; + } + + throw new AppError( + 'Unsupported network', + 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'; +} diff --git a/apps/api/src/routes/config.routes.ts b/apps/api/src/routes/config.routes.ts index 835ed8e..6f7d509 100644 --- a/apps/api/src/routes/config.routes.ts +++ b/apps/api/src/routes/config.routes.ts @@ -20,8 +20,13 @@ 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 } from '../modules/orchestra/OrchestraRails'; const router = express.Router(); @@ -36,6 +41,10 @@ const apiKeyModule = new ApiKeyModule(db); function BuildPublicConfig(platformAccount: Account | null): PublicConfig { const { livemode, settlement_rail } = GetAppConfig(); const settlement = settlement_rail ?? 'simulated'; + const orchestra = { + enabled: IsOrchestraLive() || settlement === 'simulated', + sources: ListOrchestraPayinSources(), + }; if (!platformAccount) { return { @@ -46,6 +55,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { privacy_url: '', livemode, settlement, + orchestra, }; } @@ -60,6 +70,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { privacy_url: platformAccount.settings?.privacy_url || '', livemode, settlement, + orchestra, }; } 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..b85911f 100644 --- a/apps/web/src/app/data/services/config.service.ts +++ b/apps/web/src/app/data/services/config.service.ts @@ -2,6 +2,22 @@ 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; + sources: OrchestraSource[]; +} + +type PublicConfigWithOrchestra = PublicConfig & { + orchestra?: OrchestraPublicConfig; +}; + @Injectable({ providedIn: 'root', }) @@ -150,6 +166,22 @@ 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; + } + + 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..571b146 100644 --- a/apps/web/src/app/features/checkout/checkout.component.html +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -126,6 +126,83 @@

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

+ {{ WaitingTitle() }} +

+

{{ WaitingSubtitle() }}

+ + @if (selectedMethod() === 'cashapp') { @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 (IsSimulatedSettlement()) { + + } + + +
} @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..08c81a6 100644 --- a/apps/web/src/app/features/checkout/checkout.component.scss +++ b/apps/web/src/app/features/checkout/checkout.component.scss @@ -352,6 +352,79 @@ 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; +} + +.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..5120d7d 100644 --- a/apps/web/src/app/features/checkout/checkout.component.spec.ts +++ b/apps/web/src/app/features/checkout/checkout.component.spec.ts @@ -27,10 +27,15 @@ describe('CheckoutComponent mobile wallet handoff', () => { const configService = { IsSimulatedSettlement: jest.fn(() => false), LoadConfig: jest.fn().mockResolvedValue({}), + OrchestraEnabled: 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 +113,7 @@ describe('CheckoutComponent mobile wallet handoff', () => { }); afterEach(() => { + component.ngOnDestroy(); jest.restoreAllMocks(); jest.clearAllMocks(); }); @@ -310,4 +316,39 @@ 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); + }); }); diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts index e38ba29..36deb6f 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,6 +50,7 @@ import { HasCheckoutConfirmationDetails, } from './util/checkout-completion'; import { + FormatStableAmount, FormatUsdcAmount, GetCheckoutSubmitLabel, } from './util/checkout-format'; @@ -53,7 +61,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 +129,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 +140,11 @@ 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); + copiedField: WritableSignal = signal(null); simulatedWalletOpen: WritableSignal = signal(false); mobileWalletHandoffRequested: WritableSignal = signal(false); confirmationExpanded: WritableSignal = signal(false); @@ -146,6 +166,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 +177,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 +224,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 +309,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 +321,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 +364,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 +429,254 @@ export class CheckoutComponent implements OnInit { this.paymentError.set('Payment was declined'); } + OrchestraEnabled(): boolean { + return this.configService.OrchestraEnabled(); + } + + 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; + } + + OrchestraDepositAddress(): string | 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 '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.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); + 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(); + } + + 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 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.IsSimulatedSettlement()) 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 +1098,7 @@ export class CheckoutComponent implements OnInit { } readonly FormatAmount = FormatUsdcAmount; + readonly FormatStableAmount = FormatStableAmount; DiscountAmount(): number { return this.checkoutSession()?.total_details?.amount_discount ?? 0; @@ -839,6 +1129,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 +1145,17 @@ export class CheckoutComponent implements OnInit { } MethodDetailLabel(): string { + if (this.selectedMethod() === 'cashapp') { + return this.IsSimulatedSettlement() + ? 'Paying with test Cash App' + : 'Paying with 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 +1168,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/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..02810e4 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..b812e3f 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, + WALLET_CURRENCIES, + WALLET_NETWORKS, } from '../../../utils'; export type ExternalWalletFormMode = 'onboard' | 'edit'; @@ -53,13 +55,15 @@ 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; + readonly networkOptions = WALLET_NETWORKS; + readonly currencyOptions = WALLET_CURRENCIES; ngOnInit(): void { this.InitializeForm(); @@ -75,8 +79,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 +105,20 @@ export class ExternalWalletFormComponent implements OnInit, OnChanges { this.EmitFormChange(); } + OnNetworkChange(value: string): void { + this.network.set(value.toLowerCase()); + if (!this.IsSolanaNetwork()) { + this.showWalletGuide.set(false); + } + this.ValidateWalletAddress(); + this.EmitFormChange(); + } + + OnCurrencyChange(value: string): void { + this.currency.set(value.toLowerCase()); + this.EmitFormChange(); + } + ValidateWalletAddress(): void { const address = this.walletAddress(); @@ -104,12 +128,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 +152,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,6 +169,39 @@ 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()); diff --git a/apps/web/src/app/styles/checkout.scss b/apps/web/src/app/styles/checkout.scss index 1c07de1..86d7cb3 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; 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..b7449dc --- /dev/null +++ b/apps/web/src/app/utils/validation/wallet-address.ts @@ -0,0 +1,98 @@ +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: 'optimism', label: 'Optimism' }, + { value: 'polygon', label: 'Polygon' }, + { value: 'tron', label: 'Tron' }, +] as const; + +export const WALLET_CURRENCIES = [ + { value: 'usdc', label: 'USDC' }, + { value: 'usdt', label: 'USDT' }, +] as const; + +const EVM_NETWORKS = new Set([ + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', +]); + +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/docs/orchestra.md b/docs/orchestra.md new file mode 100644 index 0000000..2bf12e1 --- /dev/null +++ b/docs/orchestra.md @@ -0,0 +1,43 @@ +# 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. + +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 (v1) + +Pay-in sources: Cash App, plus `base/usdc`, `arbitrum/usdc`, `ethereum/usdc`, `optimism/usdc`, `polygon/usdc`, `tron/usdt`. + +Payout dests: the same list. `solana/usdc` stays 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..cf65598 100644 --- a/libs/shared-schemas/src/lib/ExternalWalletSchema.ts +++ b/libs/shared-schemas/src/lib/ExternalWalletSchema.ts @@ -1,29 +1,113 @@ 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', + 'tron', +]); +const EVM_NETWORKS = new Set([ + 'base', + 'arbitrum', + 'ethereum', + 'optimism', + 'polygon', +]); + +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' || EVM_NETWORKS.has(network)) && currency !== 'usdc') { + ctx.addIssue({ + code: 'custom', + path: ['currency'], + message: 'This network only supports usdc', + }); + 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..e1014dd 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,14 @@ 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; + 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..4f89286 --- /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; // internal id: base, arbitrum, ethereum, optimism, polygon, 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'; From d13d143ca1d0244ea5e148afcfd8c328c5eb4012 Mon Sep 17 00:00:00 2001 From: Ethan Marcus Date: Thu, 20 Aug 2026 13:16:57 -0700 Subject: [PATCH 2/2] feat: match Orchestra stables to Flashnet routes and polish Cash App checkout Drive pay-in/payout pairs from the live route table, show a desktop Cash App QR, and keep auth rate limits from colliding with checkout polling. Co-authored-by: Cursor --- .env.example | 2 +- .env.production.example | 4 +- apps/api/src/__tests__/ExternalWallet.spec.ts | 32 ++ apps/api/src/__tests__/Orchestra.spec.ts | 89 ++++ apps/api/src/middleware/RateLimiter.ts | 9 +- apps/api/src/modules/AppConfig.ts | 12 +- .../src/modules/orchestra/OrchestraClient.ts | 98 +++- .../src/modules/orchestra/OrchestraModule.ts | 18 +- .../src/modules/orchestra/OrchestraRails.ts | 183 ++++++-- apps/api/src/routes/config.routes.ts | 16 +- .../src/app/data/services/config.service.ts | 5 + .../features/checkout/checkout.component.html | 27 +- .../features/checkout/checkout.component.scss | 25 + .../checkout/checkout.component.spec.ts | 8 + .../features/checkout/checkout.component.ts | 44 +- .../app/features/checkout/util/cashapp-qr.ts | 10 + .../external-wallet-form.component.html | 4 +- .../external-wallet-form.component.ts | 65 ++- apps/web/src/app/styles/checkout.scss | 15 +- .../app/utils/validation/wallet-address.ts | 16 +- apps/web/src/assets/images/logos/cash-app.png | Bin 0 -> 3608 bytes docs/orchestra.md | 10 +- .../src/lib/ExternalWalletSchema.ts | 6 +- libs/shared-types/src/lib/Config.ts | 2 + libs/shared-types/src/lib/Orchestra.ts | 2 +- package-lock.json | 438 ++++++++++++++---- package.json | 2 + 27 files changed, 954 insertions(+), 188 deletions(-) create mode 100644 apps/web/src/app/features/checkout/util/cashapp-qr.ts create mode 100644 apps/web/src/assets/images/logos/cash-app.png diff --git a/.env.example b/.env.example index 0b483fd..7a3bde0 100644 --- a/.env.example +++ b/.env.example @@ -77,5 +77,5 @@ LIVEMODE= # ZONELESS_TELEMETRY= # Flashnet Orchestra (optional). Server key only (fn_...). Leave unset for simulated rails in test mode. -# ORCHESTRA_API_URL=https://your-orchestra-host +# ORCHESTRA_API_URL=https://orchestration.flashnet.xyz # ORCHESTRA_API_KEY= diff --git a/.env.production.example b/.env.production.example index 8e89d99..06b6953 100644 --- a/.env.production.example +++ b/.env.production.example @@ -71,5 +71,5 @@ LIVEMODE=true # ZONELESS_TELEMETRY= # Flashnet Orchestra (optional). Server key only (fn_...). Leave unset for simulated rails in test mode. -# ORCHESTRA_API_URL=https://your-orchestra-host -# ORCHESTRA_API_KEY= \ No newline at end of file +# ORCHESTRA_API_URL=https://orchestration.flashnet.xyz +# ORCHESTRA_API_KEY= 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 index 031e76f..3b1bb05 100644 --- a/apps/api/src/__tests__/Orchestra.spec.ts +++ b/apps/api/src/__tests__/Orchestra.spec.ts @@ -11,7 +11,11 @@ import { OrchestraModule } from '../modules/orchestra/OrchestraModule'; import { CentsToFiatUsd, CentsToUsdcSmallest, + DeriveOrchestraStableSources, IsNativeSolanaUsdc, + IsOrchestraPayoutDest, + ListOrchestraPayinSources, + ResetOrchestraRouteCache, UsdcSmallestToCents, } from '../modules/orchestra/OrchestraRails'; import { @@ -194,6 +198,91 @@ describe('Orchestra', () => { }); }); + 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(); 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 d5844f3..cb17f2b 100644 --- a/apps/api/src/modules/AppConfig.ts +++ b/apps/api/src/modules/AppConfig.ts @@ -158,16 +158,12 @@ export function IsCheckoutFeeSponsored(): boolean { } /** - * Live Flashnet Orchestra: both credentials set and settlement is not simulated. - * When unset or simulated, pay-in/payout use an in-process stand-in. + * 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, settlement_rail } = GetAppConfig(); - return ( - !!orchestraApiUrl && - !!orchestraApiKey && - settlement_rail !== 'simulated' - ); + const { orchestraApiUrl, orchestraApiKey } = GetAppConfig(); + return !!orchestraApiUrl && !!orchestraApiKey; } /** diff --git a/apps/api/src/modules/orchestra/OrchestraClient.ts b/apps/api/src/modules/orchestra/OrchestraClient.ts index c13704c..ec11560 100644 --- a/apps/api/src/modules/orchestra/OrchestraClient.ts +++ b/apps/api/src/modules/orchestra/OrchestraClient.ts @@ -8,6 +8,7 @@ import { GetAppConfig, IsOrchestraLive } from '../AppConfig'; import { AppError } from '../../utils/AppError'; import { ERRORS } from '../../utils/Errors'; +import { Logger } from '../../utils/Logger'; export { IsOrchestraLive }; @@ -38,6 +39,25 @@ export interface OrchestraStatusInput { 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; @@ -103,6 +123,18 @@ function Unavailable(): AppError { ); } +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; @@ -151,6 +183,19 @@ export class OrchestraClient { ); } + 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 { @@ -185,30 +230,34 @@ export class OrchestraClient { body: Record, idempotencyKey: string ): Promise { - return this.Request(path, { - method: 'POST', - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - 'X-Idempotency-Key': idempotencyKey, - }, - body: JSON.stringify(body), - }); + 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 this.Request(path, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.apiKey}`, - }, - }); + return NormalizeOrder( + await this.RequestJson(path, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + }) + ); } - private async Request( + private async RequestJson( path: string, init: RequestInit - ): Promise { + ): Promise { if (!this.apiUrl || !this.apiKey) { throw Unavailable(); } @@ -219,9 +268,20 @@ export class OrchestraClient { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { - throw Unavailable(); + 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 NormalizeOrder(await response.json()); + 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 index c021cae..b88396a 100644 --- a/apps/api/src/modules/orchestra/OrchestraModule.ts +++ b/apps/api/src/modules/orchestra/OrchestraModule.ts @@ -28,13 +28,14 @@ import { CentsToFiatUsd, CentsToUsdcSmallest, IsNativeSolanaUsdc, - IsOrchestraPayinSource, - IsOrchestraPayoutDest, - NormalizeAsset, - NormalizeChain, - SimulatedDepositAddress, - ToFlashnetAsset, - UsdcSmallestToCents, + IsOrchestraPayinSource, + IsOrchestraPayoutDest, + NormalizeAsset, + NormalizeChain, + RefreshOrchestraRoutes, + SimulatedDepositAddress, + ToFlashnetAsset, + UsdcSmallestToCents, } from './OrchestraRails'; const TERMINAL_STATUSES = new Set(['completed', 'failed', 'refunded']); @@ -76,6 +77,7 @@ export class OrchestraModule { ); const method = input.method; + await RefreshOrchestraRoutes(this.client); const sourceChain = method === 'deposit' ? NormalizeChain(input.source_chain || '') @@ -302,6 +304,8 @@ export class OrchestraModule { return null; } + await RefreshOrchestraRoutes(this.client); + if (!IsOrchestraPayoutDest(destWallet.network, destWallet.currency)) { throw new AppError( 'Unsupported Orchestra payout destination', diff --git a/apps/api/src/modules/orchestra/OrchestraRails.ts b/apps/api/src/modules/orchestra/OrchestraRails.ts index fb34e8a..86b30b5 100644 --- a/apps/api/src/modules/orchestra/OrchestraRails.ts +++ b/apps/api/src/modules/orchestra/OrchestraRails.ts @@ -1,5 +1,5 @@ /** - * @fileOverview Curated Orchestra stables and unit conversion. + * @fileOverview Orchestra stables from Flashnet's live route table. * Books stay USDC.sol; convert only at the Flashnet edges. * * @module OrchestraRails @@ -8,36 +8,51 @@ 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 EVM_CHAINS = new Set([ - 'base', - 'arbitrum', - 'ethereum', - 'optimism', - 'polygon', -]); +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; -const PAYIN_SOURCES: OrchestraSource[] = [ - { chain: 'base', asset: 'usdc', label: 'USDC on Base' }, +/** + * 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: 'optimism', asset: 'usdc', label: 'USDC on Optimism' }, + { 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' }, ]; -const ORCHESTRA_PAYOUT_DESTS = new Set( - PAYIN_SOURCES.map((source) => `${source.chain}:${source.asset}`) -); +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 [...PAYIN_SOURCES]; + return [...cachedSources]; } export function IsNativeSolanaUsdc( @@ -45,8 +60,8 @@ export function IsNativeSolanaUsdc( currency?: string | null ): boolean { return ( - NormalizeChain(network || 'solana') === 'solana' && - NormalizeAsset(currency || 'usdc') === 'usdc' + NormalizeChain(network || 'solana') === LEDGER_CHAIN && + NormalizeAsset(currency || 'usdc') === LEDGER_ASSET ); } @@ -56,13 +71,15 @@ export function IsOrchestraPayoutDest( ): boolean { const chain = NormalizeChain(network || ''); const asset = NormalizeAsset(currency || ''); - return ORCHESTRA_PAYOUT_DESTS.has(`${chain}:${asset}`); + 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 PAYIN_SOURCES.some( + return cachedSources.some( (source) => source.chain === normalizedChain && source.asset === normalizedAsset ); @@ -99,6 +116,92 @@ export function UsdcSmallestToCents(amount: string): number { 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(); @@ -114,17 +217,6 @@ export function ValidateWalletAddress(network: string, address: string): void { return; } - if (EVM_CHAINS.has(chain)) { - if (!EVM_ADDRESS_RE.test(value)) { - throw new AppError( - 'Wallet address must be a valid EVM address', - ERRORS.VALIDATION_ERROR.status, - ERRORS.VALIDATION_ERROR.type - ); - } - return; - } - if (chain === 'tron') { if (!value.startsWith('T') || !BASE58_RE.test(value)) { throw new AppError( @@ -136,8 +228,12 @@ export function ValidateWalletAddress(network: string, address: string): void { return; } + if (EVM_ADDRESS_RE.test(value)) { + return; + } + throw new AppError( - 'Unsupported network', + 'Wallet address must be a valid EVM address', ERRORS.VALIDATION_ERROR.status, ERRORS.VALIDATION_ERROR.type ); @@ -153,3 +249,28 @@ export function SimulatedDepositAddress(chain: string): string { } 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 6f7d509..4be9c33 100644 --- a/apps/api/src/routes/config.routes.ts +++ b/apps/api/src/routes/config.routes.ts @@ -26,7 +26,10 @@ import { IsOrchestraLive, } from '../modules/AppConfig'; import { SolanaExplorerUrl } from '../modules/chains/Solana'; -import { ListOrchestraPayinSources } from '../modules/orchestra/OrchestraRails'; +import { + ListOrchestraPayinSources, + RefreshOrchestraRoutes, +} from '../modules/orchestra/OrchestraRails'; const router = express.Router(); @@ -38,11 +41,16 @@ 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: IsOrchestraLive() || settlement === 'simulated', + enabled: orchestraLive || settlement === 'simulated', + live: orchestraLive, sources: ListOrchestraPayinSources(), }; @@ -150,7 +158,7 @@ router.get( } } - res.json(BuildPublicConfig(platformAccount)); + res.json(await BuildPublicConfig(platformAccount)); }) ); diff --git a/apps/web/src/app/data/services/config.service.ts b/apps/web/src/app/data/services/config.service.ts index b85911f..5a020aa 100644 --- a/apps/web/src/app/data/services/config.service.ts +++ b/apps/web/src/app/data/services/config.service.ts @@ -11,6 +11,7 @@ export interface OrchestraSource { export interface OrchestraPublicConfig { enabled: boolean; + live?: boolean; sources: OrchestraSource[]; } @@ -174,6 +175,10 @@ export class ConfigService { return this.OrchestraConfig()?.enabled === true; } + OrchestraLive(): boolean { + return this.OrchestraConfig()?.live === true; + } + OrchestraSources(): OrchestraSource[] { return this.OrchestraConfig()?.sources ?? []; } diff --git a/apps/web/src/app/features/checkout/checkout.component.html b/apps/web/src/app/features/checkout/checkout.component.html index 571b146..3538564 100644 --- a/apps/web/src/app/features/checkout/checkout.component.html +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -131,16 +131,31 @@

class="checkout-waiting waiting-state" aria-labelledby="checkout-waiting-title" > + @if (!ShowCashAppQr()) {
+ }

{{ WaitingTitle() }}

{{ WaitingSubtitle() }}

- @if (selectedMethod() === 'cashapp') { @if (OrchestraCashAppUrl(); as - cashAppUrl) { + @if (selectedMethod() === 'cashapp') { + @if (ShowCashAppQr()) { +
+ Scan to pay with Cash App + +
+ } @if (OrchestraCashAppUrl(); as cashAppUrl) { {{ copiedField() === 'memo' ? 'Copied' : 'Copy' }}
- } @if (IsSimulatedSettlement()) { + } @if (!OrchestraLive()) {