Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ LIVEMODE=
# Optional base58 secret key that sponsors Solana checkout network fees / rent.
# When set, buyers only co-sign; the API cosigns and broadcasts.
# When unset, buyers pay fees via wallet signAndSend.
# Dashboard payouts also use this key; for payouts it must match the platform's
# default wallet and that wallet must hold both the payout USDC and fee SOL.
# TRANSACTION_FEE_PAYER_KEY=

# Subscription billing: in-process poller (single-instance Docker / local only).
Expand Down
2 changes: 2 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ LIVEMODE=true
# Optional base58 secret key that sponsors Solana checkout network fees / rent.
# When set, buyers only co-sign; the API cosigns and broadcasts.
# When unset, buyers pay fees via wallet signAndSend.
# Dashboard payouts also use this key; for payouts it must match the platform's
# default wallet and that wallet must hold both the payout USDC and fee SOL.
# TRANSACTION_FEE_PAYER_KEY=

# Subscription billing: leave the in-process poller off for Cloud Run.
Expand Down
128 changes: 128 additions & 0 deletions apps/api/src/__tests__/Payout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jest.mock('../modules/chains/Solana', () => ({
last_valid_block_height: 100000,
recipients_count: 1,
}),
GetCheckoutFeePayerPublicKey: jest.fn().mockReturnValue('platform_wallet'),
SignAndSimulatePayoutTransaction: jest
.fn()
.mockResolvedValue('signed_base64tx'),
BroadcastSignedTransaction: jest.fn().mockResolvedValue({
status: 'paid',
signature: 'sig123',
Expand Down Expand Up @@ -200,4 +204,128 @@ describe('PayoutModule', () => {
);
});
});

describe('CreateAndProcessDashboardPayout', () => {
const payout = {
id: 'po_z_1',
object: 'payout',
account: 'acct_z_seller',
platform_account: 'acct_z_platform',
amount: 1000,
currency: 'usdc',
destination: 'wa_z_1',
status: 'pending',
} as Payout;

const input = {
amount: 1000,
currency: 'usdc',
destination: 'wa_z_1',
};

it('rejects a signer mismatch before creating the payout', async () => {
jest
.spyOn(module as any, 'GetPlatformWalletPublicKey')
.mockResolvedValue('different_wallet');
const createSpy = jest.spyOn(module, 'CreatePayout');

await expect(
module.CreateAndProcessDashboardPayout(
'acct_z_platform',
'acct_z_seller',
input
)
).rejects.toThrow(
'TRANSACTION_FEE_PAYER_KEY environment variable must match the platform payout wallet'
);
expect(createSpy).not.toHaveBeenCalled();
});

it('reuses the build and broadcast flow for a dashboard payout', async () => {
jest
.spyOn(module as any, 'GetPlatformWalletPublicKey')
.mockResolvedValue('platform_wallet');
jest.spyOn(module, 'CreatePayout').mockResolvedValue(payout);
jest.spyOn(module, 'BuildPayoutsBatch').mockResolvedValue({
object: 'payout_batch_build',
unsigned_transaction: 'base64tx',
estimated_fee_lamports: 5000,
blockhash: 'blockhash123',
last_valid_block_height: 100000,
payouts: [payout],
total_amount: 1000,
recipients_count: 1,
});
const broadcastSpy = jest
.spyOn(module, 'BroadcastPayoutsBatch')
.mockResolvedValue({
object: 'payout_batch_broadcast',
signature: 'sig123',
status: 'paid',
viewer_url: 'https://solscan.io/tx/sig123',
payouts: [{ ...payout, status: 'paid' }],
});

const result = await module.CreateAndProcessDashboardPayout(
'acct_z_platform',
'acct_z_seller',
input
);

expect(result.status).toBe('paid');
expect(broadcastSpy).toHaveBeenCalledWith('acct_z_platform', {
signed_transaction: 'signed_base64tx',
payouts: ['po_z_1'],
blockhash: 'blockhash123',
last_valid_block_height: 100000,
});
});

it('fails and refunds a payout when signing or simulation fails', async () => {
jest
.spyOn(module as any, 'GetPlatformWalletPublicKey')
.mockResolvedValue('platform_wallet');
jest.spyOn(module, 'CreatePayout').mockResolvedValue(payout);
jest.spyOn(module, 'BuildPayoutsBatch').mockResolvedValue({
object: 'payout_batch_build',
unsigned_transaction: 'base64tx',
estimated_fee_lamports: 5000,
blockhash: 'blockhash123',
last_valid_block_height: 100000,
payouts: [payout],
total_amount: 1000,
recipients_count: 1,
});
(module as any).solana.SignAndSimulatePayoutTransaction.mockRejectedValue(
new Error('Simulation failed')
);
const processingPayout = { ...payout, status: 'processing' } as Payout;
const failedPayout = {
...payout,
status: 'failed',
failure_message: 'Simulation failed',
} as Payout;
jest
.spyOn(module, 'GetPayout')
.mockResolvedValueOnce(processingPayout)
.mockResolvedValueOnce(failedPayout);
const failSpy = jest
.spyOn(module as any, 'MarkPayoutFailed')
.mockResolvedValue(undefined);

const result = await module.CreateAndProcessDashboardPayout(
'acct_z_platform',
'acct_z_seller',
input
);

expect(failSpy).toHaveBeenCalledWith(
processingPayout,
'blockchain_error',
'Simulation failed'
);
expect(result.status).toBe('failed');
expect(result.payouts).toEqual([failedPayout]);
});
});
});
82 changes: 82 additions & 0 deletions apps/api/src/modules/Payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,88 @@ export class PayoutModule {
};
}

