From 6609d203742b9273b572e1af6bcdd3da6807110c Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Thu, 13 Aug 2026 15:37:50 -0700 Subject: [PATCH] feat: test mode uses simulated funds instead of devnet --- .agents/skills/zoneless-marketplace/SKILL.md | 31 ++- .agents/skills/zoneless-payments/SKILL.md | 22 +- .env.example | 9 +- .env.production.example | 9 +- CONTRIBUTING.md | 3 + apps/api/src/__tests__/Payout.spec.ts | 7 +- apps/api/src/__tests__/Settlement.spec.ts | 79 +++++++ apps/api/src/__tests__/mocks/Solana.ts | 1 + apps/api/src/main.ts | 10 +- apps/api/src/modules/AppConfig.ts | 28 ++- apps/api/src/modules/Balance.ts | 14 +- apps/api/src/modules/CheckoutPayment.ts | 33 ++- apps/api/src/modules/Invoice.ts | 9 +- apps/api/src/modules/Payout.ts | 8 +- apps/api/src/modules/Price.ts | 11 +- apps/api/src/modules/TopUp.ts | 28 +++ apps/api/src/modules/TopUpMonitor.ts | 9 +- apps/api/src/modules/chains/Settlement.ts | 221 ++++++++++++++++++ apps/api/src/modules/chains/Solana.ts | 2 + apps/api/src/routes/config.routes.ts | 5 +- apps/api/src/routes/index.ts | 2 + apps/api/src/routes/testHelpers.routes.ts | 163 +++++++++++++ .../src/app/data/services/config.service.ts | 7 + .../src/app/data/services/topup.service.ts | 9 + .../add-funds-panel.component.html | 46 +++- .../add-funds-panel.component.scss | 29 +++ .../add-funds-panel.component.ts | 35 +++ .../balance-detail.component.html | 4 + .../balance-detail.component.ts | 8 +- .../payout-modal/payout-modal.component.html | 2 + .../payout-modal/payout-modal.component.ts | 6 + .../connected-account-actions.service.ts | 52 +++-- .../payment-intent-detail.component.ts | 2 +- .../features/checkout/checkout.component.html | 40 +++- .../features/checkout/checkout.component.scss | 65 ++++++ .../checkout/checkout.component.spec.ts | 17 ++ .../features/checkout/checkout.component.ts | 61 ++++- docker-compose.yml | 1 + libs/shared-types/src/lib/Config.ts | 11 + 39 files changed, 998 insertions(+), 101 deletions(-) create mode 100644 apps/api/src/__tests__/Settlement.spec.ts create mode 100644 apps/api/src/modules/chains/Settlement.ts create mode 100644 apps/api/src/routes/testHelpers.routes.ts diff --git a/.agents/skills/zoneless-marketplace/SKILL.md b/.agents/skills/zoneless-marketplace/SKILL.md index fa95507..4cf3f3d 100644 --- a/.agents/skills/zoneless-marketplace/SKILL.md +++ b/.agents/skills/zoneless-marketplace/SKILL.md @@ -30,14 +30,13 @@ the human explicitly asks for that additional work. Use plain language whenever the human must act. Briefly explain that USDC is a digital dollar, Solana is the network carrying it, SOL pays the small network fee, a wallet address is safe to share, and a wallet secret key authorizes -spending and must remain private. Explain that devnet/test mode uses free, -worthless test funds on a network separate from mainnet/live mode, and that a -faucet is a website that supplies those test funds. Do not use terms such as -gas, mint, token account, cluster, or airdrop without explaining them. +spending and must remain private. Explain that test mode uses fake USDC so they +can onboard sellers and run payouts without a wallet, a faucet, or the live +Solana network. Live mode is when real USDC moves on Solana. Do not use terms +such as gas, mint, token account, cluster, or airdrop without explaining them. -Give concrete instructions: name the network and asset, say where to copy the -public wallet address, link the relevant guide or faucet, and distinguish test -funds from real funds. Never assume prior wallet or blockchain experience. +Give concrete instructions: distinguish test funds from real funds. Never +assume prior wallet or blockchain experience. ## Read documentation just in time @@ -195,7 +194,8 @@ Use the payout helper that matches the existing worker: `payouts.broadcast(...)` when the marketplace must process an explicitly claimed payout ID. -The SDK already builds and signs Solana transactions. Do not install +The SDK already builds and signs Solana transactions. In simulated test mode +it passes dummy `sim_tx` payloads through without signing. Do not install `@solana/web3.js`, `@solana/spl-token`, `bs58`, or add mint, cluster, RPC, token account, or platform-account configuration to a Node application. Those are Zoneless implementation details. The marketplace app needs only the relevant @@ -351,14 +351,13 @@ Report: - required environment-variable names, never values; - tests run and any unverified behavior; - the test-mode onboarding path; -- that before an end-to-end test payout, the human must fund the test - platform's public wallet address (the `wallet_public_key` returned by setup or - the address shown under **Balance**) with devnet SOL from - `https://faucet.solana.com/` and test USDC from - `https://faucet.circle.com/`, selecting **USDC** and **Solana Devnet** at the - Circle faucet; link - `https://zoneless.com/docs/local-development.md` for the complete steps and - warn never to send real SOL or USDC to devnet; +- that a test payout still uses the live two-step flow: create stays + pending, then `processAll` / build and broadcast marks it paid with + simulated USDC. No wallet, faucet, or Devnet is required β€” broadcast + the unsigned dummy transaction. Use **Add test USDC** + on the dashboard Balance page (or + `POST /v1/test_helpers/treasury/topups`) if the platform ledger needs a + balance first; - where the human must configure the API key and webhook secret; - the exact live-mode promotion sequence, including the bound live profile name, `https://api.zoneless.com`, deployment secret changes, live webhook diff --git a/.agents/skills/zoneless-payments/SKILL.md b/.agents/skills/zoneless-payments/SKILL.md index fc17e8a..16ed168 100644 --- a/.agents/skills/zoneless-payments/SKILL.md +++ b/.agents/skills/zoneless-payments/SKILL.md @@ -30,10 +30,10 @@ explicitly asks for that additional work. Use plain language whenever the human must act. Briefly explain that USDC is a digital dollar and Solana is the network carrying it, that a wallet address is safe to share, and that a wallet secret key authorizes spending and must remain -private. Explain that test mode uses free, worthless test funds on a network -separate from live mode, and that a faucet is a website that supplies those -test funds. Do not use terms such as gas, mint, token account, cluster, or -airdrop without explaining them. +private. Explain that test mode uses fake USDC so they can try checkout +without a wallet, a faucet, or the live Solana network. Live mode is when +real USDC moves on Solana. Do not use terms such as gas, mint, token account, +cluster, or airdrop without explaining them. The merchant does not need to hold, fund, or manage any crypto to receive subscription payments. Money moves from the customer's wallet to the platform @@ -365,9 +365,9 @@ Run the project's formatter, focused tests, linter, type checker, and build. Fix regressions introduced by the integration. Open a test Checkout Session and verify that the hosted page shows the expected -product, amount, cadence, and return URLs. When a test wallet is available, -complete one supervised test subscription and verify the resulting entitlement. -Do not describe unexecuted end-to-end behavior as tested. +product, amount, cadence, and return URLs. Complete one test subscription with +the simulated wallet (or the test_helpers complete endpoint) and verify the +resulting entitlement. Do not describe unexecuted end-to-end behavior as tested. ## Human handoff @@ -381,10 +381,10 @@ Report: `ZONELESS_API_URL`, and `ZONELESS_WEBHOOK_SECRET`), never values; - checks run, distinguishing static checks, unit tests, hosted-checkout verification, and completed end-to-end payments; -- that to test a subscription end to end, the human needs a Solana wallet - holding test USDC, available free from `https://faucet.circle.com/` by - selecting **USDC** and **Solana Devnet**, and that they must never send real - funds to a test network; +- that to test a subscription end to end, the human opens hosted checkout in + test mode and approves the simulated wallet (no Phantom, faucet, or Devnet + switch). They can also complete a session with + `POST /v1/test_helpers/checkout/sessions/:id/complete`; - that the customer approves the plan once at checkout and every later cycle is collected automatically, so no further signing is required; - where the human must configure the live API key, live API URL, and live diff --git a/.env.example b/.env.example index 30438c6..972bc8e 100644 --- a/.env.example +++ b/.env.example @@ -45,13 +45,18 @@ SINGLE_TENANT= # /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored). OPERATOR_API_KEY= -# Solana RPC endpoint +# Solana RPC endpoint (only needed when SETTLEMENT_RAIL=onchain) # e.g. https://api.devnet.solana.com or a Helius/QuickNode URL SOLANA_RPC_URL= -# LIVEMODE controls Solana network: false = devnet (test), true = mainnet (real USDC) +# LIVEMODE=true uses Solana mainnet (real USDC). Unset/false is test mode. LIVEMODE= +# How test mode settles money: simulated (default, fake funds) or onchain +# (Solana Devnet β€” internal escape hatch). Live mode is always on-chain. +# SETTLEMENT_RAIL=simulated +# SETTLEMENT_RAIL=onchain + # Base58 secret key that owns on-chain subscription plans and signs pulls. # Required to create recurring prices. Existing plans must keep the same owner. # SUBSCRIPTION_OPERATOR_KEY= diff --git a/.env.production.example b/.env.production.example index dbc9172..784ed0d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -38,13 +38,18 @@ SINGLE_TENANT=true # /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored). OPERATOR_API_KEY= -# Solana RPC endpoint +# Solana RPC endpoint (required for live / on-chain settlement) # e.g. https://api.mainnet-beta.solana.com or a Helius/QuickNode URL SOLANA_RPC_URL= -# LIVEMODE controls Solana network: true = mainnet (real USDC), false = devnet (test) +# LIVEMODE=true uses Solana mainnet (real USDC). Unset/false is test mode +# with simulated funds by default. LIVEMODE=true +# Test mode settlement: simulated (default) or onchain (Solana Devnet). +# Invalid when LIVEMODE=true. +# SETTLEMENT_RAIL=onchain + # Base58 secret key that owns on-chain subscription plans and signs pulls. # Required to create recurring prices. Must match the owner used when plans # were first created. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66897f3..6f013fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,9 @@ npx nx lint api --fix npx nx lint web --fix ``` +Test mode uses simulated funds by default (`SETTLEMENT_RAIL=simulated`). To +exercise Solana Devnet instead, set `LIVEMODE=false` and `SETTLEMENT_RAIL=onchain`. + ## How to Contribute ### Reporting Bugs diff --git a/apps/api/src/__tests__/Payout.spec.ts b/apps/api/src/__tests__/Payout.spec.ts index 5479855..0a032a9 100644 --- a/apps/api/src/__tests__/Payout.spec.ts +++ b/apps/api/src/__tests__/Payout.spec.ts @@ -22,8 +22,8 @@ jest.mock('../modules/AppConfig', () => ({ appSecret: 'test-secret', })), })); -jest.mock('../modules/chains/Solana', () => ({ - Solana: jest.fn().mockImplementation(() => ({ +jest.mock('../modules/chains/Settlement', () => ({ + GetSettlement: () => ({ CheckWalletExists: jest.fn().mockResolvedValue(true), GetUSDCBalance: jest.fn().mockResolvedValue(100), GetSOLBalance: jest.fn().mockResolvedValue(1), @@ -39,7 +39,8 @@ jest.mock('../modules/chains/Solana', () => ({ signature: 'sig123', viewer_url: 'https://solscan.io/tx/sig123', }), - })), + }), + IsSimulatedSettlement: jest.fn(() => false), })); describe('PayoutModule', () => { diff --git a/apps/api/src/__tests__/Settlement.spec.ts b/apps/api/src/__tests__/Settlement.spec.ts new file mode 100644 index 0000000..c707c32 --- /dev/null +++ b/apps/api/src/__tests__/Settlement.spec.ts @@ -0,0 +1,79 @@ +import { + SimulatedSettlement, + SimulatedSignature, +} from '../modules/chains/Settlement'; + +describe('SimulatedSettlement', () => { + const settlement = new SimulatedSettlement(); + + it('accepts a plausible wallet address', async () => { + await expect( + settlement.CheckWalletExists( + 'D8VMZCmmTUUfhejNhNQKAmqvZCKfUq1qU6RqQKxQwXyX' + ) + ).resolves.toBe(true); + await expect(settlement.CheckWalletExists('short')).resolves.toBe(false); + }); + + it('verifies a simulated checkout signature', async () => { + const signature = SimulatedSignature('cs_z_1', 'Payer111'); + const result = await settlement.VerifyCheckoutPayment(signature, { + merchantWalletAddress: 'Merchant111', + amountInCents: 1500, + checkoutSessionId: 'cs_z_1', + }); + expect(result).toEqual({ + verified: true, + amount_cents: 1500, + payer_address: 'Payer111', + }); + }); + + it('rejects a non-simulated checkout signature', async () => { + const result = await settlement.VerifyCheckoutPayment('real_sig', { + merchantWalletAddress: 'Merchant111', + amountInCents: 1500, + checkoutSessionId: 'cs_z_1', + }); + expect(result.verified).toBe(false); + }); + + it('skips subscription authority init so checkout is one approval', async () => { + await expect( + settlement.BuildInitSubscriptionAuthorityTransaction('Payer111') + ).resolves.toBeNull(); + }); + + it('collects a subscription without hitting a chain', async () => { + await expect( + settlement.CollectSubscriptionPayment({ + subscriberWallet: 'Payer111', + planPda: 'sim_plan_price_z_1', + subscriptionPda: 'sim_sub_1', + amountCents: 2000, + }) + ).resolves.toEqual({ + signature: 'sim_collect:Payer111', + alreadyCollected: false, + }); + }); + + it('builds and broadcasts a dummy payout without a chain', async () => { + await expect( + settlement.BuildBatchPayoutTransaction('Sender111', [ + { destinationAddress: 'Dest111', amountInCents: 500 }, + ]) + ).resolves.toMatchObject({ + unsigned_transaction: 'sim_tx', + last_valid_block_height: 1, + recipients_count: 1, + }); + await expect( + settlement.BroadcastSignedTransaction('sim_tx') + ).resolves.toEqual({ + signature: 'sim_sig_payout', + status: 'paid', + viewer_url: '', + }); + }); +}); diff --git a/apps/api/src/__tests__/mocks/Solana.ts b/apps/api/src/__tests__/mocks/Solana.ts index 682244f..a8524eb 100644 --- a/apps/api/src/__tests__/mocks/Solana.ts +++ b/apps/api/src/__tests__/mocks/Solana.ts @@ -30,6 +30,7 @@ export class Solana { GetSOLBalance = jest.fn().mockResolvedValue(1); GetUSDCMintAddress = jest.fn().mockReturnValue('UsdcMint_test'); GetPlanOwnerPublicKey = jest.fn().mockReturnValue('PlanOwner111'); + GetIncomingDeposits = jest.fn().mockResolvedValue([]); GetIncomingUSDCDeposits = jest.fn().mockResolvedValue([]); BuildCheckoutPaymentTransaction = jest.fn().mockResolvedValue({ unsigned_transaction: 'base64tx', diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1f42a4c..8a40556 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -225,11 +225,13 @@ async function StartServer() { console.log(`πŸ“Š Health check at http://localhost:${port}/api/health`); console.log(''); const livemode = appConfig.livemode; - console.log( - livemode + const settlement = + appConfig.settlement_rail === 'simulated' + ? 'πŸ§ͺ Simulated settlement (test mode)' + : livemode ? 'πŸ’° Solana: mainnet-beta (live USDC)' - : 'πŸ§ͺ Solana: devnet (test mode)' - ); + : 'πŸ§ͺ Solana: devnet (test mode)'; + console.log(settlement); if (IsSingleTenantMode()) { console.log('πŸ”’ Single-tenant mode: Only one platform allowed'); } else { diff --git a/apps/api/src/modules/AppConfig.ts b/apps/api/src/modules/AppConfig.ts index 1a954e8..1861b26 100644 --- a/apps/api/src/modules/AppConfig.ts +++ b/apps/api/src/modules/AppConfig.ts @@ -22,7 +22,11 @@ import * as dotenv from 'dotenv'; import * as path from 'path'; import { randomBytes } from 'crypto'; -import type { AppConfig, AppSecrets } from '@zoneless/shared-types'; +import type { + AppConfig, + AppSecrets, + SettlementRail, +} from '@zoneless/shared-types'; import { DeriveKey } from './Encryption'; // Load environment variables first. @@ -61,6 +65,24 @@ function NormalizeOrigin(url: string): string { return url.replace(/\/$/, ''); } +/** + * Test mode defaults to simulated settlement. Live mode is always on-chain. + * SETTLEMENT_RAIL=onchain with LIVEMODE=false restores Solana Devnet. + */ +function ResolveSettlementRail(livemode: boolean): SettlementRail { + const rail = (process.env.SETTLEMENT_RAIL || '').trim().toLowerCase(); + if (livemode) { + if (rail === 'simulated') { + throw new Error( + 'SETTLEMENT_RAIL=simulated is not valid when LIVEMODE=true' + ); + } + return 'onchain'; + } + if (rail === 'onchain') return 'onchain'; + return 'simulated'; +} + /** * Build the base config from environment variables. * appSecret will be empty until InitializeAppConfig is called (if not in env). @@ -74,6 +96,7 @@ function BuildConfigFromEnv(): AppConfig { const paymentLinkUrl = NormalizeOrigin( process.env.PAYMENT_LINK_URL || checkoutUrl ); + const livemode = process.env.LIVEMODE === 'true'; return { mongodbUri: @@ -83,7 +106,8 @@ function BuildConfigFromEnv(): AppConfig { checkoutUrl, paymentLinkUrl, appSecret: process.env.APP_SECRET || '', - livemode: process.env.LIVEMODE === 'true', + livemode, + settlement_rail: ResolveSettlementRail(livemode), }; } diff --git a/apps/api/src/modules/Balance.ts b/apps/api/src/modules/Balance.ts index f539faf..45b192c 100644 --- a/apps/api/src/modules/Balance.ts +++ b/apps/api/src/modules/Balance.ts @@ -21,7 +21,7 @@ import { GetAppConfig } from './AppConfig'; import { GetPlatformAccountId } from './PlatformAccess'; import { AccountModule } from './Account'; import { ExternalWalletModule } from './ExternalWallet'; -import { Solana } from './chains/Solana'; +import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement'; export class BalanceModule { private readonly db: Database; @@ -206,11 +206,15 @@ export class BalanceModule { if (!wallet) return null; - const solana = new Solana(); + const settlement = GetSettlement(); const [walletUsdc, walletSol, platformBalance, connectedOwed] = await Promise.all([ - solana.GetUSDCBalance(wallet.wallet_address), - solana.GetSOLBalance(wallet.wallet_address), + IsSimulatedSettlement() + ? Promise.resolve(0) + : settlement.GetUSDCBalance(wallet.wallet_address), + IsSimulatedSettlement() + ? Promise.resolve(0) + : settlement.GetSOLBalance(wallet.wallet_address), this.GetBalance(platformAccountId), this.GetConnectedAccountsOwed(platformAccountId), ]); @@ -223,7 +227,7 @@ export class BalanceModule { return { object: 'balance_details', - wallet_usdc: walletUsdc, + wallet_usdc: IsSimulatedSettlement() ? available / 100 : walletUsdc, wallet_sol: walletSol, connected_accounts_owed: connectedOwed, platform_available: available, diff --git a/apps/api/src/modules/CheckoutPayment.ts b/apps/api/src/modules/CheckoutPayment.ts index 2b25ba4..2682a8f 100644 --- a/apps/api/src/modules/CheckoutPayment.ts +++ b/apps/api/src/modules/CheckoutPayment.ts @@ -19,7 +19,12 @@ import type { SubscriptionModule } from './Subscription'; import type { PaymentIntentModule } from './PaymentIntent'; import type { ChargeModule } from './Charge'; import type { PaymentLinkModule } from './PaymentLink'; -import { Solana, SolanaExplorerUrl } from './chains/Solana'; +import { SolanaExplorerUrl } from './chains/Solana'; +import { + GetSettlement, + SimulatedSignature, + type Settlement, +} from './chains/Settlement'; import { IsCheckoutFeeSponsored } from './AppConfig'; import { AppError } from '../utils/AppError'; import { ERRORS } from '../utils/Errors'; @@ -114,7 +119,7 @@ export class CheckoutPaymentModule { private readonly paymentLinkModule: PaymentLinkModule | null; private readonly customerModule: CustomerModule | null; private readonly subscriptionModule: SubscriptionModule | null; - private readonly solana: Solana; + private readonly solana: Settlement; constructor( db: Database, @@ -124,7 +129,7 @@ export class CheckoutPaymentModule { paymentIntentModule?: PaymentIntentModule, chargeModule?: ChargeModule, paymentLinkModule?: PaymentLinkModule, - solana?: Solana, + solana?: Settlement, customerModule?: CustomerModule, subscriptionModule?: SubscriptionModule ) { @@ -140,7 +145,7 @@ export class CheckoutPaymentModule { this.paymentLinkModule = paymentLinkModule || null; this.customerModule = customerModule || null; this.subscriptionModule = subscriptionModule || null; - this.solana = solana || new Solana(); + this.solana = solana || GetSettlement(); } /** @@ -489,6 +494,26 @@ export class CheckoutPaymentModule { return this.ConfirmOneTimePayment(session, resolvedSignature); } + /** + * Complete a checkout session as if the simulated wallet approved. + * Simulated test mode only. + */ + async CompleteSimulatedCheckout( + urlSlug: string, + payerWallet: string, + customerDetails?: Omit + ): Promise { + const prepared = await this.PreparePayment( + urlSlug, + payerWallet, + customerDetails + ); + return this.ConfirmPayment( + urlSlug, + SimulatedSignature(prepared.checkout_session, payerWallet) + ); + } + /** * Land the SubscriptionAuthority init tx without completing checkout. * Client must prepare+confirm subscribe next. diff --git a/apps/api/src/modules/Invoice.ts b/apps/api/src/modules/Invoice.ts index 519c87d..644d327 100644 --- a/apps/api/src/modules/Invoice.ts +++ b/apps/api/src/modules/Invoice.ts @@ -20,7 +20,8 @@ import type { ChargeModule } from './Charge'; import type { PriceModule } from './Price'; import { BalanceModule } from './Balance'; import { BalanceTransactionModule } from './BalanceTransaction'; -import { Solana, SolanaExplorerUrl } from './chains/Solana'; +import { SolanaExplorerUrl } from './chains/Solana'; +import { GetSettlement, type Settlement } from './chains/Settlement'; import { GenerateId } from '../utils/IdGenerator'; import { BalanceTransaction as BalanceTransactionType, @@ -104,7 +105,7 @@ export class InvoiceModule { private readonly priceModule: PriceModule | null; private readonly balanceModule: BalanceModule; private readonly balanceTransactionModule: BalanceTransactionModule; - private readonly solana: Solana; + private readonly solana: Settlement; constructor( db: Database, @@ -114,7 +115,7 @@ export class InvoiceModule { paymentIntentModule?: PaymentIntentModule, chargeModule?: ChargeModule, priceModule?: PriceModule, - solana?: Solana + solana?: Settlement ) { this.db = db; this.eventService = eventService || null; @@ -132,7 +133,7 @@ export class InvoiceModule { this.priceModule = priceModule || null; this.balanceModule = new BalanceModule(db); this.balanceTransactionModule = new BalanceTransactionModule(db); - this.solana = solana || new Solana(); + this.solana = solana || GetSettlement(); } /** diff --git a/apps/api/src/modules/Payout.ts b/apps/api/src/modules/Payout.ts index d9a9e77..47f9309 100644 --- a/apps/api/src/modules/Payout.ts +++ b/apps/api/src/modules/Payout.ts @@ -13,7 +13,7 @@ import { BalanceTransactionModule } from './BalanceTransaction'; import { BalanceModule } from './Balance'; import { ExternalWalletModule } from './ExternalWallet'; import { AccountModule } from './Account'; -import { Solana } from './chains/Solana'; +import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement'; import { GetPlatformAccountId } from './PlatformAccess'; import { GetAppConfig } from './AppConfig'; import { GenerateId } from '../utils/IdGenerator'; @@ -73,7 +73,7 @@ export class PayoutModule { private readonly externalWalletModule: ExternalWalletModule; private readonly balanceModule: BalanceModule; private readonly balanceTransactionModule: BalanceTransactionModule; - private readonly solana: Solana; + private readonly solana: ReturnType; constructor(db: Database, eventService?: EventService) { this.db = db; @@ -82,7 +82,7 @@ export class PayoutModule { this.externalWalletModule = new ExternalWalletModule(db); this.balanceModule = new BalanceModule(db); this.balanceTransactionModule = new BalanceTransactionModule(db); - this.solana = new Solana(); + this.solana = GetSettlement(); this.listHelper = new ListHelper(db, { collection: 'Payouts', orderByField: 'created', @@ -767,7 +767,7 @@ export class PayoutModule { // Success: mark all payouts as paid for (const payout of payouts) { await this.MarkPayoutPaid(payout, { - network: 'solana', + network: IsSimulatedSettlement() ? 'simulated' : 'solana', blockchain_tx: result.signature, gas_fee: 0, // Fee was paid by platform, not deducted from payout gas_fee_currency: 'sol', diff --git a/apps/api/src/modules/Price.ts b/apps/api/src/modules/Price.ts index 6de7d3c..74f91dd 100644 --- a/apps/api/src/modules/Price.ts +++ b/apps/api/src/modules/Price.ts @@ -15,7 +15,7 @@ import { } from '@zoneless/shared-types'; import { ValidateUpdate } from './Util'; import { ExtractChangedFields } from './Event'; -import { Solana } from './chains/Solana'; +import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement'; import { ExternalWalletModule } from './ExternalWallet'; import type { ProductModule } from './Product'; import { @@ -116,9 +116,9 @@ export class PriceModule { if ( price.type === 'recurring' && price.recurring && - process.env.NODE_ENV !== 'test' + (IsSimulatedSettlement() || process.env.NODE_ENV !== 'test') ) { - const solana = new Solana(); + const settlement = GetSettlement(); const amount = price.unit_amount as number; const periodHours = this.PeriodToHours( price.recurring.interval, @@ -127,15 +127,14 @@ export class PriceModule { const destinationAddress = await this.GetPlatformWalletPublicKey( price.platform_account ); - const pullerAddress = solana.GetPlanOwnerPublicKey(); - const planPda = await solana.CreateSubscriptionPlan( + const pullerAddress = settlement.GetPlanOwnerPublicKey(); + price.subscription_plan_pda = await settlement.CreateSubscriptionPlan( price.id, periodHours, amount, destinationAddress, pullerAddress ); - price.subscription_plan_pda = planPda; } await this.db.Set('Prices', price.id, price); diff --git a/apps/api/src/modules/TopUp.ts b/apps/api/src/modules/TopUp.ts index ca21785..6748654 100644 --- a/apps/api/src/modules/TopUp.ts +++ b/apps/api/src/modules/TopUp.ts @@ -185,6 +185,34 @@ export class TopUpModule { }); } + /** + * Credit the platform ledger with fake USDC in simulated test mode. + */ + async CreateSimulatedDeposit( + amountCents: number, + platformAccountId: string + ): Promise { + if (!Number.isInteger(amountCents) || amountCents <= 0) { + throw new AppError( + 'Amount must be a positive integer number of cents', + ERRORS.VALIDATION_ERROR.status, + ERRORS.VALIDATION_ERROR.type + ); + } + + return this.CreateFromDeposit( + { + signature: GenerateId('sim_dep'), + amount: amountCents / 100, + amountCents, + senderAddress: 'SimulatedTestWallet1111111111111111111', + timestamp: Now(), + slot: 0, + }, + platformAccountId + ); + } + /** * Cancel a pending top-up. * Only pending top-ups can be canceled. diff --git a/apps/api/src/modules/TopUpMonitor.ts b/apps/api/src/modules/TopUpMonitor.ts index 537e9ec..b6afb71 100644 --- a/apps/api/src/modules/TopUpMonitor.ts +++ b/apps/api/src/modules/TopUpMonitor.ts @@ -23,7 +23,8 @@ import { Database } from './Database'; import { TopUpModule } from './TopUp'; import { AccountModule } from './Account'; import { ExternalWalletModule } from './ExternalWallet'; -import { Solana, IncomingDeposit } from './chains/Solana'; +import { IncomingDeposit } from './chains/Solana'; +import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement'; import { EventService } from './EventService'; import { TopUp, CheckDepositsResponse } from '@zoneless/shared-types'; import { Logger } from '../utils/Logger'; @@ -53,7 +54,7 @@ export class TopUpMonitor { private readonly topUpModule: TopUpModule; private readonly accountModule: AccountModule; private readonly externalWalletModule: ExternalWalletModule; - private readonly solana: Solana; + private readonly solana: ReturnType; private intervalId: NodeJS.Timeout | null = null; private isRunning: boolean = false; @@ -63,14 +64,14 @@ export class TopUpMonitor { this.topUpModule = new TopUpModule(db, eventService); this.accountModule = new AccountModule(db); this.externalWalletModule = new ExternalWalletModule(db); - this.solana = new Solana(); + this.solana = GetSettlement(); } /** * Check if the TopUp monitor is enabled via configuration. */ static IsEnabled(): boolean { - return TOPUP_MONITOR_ENABLED; + return TOPUP_MONITOR_ENABLED && !IsSimulatedSettlement(); } /** diff --git a/apps/api/src/modules/chains/Settlement.ts b/apps/api/src/modules/chains/Settlement.ts new file mode 100644 index 0000000..e2dce97 --- /dev/null +++ b/apps/api/src/modules/chains/Settlement.ts @@ -0,0 +1,221 @@ +/** + * Settlement rail selection and the simulated (no-chain) implementation. + * + * Test mode defaults to simulated funds. Set SETTLEMENT_RAIL=onchain with + * LIVEMODE=false to use Solana Devnet (internal escape hatch). + * + * @module Settlement + */ + +import { GetAppConfig } from '../AppConfig'; +import { + CheckoutPaymentVerification, + CheckoutSubscribeVerification, + Solana, +} from './Solana'; + +export type SettlementRail = 'simulated' | 'onchain'; + +const SIM_SIG_PREFIX = 'sim_sig:'; + +/** Default payer used by simulated checkout and test helpers. */ +export const SIMULATED_TEST_WALLET = + 'D8VMZCmmTUUfhejNhNQKAmqvZCKfUq1qU6RqQKxQwXyX'; + +export function IsSimulatedSettlement(): boolean { + return GetAppConfig().settlement_rail === 'simulated'; +} + +export function SimulatedSignature( + checkoutSessionId: string, + payerWallet: string +): string { + return `${SIM_SIG_PREFIX}${checkoutSessionId}:${payerWallet}`; +} + +function DummyUnsignedTransaction() { + return { + unsigned_transaction: 'sim_tx', + estimated_fee_lamports: 0, + blockhash: 'sim_blockhash', + last_valid_block_height: 1, + min_context_slot: 0, + }; +} + +function PayerFromSimulatedSignature(signature: string): string | null { + if (!signature.startsWith(SIM_SIG_PREFIX)) return null; + const rest = signature.slice(SIM_SIG_PREFIX.length); + const separator = rest.indexOf(':'); + if (separator < 0) return rest || null; + return rest.slice(separator + 1) || null; +} + +/** + * In-process stand-in for Solana. Same method names as `Solana` so call + * sites can swap via GetSettlement() without per-flow branching. + */ +export class SimulatedSettlement { + async CheckWalletExists(address: string): Promise { + return typeof address === 'string' && address.length >= 32; + } + + async GetSOLBalance(_publicKeyString: string): Promise { + return 0; + } + + async GetUSDCBalance(_publicKeyString: string): Promise { + return 0; + } + + GetUSDCMintAddress(): string { + return 'simulated'; + } + + GetPlanOwnerPublicKey(): string { + return 'SimulatedPlanOwner111111111111111111111'; + } + + async GetIncomingDeposits() { + return []; + } + + async BuildCheckoutPaymentTransaction( + _payerPublicKey: string, + _merchantWalletAddress: string, + _amountInCents: number, + _checkoutSessionId: string, + _options?: { feeSponsored?: boolean } + ) { + return DummyUnsignedTransaction(); + } + + async BuildInitSubscriptionAuthorityTransaction( + _subscriberWallet: string, + _options?: { feeSponsored?: boolean } + ) { + return null; + } + + async BuildSubscribeTransaction( + _subscriberWallet: string, + _priceId: string, + _planPda: string, + _options?: { feeSponsored?: boolean } + ) { + return DummyUnsignedTransaction(); + } + + async WaitForSubscriptionAuthority(_subscriberWallet: string): Promise { + return; + } + + async ValidateAndBroadcastCheckoutTransaction( + signedByCustomerBase64: string + ): Promise<{ signature: string }> { + return { signature: signedByCustomerBase64 || 'sim_sig_broadcast' }; + } + + async FindExistingSubscriptionDelegation( + _planPda: string, + _subscriberWallet: string + ): Promise { + return null; + } + + async VerifySubscribeTransaction( + _signature: string, + expected: { + planPda: string; + subscriberWallet: string; + } + ): Promise { + return { + verified: true, + subscriber_address: expected.subscriberWallet, + subscription_delegation_pda: `sim_sub_${expected.planPda}_${expected.subscriberWallet}`, + }; + } + + async CollectSubscriptionPayment(params: { + subscriberWallet: string; + planPda: string; + subscriptionPda: string; + amountCents: number; + destinationWallet?: string; + }): Promise<{ signature: string; alreadyCollected: boolean }> { + return { + signature: `sim_collect:${params.subscriberWallet}`, + alreadyCollected: false, + }; + } + + async VerifyCheckoutPayment( + signature: string, + expected: { + merchantWalletAddress: string; + amountInCents: number; + checkoutSessionId: string; + } + ): Promise { + const payerAddress = PayerFromSimulatedSignature(signature); + if (!payerAddress) { + return { + verified: false, + amount_cents: 0, + payer_address: null, + failure_reason: 'Invalid simulated payment', + }; + } + return { + verified: true, + amount_cents: expected.amountInCents, + payer_address: payerAddress, + }; + } + + async CreateSubscriptionPlan( + priceId: string, + _periodHours: number, + _amountCents: number, + _destinationAddress: string, + _pullerAddress: string + ): Promise { + return `sim_plan_${priceId}`; + } + + async BuildBatchPayoutTransaction( + _senderPublicKey: string, + recipients: { destinationAddress: string; amountInCents: number }[] + ): Promise<{ + unsigned_transaction: string; + estimated_fee_lamports: number; + blockhash: string; + last_valid_block_height: number; + recipients_count: number; + }> { + return { + ...DummyUnsignedTransaction(), + recipients_count: recipients.length, + }; + } + + async BroadcastSignedTransaction(_signedTransaction: string): Promise<{ + signature: string; + status: 'paid' | 'failed'; + viewer_url: string; + failure_message?: string; + }> { + return { + signature: 'sim_sig_payout', + status: 'paid', + viewer_url: '', + }; + } +} + +export type Settlement = Solana | SimulatedSettlement; + +export function GetSettlement(): Settlement { + return IsSimulatedSettlement() ? new SimulatedSettlement() : new Solana(); +} diff --git a/apps/api/src/modules/chains/Solana.ts b/apps/api/src/modules/chains/Solana.ts index 3081e4d..baa81de 100644 --- a/apps/api/src/modules/chains/Solana.ts +++ b/apps/api/src/modules/chains/Solana.ts @@ -51,6 +51,7 @@ import { subscriptionsProgram, } from '@solana/subscriptions'; import { + GetAppConfig, GetCheckoutFeePayerSecretKey, RequireSubscriptionOperatorSecretKey, } from '../AppConfig'; @@ -184,6 +185,7 @@ export function SolanaExplorerUrl( type: 'tx' | 'address', value: string ): string { + if (GetAppConfig().settlement_rail === 'simulated') return ''; const livemode = process.env.LIVEMODE === 'true'; const clusterParam = livemode ? '' : '?cluster=devnet'; return `https://explorer.solana.com/${type}/${value}${clusterParam}`; diff --git a/apps/api/src/routes/config.routes.ts b/apps/api/src/routes/config.routes.ts index 3661e07..835ed8e 100644 --- a/apps/api/src/routes/config.routes.ts +++ b/apps/api/src/routes/config.routes.ts @@ -34,7 +34,8 @@ const apiKeyModule = new ApiKeyModule(db); * Helper to build PublicConfig from a platform account. */ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { - const { livemode } = GetAppConfig(); + const { livemode, settlement_rail } = GetAppConfig(); + const settlement = settlement_rail ?? 'simulated'; if (!platformAccount) { return { @@ -44,6 +45,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { terms_url: '', privacy_url: '', livemode, + settlement, }; } @@ -57,6 +59,7 @@ function BuildPublicConfig(platformAccount: Account | null): PublicConfig { terms_url: platformAccount.settings?.terms_url || '', privacy_url: platformAccount.settings?.privacy_url || '', livemode, + settlement, }; } diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 9662639..f66ccbe 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -36,6 +36,7 @@ import billingRouter from './billing.routes'; import telemetryRouter from './telemetry.routes'; import identityVerificationSessionsRouter from './identityVerificationSessions.routes'; import identityWebhooksRouter from './identityWebhooks.routes'; +import testHelpersRouter from './testHelpers.routes'; const router = express.Router(); @@ -93,6 +94,7 @@ router.use('/invoiceitems', invoiceItemsRouter); router.use('/invoices', invoicesRouter); router.use('/reporting', reportingRouter); router.use('/telemetry', telemetryRouter); +router.use('/test_helpers', testHelpersRouter); router.use( '/identity/verification_sessions', identityVerificationSessionsRouter diff --git a/apps/api/src/routes/testHelpers.routes.ts b/apps/api/src/routes/testHelpers.routes.ts new file mode 100644 index 0000000..6a416a4 --- /dev/null +++ b/apps/api/src/routes/testHelpers.routes.ts @@ -0,0 +1,163 @@ +/** + * Stripe-shaped test helpers. Simulated settlement only. + */ + +import * as express from 'express'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { AppError } from '../utils/AppError'; +import { ERRORS } from '../utils/Errors'; +import { RequirePlatform } from '../middleware/Authorization'; +import { db } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { CheckoutSessionModule } from '../modules/CheckoutSession'; +import { CheckoutPaymentModule } from '../modules/CheckoutPayment'; +import { PriceModule } from '../modules/Price'; +import { ProductModule } from '../modules/Product'; +import { CustomerModule } from '../modules/Customer'; +import { ExternalWalletModule } from '../modules/ExternalWallet'; +import { PaymentIntentModule } from '../modules/PaymentIntent'; +import { ChargeModule } from '../modules/Charge'; +import { PaymentLinkModule } from '../modules/PaymentLink'; +import { InvoiceItemModule } from '../modules/InvoiceItem'; +import { InvoiceModule } from '../modules/Invoice'; +import { SubscriptionModule } from '../modules/Subscription'; +import { TopUpModule } from '../modules/TopUp'; +import { + IsSimulatedSettlement, + SIMULATED_TEST_WALLET, +} from '../modules/chains/Settlement'; + +const router = express.Router(); + +const eventService = new EventService(db); +const productModule = new ProductModule(db, eventService); +const priceModule = new PriceModule(db, eventService, productModule); +const customerModule = new CustomerModule(db, eventService); +const paymentIntentModule = new PaymentIntentModule( + db, + eventService, + customerModule +); +const chargeModule = new ChargeModule(db, eventService, customerModule); +const checkoutSessionModule = new CheckoutSessionModule( + db, + eventService, + priceModule, + productModule, + customerModule, + paymentIntentModule +); +const paymentLinkModule = new PaymentLinkModule( + db, + eventService, + priceModule, + productModule, + checkoutSessionModule +); +const externalWalletModule = new ExternalWalletModule(db, eventService); +const invoiceItemModule = new InvoiceItemModule( + db, + eventService, + customerModule, + priceModule +); +const invoiceModule = new InvoiceModule( + db, + eventService, + customerModule, + invoiceItemModule, + paymentIntentModule, + chargeModule, + priceModule +); +const subscriptionModule = new SubscriptionModule( + db, + eventService, + customerModule, + priceModule, + invoiceModule +); +const checkoutPaymentModule = new CheckoutPaymentModule( + db, + checkoutSessionModule, + externalWalletModule, + productModule, + paymentIntentModule, + chargeModule, + paymentLinkModule, + undefined, + customerModule, + subscriptionModule +); +const topUpModule = new TopUpModule(db, eventService); + +function AssertSimulatedSettlement(): void { + if (!IsSimulatedSettlement()) { + throw new AppError( + 'Test helpers are only available in simulated test mode', + 400, + 'invalid_request_error' + ); + } +} + +/** + * POST /v1/test_helpers/checkout/sessions/:id/complete + * Completes a checkout session as if the simulated wallet approved. + */ +router.post( + '/checkout/sessions/:id/complete', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + AssertSimulatedSettlement(); + + const session = await checkoutSessionModule.GetCheckoutSession( + req.params.id + ); + if (!session) { + throw new AppError( + ERRORS.CHECKOUT_SESSION_NOT_FOUND.message, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.status, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.type + ); + } + if (session.platform_account !== req.user.account) { + throw new AppError( + ERRORS.CHECKOUT_SESSION_NOT_FOUND.message, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.status, + ERRORS.CHECKOUT_SESSION_NOT_FOUND.type + ); + } + + const payerWallet = + (typeof req.body?.payer_wallet === 'string' && req.body.payer_wallet) || + SIMULATED_TEST_WALLET; + + const completed = await checkoutPaymentModule.CompleteSimulatedCheckout( + session.url_slug, + payerWallet + ); + res.json(completed); + }) +); + +/** + * POST /v1/test_helpers/treasury/topups + * Credits the platform ledger with fake USDC. + */ +router.post( + '/treasury/topups', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + AssertSimulatedSettlement(); + + const amount = Number(req.body?.amount); + const topUp = await topUpModule.CreateSimulatedDeposit( + amount, + req.user.account + ); + res.status(201).json(topUp); + }) +); + +export default router; diff --git a/apps/web/src/app/data/services/config.service.ts b/apps/web/src/app/data/services/config.service.ts index e23f9fd..4b65038 100644 --- a/apps/web/src/app/data/services/config.service.ts +++ b/apps/web/src/app/data/services/config.service.ts @@ -143,6 +143,13 @@ export class ConfigService { return this.config()?.livemode === false; } + /** + * Returns true when test mode uses fake funds instead of a blockchain. + */ + IsSimulatedSettlement(): boolean { + return this.config()?.settlement === 'simulated'; + } + /** * Clear the cached config. Useful when switching contexts. */ diff --git a/apps/web/src/app/data/services/topup.service.ts b/apps/web/src/app/data/services/topup.service.ts index ac3e252..dd328da 100644 --- a/apps/web/src/app/data/services/topup.service.ts +++ b/apps/web/src/app/data/services/topup.service.ts @@ -147,6 +147,15 @@ export class TopupService { return response.data; } + /** + * Credit the platform ledger with fake USDC (simulated test mode only). + */ + async FundTestBalance(amountCents: number): Promise { + return this.api.Call('POST', 'test_helpers/treasury/topups', { + amount: amountCents, + }); + } + /** * Reset service state. */ diff --git a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.html b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.html index 71e0c2b..7d25d21 100644 --- a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.html +++ b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.html @@ -16,13 +16,47 @@ />

