diff --git a/backend/.env.example b/backend/.env.example index e1a3f19..ec6b432 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -57,3 +57,8 @@ PLATFORM_RELEASER_SECRET= ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM # Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet) ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Issue #92 testnet-only prototype: defaults to direct facility payouts. +# `pooled` routes escrow payouts to this shared G-address and is rejected in +# production; use only a testnet account during evaluation. +PAYOUT_PRIVACY_MODE=facility +PAYOUT_PRIVACY_POOL_ADDRESS= diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 7c2b0c0..ead5fbf 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,5 +1,10 @@ import 'dotenv/config'; +const payoutPrivacyMode = process.env.PAYOUT_PRIVACY_MODE ?? 'facility'; +if (payoutPrivacyMode !== 'facility' && payoutPrivacyMode !== 'pooled') { + throw new Error('PAYOUT_PRIVACY_MODE must be either "facility" or "pooled".'); +} + function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing required env var: ${name}`); @@ -146,5 +151,7 @@ export const config = { platformReleaserSecret: process.env.PLATFORM_RELEASER_SECRET, escrowContractId: process.env.ESCROW_CONTRACT_ID, escrowTokenId: process.env.ESCROW_TOKEN_ID, + payoutPrivacyMode, + payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, }, } as const; diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index d159248..8dab534 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -13,6 +13,7 @@ import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk } from './escrow-sdk-loader'; +import { resolveEscrowPayee } from './payout-privacy'; import { paginate, type PatientExportSection } from '../../patients/services'; import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; @@ -125,11 +126,17 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); const { platformPayer, platformReleaser, escrowContractId, escrowTokenId } = await requirePaymentsConfig(); + const payee = resolveEscrowPayee({ + facilityAddress: walletLink.stellarPublicKey, + mode: config.payments.payoutPrivacyMode, + poolAddress: config.payments.payoutPrivacyPoolAddress, + production: process.env.NODE_ENV === 'production', + }); const client = await escrowClient(escrowContractId); const { escrowId, txHash } = await client.createEscrow({ payer: platformPayer, - payee: walletLink.stellarPublicKey, + payee, token: escrowTokenId, amount: intent.amountStroops, // The PaymentIntent's own id is the opaque condition_ref — the diff --git a/backend/src/modules/payments/services/payout-privacy.ts b/backend/src/modules/payments/services/payout-privacy.ts new file mode 100644 index 0000000..a76ff9a --- /dev/null +++ b/backend/src/modules/payments/services/payout-privacy.ts @@ -0,0 +1,17 @@ +import { HttpError } from '../../../http-error'; + +export function resolveEscrowPayee(input: { + facilityAddress: string; + mode: 'facility' | 'pooled'; + poolAddress?: string; + production: boolean; +}): string { + if (input.mode === 'facility') return input.facilityAddress; + if (input.production) { + throw new HttpError(503, 'Pooled payout privacy is a testnet prototype and is disabled in production.'); + } + if (!input.poolAddress || !/^G[A-Z2-7]{55}$/.test(input.poolAddress)) { + throw new HttpError(503, 'Pooled payout privacy requires a valid PAYOUT_PRIVACY_POOL_ADDRESS.'); + } + return input.poolAddress; +} \ No newline at end of file diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index 3ae83ae..c27f2de 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -123,6 +123,15 @@ describe('payments module against live testnet', () => { expect(funded.escrowId).not.toBeNull(); expect(funded.stellarTxHash).toBeTruthy(); + const transactionResponse = await fetch(`https://horizon-testnet.stellar.org/transactions/${funded.stellarTxHash}`); + if (!transactionResponse.ok) { + throw new Error(`Unable to retrieve testnet fee measurement: HTTP ${transactionResponse.status}`); + } + const transaction = (await transactionResponse.json()) as { fee_charged: string }; + console.log( + `[payout privacy testnet] ${process.env.PAYOUT_PRIVACY_MODE ?? 'facility'} funding fee: ${transaction.fee_charged} stroops`, + ); + const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); expect(released.status).toBe('released'); }, 60_000); diff --git a/backend/tests/unit/payment-payout-privacy.test.ts b/backend/tests/unit/payment-payout-privacy.test.ts new file mode 100644 index 0000000..4285ec6 --- /dev/null +++ b/backend/tests/unit/payment-payout-privacy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { HttpError } from '../../src/http-error'; +import { resolveEscrowPayee } from '../../src/modules/payments/services/payout-privacy'; + +const facilityAddress = `G${'A'.repeat(55)}`; +const poolAddress = `G${'B'.repeat(55)}`; + +describe('payout privacy prototype', () => { + it('keeps the facility wallet as the default payout destination', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'facility', production: true })).toBe(facilityAddress); + }); + + it('uses the shared pool only when explicitly enabled outside production', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: false })).toBe(poolAddress); + }); + + it('rejects pooled mode in production or without a valid pool address', () => { + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: true })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', production: false })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress: `M${'A'.repeat(55)}`, production: false })).toThrow(HttpError); + }); +}); \ No newline at end of file