/**
* Create and process a payout initiated from the platform dashboard.
*
* The configured fee payer must also be the platform wallet because that
* wallet owns the USDC being transferred.
*/
async CreateAndProcessDashboardPayout(
platformAccountId: string,
connectedAccountId: string,
input: CreatePayoutInput
): Promise<PayoutBatchBroadcastResponse> {
const platformWalletPublicKey = await this.GetPlatformWalletPublicKey(
platformAccountId
);
let configuredSigner: string;
try {
configuredSigner = this.solana.GetCheckoutFeePayerPublicKey();
} catch {
throw new AppError(
'TRANSACTION_FEE_PAYER_KEY environment variable is required for dashboard payouts',
400,
'invalid_request_error'
);
}

if (configuredSigner !== platformWalletPublicKey) {
throw new AppError(
'TRANSACTION_FEE_PAYER_KEY environment variable must match the platform payout wallet',
400,
'invalid_request_error'
);
}

const payout = await this.CreatePayout(connectedAccountId, input);
let buildResult: PayoutBatchBuildResponse;

try {
buildResult = await this.BuildPayoutsBatch(platformAccountId, {
payouts: [payout.id],
});

const signedTransaction =
await this.solana.SignAndSimulatePayoutTransaction(
buildResult.unsigned_transaction,
platformWalletPublicKey
);

return this.BroadcastPayoutsBatch(platformAccountId, {
signed_transaction: signedTransaction,
payouts: [payout.id],
blockhash: buildResult.blockhash,
last_valid_block_height: buildResult.last_valid_block_height,
});
} catch (error: unknown) {
const failureMessage =
error instanceof Error ? error.message : 'Failed to process payout';
const currentPayout = await this.GetPayout(payout.id);

if (
currentPayout &&
(currentPayout.status === 'pending' ||
currentPayout.status === 'processing')
) {
await this.MarkPayoutFailed(
currentPayout,
'blockchain_error',
failureMessage
);
}

const failedPayout = await this.GetPayout(payout.id);
return {
object: 'payout_batch_broadcast',
signature: '',
status: 'failed',
viewer_url: '',
payouts: failedPayout ? [failedPayout] : [],
failure_message: failureMessage,
};
}
}

/**
* Broadcast a signed batch payout transaction and update payout statuses.
*
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/modules/chains/Solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,70 @@ export class Solana {
};
}

/**
* Return the public key for the configured server-side fee payer.
*/
GetCheckoutFeePayerPublicKey(): string {
const keypair = Keypair.fromSecretKey(
bs58.decode(this.RequireCheckoutFeePayerSecretKey())
);
return keypair.publicKey.toBase58();
}