Add funds to your balance

- Send USDC on Solana to the wallet address below. Funds typically arrive - within 30 seconds of network confirmation. + @if (IsSimulatedSettlement()) { Add test USDC to your balance. These funds + are simulated and have no value. } @else { Send USDC on Solana to the + wallet address below. Funds typically arrive within 30 seconds of network + confirmation. }

- - @if (IsTestMode()) { + + @if (IsSimulatedSettlement()) { +
+
+ You're in test mode. Add simulated USDC below. +
+
+
Amount
+
+ US$ + +
+
+ @if (testFundsError()) { +
{{ testFundsError() }}
+ } + +
+ } @else if (IsTestMode()) {
You're in test mode. Use the buttons below to add funds. @@ -56,8 +90,7 @@

Add funds to your balance

- } - + } @if (!IsSimulatedSettlement()) {
Your deposit address
@@ -187,6 +220,7 @@

How to send funds

/>
+ } } diff --git a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.scss b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.scss index 6c9d8e6..5fd7b51 100644 --- a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.scss +++ b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.scss @@ -1,5 +1,6 @@ @use '../../../../../styles/base.scss' as *; @use '../../../../../styles/buttons.scss' as *; +@use '../../../../../styles/forms.scss' as *; @use '../../../../../styles/panels.scss' as *; .add-funds-content { @@ -216,6 +217,34 @@ gap: $spacing; } +.field-title { + font-size: $font-size-small; + font-weight: $medium-weight; + margin-bottom: $spacing-extra-small; +} + +.amount-input-row { + display: flex; + align-items: center; + border: 1px solid $darkest-background-color; + border-radius: $border-radius-small; + overflow: hidden; +} + +.amount-prefix { + padding: 0 $spacing-small 0 $spacing; + font-size: $font-size; + opacity: $dimmed; + white-space: nowrap; +} + +.amount-input { + border: none !important; + border-radius: 0 !important; + flex: 1; + min-width: 0; +} + .testmode-banner { padding: $spacing; background-color: #eff6ff; diff --git a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.ts b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.ts index 3ba9d50..6f3a43b 100644 --- a/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.ts +++ b/apps/web/src/app/features/account/balance/components/add-funds-panel/add-funds-panel.component.ts @@ -41,6 +41,9 @@ export class AddFundsPanelComponent implements OnChanges, OnDestroy { state: WritableSignal = signal('loading'); copiedField: WritableSignal = signal(null); newDeposit: WritableSignal = signal(null); + testAmount: WritableSignal = signal('100.00'); + testFundsError: WritableSignal = signal(null); + testFundsLoading: WritableSignal = signal(false); ngOnChanges(changes: SimpleChanges): void { if (changes['isOpen']) { @@ -112,6 +115,10 @@ export class AddFundsPanelComponent implements OnChanges, OnDestroy { return this.configService.IsTestMode(); } + IsSimulatedSettlement(): boolean { + return this.configService.IsSimulatedSettlement(); + } + GetExplorerUrl(): string { return this.depositInfo()?.explorer_url || ''; } @@ -197,4 +204,32 @@ export class AddFundsPanelComponent implements OnChanges, OnDestroy { OnOpenUsdcFaucet(): void { window.open('https://faucet.circle.com/', '_blank', 'noopener,noreferrer'); } + + OnTestAmountInput(event: Event): void { + this.testAmount.set((event.target as HTMLInputElement).value); + } + + async OnAddTestFunds(): Promise { + const cents = Math.round(parseFloat(this.testAmount()) * 100); + if (!Number.isFinite(cents) || cents <= 0) { + this.testFundsError.set('Enter an amount greater than 0'); + return; + } + + this.testFundsError.set(null); + this.testFundsLoading.set(true); + try { + const deposit = await this.topupService.FundTestBalance(cents); + this.newDeposit.set(deposit); + this.state.set('success'); + this.ScrollToTop(); + this.depositCompleted.emit(deposit); + } catch (error) { + this.testFundsError.set( + error instanceof Error ? error.message : 'Failed to add test funds' + ); + } finally { + this.testFundsLoading.set(false); + } + } } diff --git a/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.html b/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.html index c9101db..bd342f2 100644 --- a/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.html +++ b/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.html @@ -15,12 +15,14 @@

Wallet balances

> + @if (!IsSimulatedSettlement()) {
SOL {{ GetWalletSol() }} SOL
+ }
Wallet @@ -37,6 +39,7 @@

Wallet balances

+ @if (GetExplorerUrl()) { + } diff --git a/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.ts b/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.ts index 969f308..0a78159 100644 --- a/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.ts +++ b/apps/web/src/app/features/account/balance/components/balance-detail/balance-detail.component.ts @@ -13,7 +13,7 @@ import { import { DecimalPipe } from '@angular/common'; import { BalanceDetails } from '@zoneless/shared-types'; -import { BalanceService } from '../../../../../data'; +import { BalanceService, ConfigService } from '../../../../../data'; import { LoaderComponent } from '../../../../../shared'; @Component({ @@ -29,6 +29,7 @@ export class BalanceDetailComponent implements OnChanges { @Output() synced = new EventEmitter(); readonly balanceService = inject(BalanceService); + private readonly configService = inject(ConfigService); syncSuccess: WritableSignal = signal(false); @@ -84,6 +85,7 @@ export class BalanceDetailComponent implements OnChanges { } GetExplorerUrl(): string { + if (this.configService.IsSimulatedSettlement()) return ''; const address = this.GetWalletAddress(); if (!address) return ''; const isTestMode = this.balanceService.balance()?.livemode === false; @@ -91,6 +93,10 @@ export class BalanceDetailComponent implements OnChanges { return `https://explorer.solana.com/address/${address}${clusterParam}`; } + IsSimulatedSettlement(): boolean { + return this.configService.IsSimulatedSettlement(); + } + IsInSync(): boolean { const details = this.GetDetails(); if (!details) return true; 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 37f2608..bb15127 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 @@ -48,6 +48,7 @@ + @if (!isSimulatedSettlement()) {
Sign payout
@@ -120,6 +121,7 @@ } }
+ }
Confirm payout
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 44a02e1..b16ea0d 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 @@ -5,6 +5,7 @@ import { inject, } from '@angular/core'; import { ModalComponent } from '../../../../../shared'; +import { ConfigService } from '../../../../../data'; import { ConnectedAccountActionsService } from '../../services/connected-account-actions.service'; @Component({ @@ -16,6 +17,11 @@ import { ConnectedAccountActionsService } from '../../services/connected-account }) export class PayoutModalComponent { readonly actions = inject(ConnectedAccountActionsService); + private readonly configService = inject(ConfigService); + + readonly isSimulatedSettlement = computed(() => + this.configService.IsSimulatedSettlement() + ); readonly availableCents = computed(() => this.actions.GetAvailableAmount(this.actions.connectedBalance()) 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 a01cd1f..149afe1 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 @@ -185,6 +185,7 @@ export class ConnectedAccountActionsService { const amount = this.ParseAmountCents(this.payoutAmount()); const available = this.GetAvailableAmount(this.connectedBalance()); const signerReady = + this.configService.IsSimulatedSettlement() || !!this.payoutSignedTransaction() || this.GetPayoutSignerAddress() === this.payoutPlatformWalletAddress(); const account = this.activeAccount(); @@ -559,7 +560,7 @@ export class ConnectedAccountActionsService { ? { statement_descriptor: statementDescriptor } : {}), }; - const result = await this.ConfirmClientSignedPayout(account.id, input); + const result = await this.ConfirmProcessedPayout(account.id, input); this.events$.next({ type: 'payout_processed', @@ -643,12 +644,13 @@ export class ConnectedAccountActionsService { } } - private async ConfirmClientSignedPayout( + private async ConfirmProcessedPayout( connectedAccountId: string, input: Parameters[1] ) { + const simulated = this.configService.IsSimulatedSettlement(); const expectedSigner = this.payoutPlatformWalletAddress(); - if (!this.payoutSignedTransaction()) { + if (!simulated && !this.payoutSignedTransaction()) { this.ValidatePayoutSignerAddress(this.GetPayoutSignerAddress()); } @@ -669,29 +671,33 @@ export class ConnectedAccountActionsService { this.payoutBuildResult.set(buildResult); } - const chain = this.configService.config()?.livemode - ? 'solana:mainnet' - : 'solana:devnet'; let signedTransaction = this.payoutSignedTransaction(); if (!signedTransaction) { - const signedBytes = - this.payoutSignerMethod() === 'wallet' - ? await this.solanaWalletService.SignUnsignedTransaction( - buildResult.unsigned_transaction, - chain - ) - : await this.solanaWalletService.SignUnsignedTransactionWithSecretKey( - buildResult.unsigned_transaction, - this.payoutPrivateKey(), - expectedSigner - ); - signedTransaction = this.solanaWalletService.BytesToBase64(signedBytes); - this.payoutSignedTransaction.set(signedTransaction); - - if (this.payoutSignerMethod() === 'private_key') { - this.payoutPrivateKey.set(''); - this.payoutPrivateKeyAddress.set(''); + if (simulated) { + signedTransaction = buildResult.unsigned_transaction; + } else { + const chain = this.configService.config()?.livemode + ? 'solana:mainnet' + : 'solana:devnet'; + const signedBytes = + this.payoutSignerMethod() === 'wallet' + ? await this.solanaWalletService.SignUnsignedTransaction( + buildResult.unsigned_transaction, + chain + ) + : await this.solanaWalletService.SignUnsignedTransactionWithSecretKey( + buildResult.unsigned_transaction, + this.payoutPrivateKey(), + expectedSigner + ); + signedTransaction = this.solanaWalletService.BytesToBase64(signedBytes); + + if (this.payoutSignerMethod() === 'private_key') { + this.payoutPrivateKey.set(''); + this.payoutPrivateKeyAddress.set(''); + } } + this.payoutSignedTransaction.set(signedTransaction); } return this.payoutService.BroadcastPayoutsBatch({ diff --git a/apps/web/src/app/features/account/payment-intents/views/payment-intent-detail/payment-intent-detail.component.ts b/apps/web/src/app/features/account/payment-intents/views/payment-intent-detail/payment-intent-detail.component.ts index f94000e..73dc6d1 100644 --- a/apps/web/src/app/features/account/payment-intents/views/payment-intent-detail/payment-intent-detail.component.ts +++ b/apps/web/src/app/features/account/payment-intents/views/payment-intent-detail/payment-intent-detail.component.ts @@ -199,7 +199,7 @@ export class PaymentIntentDetailComponent implements OnInit, OnDestroy { } GetExplorerUrl(txHash: string | null | undefined): string | null { - if (!txHash) return null; + if (!txHash || txHash.startsWith('sim_')) return null; const livemode = this.paymentIntent()?.livemode; const clusterParam = livemode === false ? '?cluster=devnet' : ''; return `https://explorer.solana.com/tx/${txHash}${clusterParam}`; diff --git a/apps/web/src/app/features/checkout/checkout.component.html b/apps/web/src/app/features/checkout/checkout.component.html index fd3c344..2db610b 100644 --- a/apps/web/src/app/features/checkout/checkout.component.html +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -88,7 +88,45 @@
- @if (IsComplete()) { + @if (simulatedWalletOpen()) { + + } @else if (IsComplete()) {
{ @@ -23,6 +24,10 @@ describe('CheckoutComponent mobile wallet handoff', () => { SignUnsignedTransaction: jest.fn(), BytesToBase64: jest.fn(), }; + const configService = { + IsSimulatedSettlement: jest.fn(() => false), + LoadConfig: jest.fn().mockResolvedValue({}), + }; const checkoutSessionService = { PreparePayment: jest.fn(), ConfirmPayment: jest.fn(), @@ -65,6 +70,7 @@ describe('CheckoutComponent mobile wallet handoff', () => { useValue: { snapshot: { paramMap: { get: () => null } } }, }, { provide: CheckoutSessionService, useValue: checkoutSessionService }, + { provide: ConfigService, useValue: configService }, { provide: MetaService, useValue: {} }, { provide: SolanaWalletService, useValue: walletService }, ], @@ -98,6 +104,7 @@ describe('CheckoutComponent mobile wallet handoff', () => { walletService.BytesToBase64.mockReturnValue('signed-transaction'); checkoutSessionService.PreparePayment.mockResolvedValue(preparedPayment); checkoutSessionService.ConfirmPayment.mockResolvedValue(completedSession); + configService.IsSimulatedSettlement.mockReturnValue(false); }); afterEach(() => { @@ -293,4 +300,14 @@ describe('CheckoutComponent mobile wallet handoff', () => { expect(component.NeedsMobileWalletHandoff()).toBe(true); expect(component.paymentError()).toBeNull(); }); + + it('opens a simulated wallet instead of connecting a chain wallet', async () => { + configService.IsSimulatedSettlement.mockReturnValue(true); + + await component.Pay(); + + expect(component.simulatedWalletOpen()).toBe(true); + expect(walletService.Connect).not.toHaveBeenCalled(); + expect(component.NeedsMobileWalletHandoff()).toBe(false); + }); }); diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts index a1b8c27..e38ba29 100644 --- a/apps/web/src/app/features/checkout/checkout.component.ts +++ b/apps/web/src/app/features/checkout/checkout.component.ts @@ -19,6 +19,7 @@ import { CheckoutPaymentTransaction, CheckoutSessionService, } from '../../data/services/checkout-session.service'; +import { ConfigService } from '../../data/services/config.service'; import { LoaderComponent, PageLoaderComponent } from '../../shared'; import { ISO_CODES } from '../../utils'; import { @@ -50,6 +51,7 @@ import { IsMobileBrowser, MobileWalletOption, } from './util/mobile-wallet'; +import { TEST_WALLET_DATA } from '../../utils/constants/test-data'; type PaymentPhase = 'idle' | 'awaiting_wallet' | 'processing' | 'complete'; @@ -115,6 +117,7 @@ function HasAddressDetails(form: AddressFormValue): boolean { export class CheckoutComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly checkoutSessionService = inject(CheckoutSessionService); + private readonly configService = inject(ConfigService); private readonly metaService = inject(MetaService); private readonly solanaWalletService = inject(SolanaWalletService); @@ -122,6 +125,7 @@ export class CheckoutComponent implements OnInit { loading: WritableSignal = signal(true); paymentPhase: WritableSignal = signal('idle'); paymentError: WritableSignal = signal(null); + simulatedWalletOpen: WritableSignal = signal(false); mobileWalletHandoffRequested: WritableSignal = signal(false); confirmationExpanded: WritableSignal = signal(false); billingAddressExpanded: WritableSignal = signal(false); @@ -145,7 +149,10 @@ export class CheckoutComponent implements OnInit { async ngOnInit(): Promise { const urlSlug = this.route.snapshot.paramMap.get('checkoutSessionId'); if (!urlSlug) return; - await this.LoadCheckoutSession(urlSlug); + await Promise.all([ + this.LoadCheckoutSession(urlSlug), + this.configService.LoadConfig().catch(() => undefined), + ]); } private async LoadCheckoutSession(urlSlug: string): Promise { @@ -279,6 +286,7 @@ export class CheckoutComponent implements OnInit { } NeedsMobileWalletHandoff(): boolean { + if (this.IsSimulatedSettlement()) return false; if (typeof navigator === 'undefined') return false; return ( IsMobileBrowser(navigator.userAgent, navigator.maxTouchPoints) && @@ -296,6 +304,15 @@ export class CheckoutComponent implements OnInit { ); } + IsSimulatedSettlement(): boolean { + return this.configService.IsSimulatedSettlement(); + } + + SimulatedPayerLabel(): string { + const address = TEST_WALLET_DATA.walletAddress; + return `${address.slice(0, 6)}…${address.slice(-6)}`; + } + async Pay(): Promise { const session = this.checkoutSession(); if (!session) return; @@ -311,6 +328,11 @@ export class CheckoutComponent implements OnInit { this.paymentError.set(null); this.mobileWalletHandoffRequested.set(false); + if (this.IsSimulatedSettlement()) { + this.simulatedWalletOpen.set(true); + return; + } + if (this.solanaWalletService.SupportsMobileWalletAdapter()) { this.paymentPhase.set('awaiting_wallet'); try { @@ -339,6 +361,33 @@ export class CheckoutComponent implements OnInit { } } + async ApproveSimulatedWallet(): Promise { + const session = this.checkoutSession(); + if (!session) return; + + this.simulatedWalletOpen.set(false); + this.paymentPhase.set('awaiting_wallet'); + const payerWallet = TEST_WALLET_DATA.walletAddress; + try { + const completedSession = await this.PayWithWallet( + session, + payerWallet, + (prepared) => + this.ConfirmPreparedPayment(session, prepared, { + signature: `sim_sig:${prepared.checkout_session}:${payerWallet}`, + }) + ); + this.CompletePayment(completedSession); + } catch (error) { + this.HandlePaymentError(error, 'idle'); + } + } + + DeclineSimulatedWallet(): void { + this.simulatedWalletOpen.set(false); + this.paymentError.set('Payment was declined'); + } + private PayWithMobileWallet( session: CheckoutSession ): Promise { @@ -802,6 +851,11 @@ export class CheckoutComponent implements OnInit { } MethodDetailLabel(): string { + if (this.IsSimulatedSettlement()) { + return this.IsSubscription() + ? 'Subscribing with test USDC' + : 'Paying with test USDC'; + } return this.IsSubscription() ? 'Subscribing with USDC on Solana' : 'Paying with USDC on Solana'; @@ -809,6 +863,11 @@ export class CheckoutComponent implements OnInit { MethodHelpText(): string { const amount = this.FormatAmount(this.checkoutSession()?.amount_total); + if (this.IsSimulatedSettlement()) { + return this.IsSubscription() + ? `Approve in the test wallet to start a ${amount} USDC subscription. No real wallet required.` + : `Approve in the test wallet to pay ${amount} USDC. No real wallet required.`; + } if (this.IsSubscription()) { const cadence = this.RecurringIntervalLabel(); return cadence diff --git a/docker-compose.yml b/docker-compose.yml index 3ff9d04..17ad755 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,7 @@ services: - OPERATOR_API_KEY=${OPERATOR_API_KEY:-} - SOLANA_RPC_URL=${SOLANA_RPC_URL:-} - LIVEMODE=${LIVEMODE:-false} + - SETTLEMENT_RAIL=${SETTLEMENT_RAIL:-} - API_PORT=${API_PORT:-3333} depends_on: mongodb: diff --git a/libs/shared-types/src/lib/Config.ts b/libs/shared-types/src/lib/Config.ts index e50f2fc..a4cddb4 100644 --- a/libs/shared-types/src/lib/Config.ts +++ b/libs/shared-types/src/lib/Config.ts @@ -3,6 +3,10 @@ * Static configuration from environment variables (.env). * These are deployment-specific settings that don't change at runtime. */ + +/** How an instance settles payments, payouts, and collections. */ +export type SettlementRail = 'simulated' | 'onchain'; + export interface AppConfig { /** MongoDB connection string */ mongodbUri: string; @@ -28,6 +32,11 @@ export interface AppConfig { appSecret: string; /** True if running in live mode, false for test mode (from LIVEMODE env var) */ livemode: boolean; + /** + * How test mode settles money. Live mode is always `onchain`. + * `simulated` (test default) uses fake funds; `onchain` uses Solana. + */ + settlement_rail: SettlementRail; } /** @@ -62,6 +71,8 @@ export interface PublicConfig { privacy_url: string; /** True if running in live mode, false for test mode */ livemode: boolean; + /** How this instance settles money: fake funds vs a real chain */ + settlement: SettlementRail; } /**