/**
* Sign and simulate a server-built payout transaction.
*
* The configured key must match the expected platform wallet and must be a
* required signer on the transaction.
*/
async SignAndSimulatePayoutTransaction(
unsignedTransaction: string,
expectedSigner: string
): Promise<string> {
const transaction = Transaction.from(
Buffer.from(unsignedTransaction, 'base64')
);
const signerKeypair = Keypair.fromSecretKey(
bs58.decode(this.RequireCheckoutFeePayerSecretKey())
);
const signerPublicKey = signerKeypair.publicKey.toBase58();

if (signerPublicKey !== expectedSigner) {
throw new Error(
'TRANSACTION_FEE_PAYER_KEY does not match the platform payout wallet'
);
}

if (transaction.feePayer?.toBase58() !== expectedSigner) {
throw new Error(
'Payout transaction fee payer does not match the platform'
);
}

const requiresSigner = transaction.signatures.some(
({ publicKey }) => publicKey.toBase58() === expectedSigner
);
if (!requiresSigner) {
throw new Error('Platform wallet is not a required payout signer');
}

transaction.sign(signerKeypair);

const simulation = await this.WithRetry(() =>
this.connection.simulateTransaction(transaction)
);
if (simulation.value.err) {
const logs = simulation.value.logs?.slice(-3).join(' | ');
throw new Error(
`Payout transaction simulation failed: ${JSON.stringify(
simulation.value.err
)}${logs ? ` (${logs})` : ''}`
);
}

return transaction.serialize().toString('base64');
}

/**
* Broadcast a signed transaction to the Solana network.
* The transaction must be fully signed before calling this method.
Expand Down
61 changes: 61 additions & 0 deletions apps/api/src/routes/dashboardPayouts.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Dashboard-only payout routes.
*
* These routes orchestrate server-side signing without changing the
* Stripe-compatible payout API.
*/

import * as express from 'express';
import { CreatePayoutSchema } from '@zoneless/shared-schemas';
import {
RequireConnectedAccountOwnership,
RequirePlatform,
} from '../middleware/Authorization';
import { ValidateRequest } from '../middleware/ValidateRequest';
import { db } from '../modules/Database';
import { EventService } from '../modules/EventService';
import { PayoutModule } from '../modules/Payout';
import { AsyncHandler } from '../utils/AsyncHandler';
import { Logger } from '../utils/Logger';

const router = express.Router();
const eventService = new EventService(db);
const payoutModule = new PayoutModule(db, eventService);

/**
* POST /v1/dashboard/payouts
* Create, sign, simulate, and broadcast a connected-account payout.
*/
router.post(
'/',
RequirePlatform(),
RequireConnectedAccountOwnership('zoneless-account', 'header'),
ValidateRequest(CreatePayoutSchema),
AsyncHandler(async (req: express.Request, res: express.Response) => {
const platformAccountId = req.user.account;
const connectedAccountId = req.connectedAccount!.id;

Logger.info('Processing dashboard payout', {
platformAccountId,
connectedAccountId,
amount: req.body.amount,
});

const result = await payoutModule.CreateAndProcessDashboardPayout(
platformAccountId,
connectedAccountId,
req.body
);

Logger.info('Dashboard payout processing completed', {
platformAccountId,
connectedAccountId,
status: result.status,
signature: result.signature,
});

res.status(201).json(result);
})
);

export default router;
2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import invoicesRouter from './invoices.routes';
import reportingRouter from './reporting.routes';
import billingRouter from './billing.routes';
import telemetryRouter from './telemetry.routes';
import dashboardPayoutsRouter from './dashboardPayouts.routes';

const router = express.Router();

Expand Down Expand Up @@ -89,4 +90,5 @@ router.use('/invoiceitems', invoiceItemsRouter);
router.use('/invoices', invoicesRouter);
router.use('/reporting', reportingRouter);
router.use('/telemetry', telemetryRouter);
router.use('/dashboard/payouts', dashboardPayoutsRouter);
export default router;
Loading
Loading