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
159 changes: 104 additions & 55 deletions src/payments/crypto-checkout.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma, WalletCreationStatus } from '@prisma/client';
import { CircleService } from '../circle/circle.service';
import { CryptoCheckoutService } from './crypto-checkout.service';

const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';

// getChain reads from chains.config (env-driven). Stub it so the test
// doesn't depend on ACTIVE_CHAINS / RPC env being set.
jest.mock('../blockchain/chains.config', () => ({
getChain: (id: string) => ({
id,
usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
}),
getChain: (id: string) => ({ id, usdcAddress: USDC_ADDRESS }),
}));

function makeConfig(rate = 1600, expiry = 30): ConfigService {
Expand All @@ -18,16 +18,36 @@ function makeConfig(rate = 1600, expiry = 30): ConfigService {
} as unknown as ConfigService;
}

// `null` → no USDC balance; a string → that USDC balance on the wallet.
function makeCircle(usdcAmount: string | null): CircleService {
return {
client: {
getWalletTokenBalance: jest.fn().mockResolvedValue({
data: {
tokenBalances:
usdcAmount === null
? []
: [{ token: { tokenAddress: USDC_ADDRESS }, amount: usdcAmount }],
},
}),
},
} as unknown as CircleService;
}

describe('CryptoCheckoutService', () => {
describe('ngnToUsdc', () => {
it('converts NGN to 6-dp USDC at the configured rate', () => {
const svc = new CryptoCheckoutService({} as never, makeConfig(1600));
const svc = new CryptoCheckoutService(
{} as never,
makeConfig(1600),
makeCircle('0'),
);
expect(svc.ngnToUsdc(new Prisma.Decimal(5000)).toString()).toBe('3.125');
expect(svc.ngnToUsdc(new Prisma.Decimal(1600)).toString()).toBe('1');
});
});

describe('createDepositIntent', () => {
describe('prepareCrypto', () => {
const wallet = {
circleWalletId: 'cw-1',
address: '0xWALLET',
Expand All @@ -41,72 +61,101 @@ describe('CryptoCheckoutService', () => {
};
}

it('creates a CryptoDeposit and returns the deposit instruction', async () => {
const input = {
transactionId: 'txn-1',
buyerId: 'buyer-1',
chain: 'BASE-SEPOLIA',
priceNgn: new Prisma.Decimal(5000),
quantity: 1,
};

it('pays from balance (no deposit) when the wallet already holds enough', async () => {
const prisma = makePrisma(wallet);
const svc = new CryptoCheckoutService(prisma as never, makeConfig(1600));

const intent = await svc.createDepositIntent({
transactionId: 'txn-1',
buyerId: 'buyer-1',
chain: 'BASE-SEPOLIA',
priceNgn: new Prisma.Decimal(5000),
quantity: 1,
});
const svc = new CryptoCheckoutService(
prisma as never,
makeConfig(1600),
makeCircle('5'),
);

const plan = await svc.prepareCrypto(input);

// Organizer-bears: face price is 3.125 USDC; ticketFee is backed out
// of it and HostIT's 3% added back, landing at totalFee 3.124999 (a
// 1-base-unit rounding delta from face) — what the buyer must send.
expect(intent).toMatchObject({
chain: 'BASE-SEPOLIA',
address: '0xWALLET',
amountUsdc: '3.124999',
usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
decimals: 6,
expect(plan).toMatchObject({
mode: 'balance',
requiredUsdc: '3.124999', // organizer-bears total for a 5000 NGN ticket
walletBalanceUsdc: '5.000000',
});
expect(intent.expiresAt).toBeInstanceOf(Date);
expect(prisma.cryptoDeposit.create).not.toHaveBeenCalled();
});

it('returns a shortfall deposit when the balance is insufficient', async () => {
const prisma = makePrisma(wallet);
const svc = new CryptoCheckoutService(
prisma as never,
makeConfig(1600),
makeCircle('1'),
);

const plan = await svc.prepareCrypto(input);

expect(plan.mode).toBe('deposit');
if (plan.mode === 'deposit') {
// 3.124999 required - 1 held = 2.124999 to top up.
expect(plan.deposit).toMatchObject({
address: '0xWALLET',
amountUsdc: '2.124999',
usdcAddress: USDC_ADDRESS,
decimals: 6,
});
expect(plan.walletBalanceUsdc).toBe('1.000000');
}
const createArg = prisma.cryptoDeposit.create.mock.calls[0][0];
expect(createArg.data).toMatchObject({
transactionId: 'txn-1',
walletId: 'cw-1',
address: '0xWALLET',
chain: 'BASE-SEPOLIA',
});
expect(createArg.data.amountUsdc.toString()).toBe('3.124999');
expect(createArg.data.amountUsdc.toString()).toBe('2.124999');
});

it('deposits the full amount when the wallet holds no USDC', async () => {
const prisma = makePrisma(wallet);
const svc = new CryptoCheckoutService(
prisma as never,
makeConfig(1600),
makeCircle(null),
);

const plan = await svc.prepareCrypto(input);

expect(plan.mode).toBe('deposit');
if (plan.mode === 'deposit') {
expect(plan.deposit.amountUsdc).toBe('3.124999');
}
});

it('rejects when the buyer wallet is not yet provisioned', async () => {
const prisma = makePrisma({
...wallet,
creationStatus: WalletCreationStatus.PENDING,
});
const svc = new CryptoCheckoutService(prisma as never, makeConfig());

await expect(
svc.createDepositIntent({
transactionId: 'txn-1',
buyerId: 'buyer-1',
chain: 'BASE-SEPOLIA',
priceNgn: new Prisma.Decimal(5000),
quantity: 1,
}),
).rejects.toBeInstanceOf(BadRequestException);
const svc = new CryptoCheckoutService(
prisma as never,
makeConfig(),
makeCircle('0'),
);

await expect(svc.prepareCrypto(input)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.cryptoDeposit.create).not.toHaveBeenCalled();
});

it('rejects when the buyer has no wallet on the chain', async () => {
const prisma = makePrisma(null);
const svc = new CryptoCheckoutService(prisma as never, makeConfig());

await expect(
svc.createDepositIntent({
transactionId: 'txn-1',
buyerId: 'buyer-1',
chain: 'BASE-SEPOLIA',
priceNgn: new Prisma.Decimal(5000),
quantity: 1,
}),
).rejects.toBeInstanceOf(BadRequestException);
const svc = new CryptoCheckoutService(
prisma as never,
makeConfig(),
makeCircle('0'),
);

await expect(svc.prepareCrypto(input)).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
});
113 changes: 84 additions & 29 deletions src/payments/crypto-checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma, WalletCreationStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CircleService } from '../circle/circle.service';
import { getChain } from '../blockchain/chains.config';
import { computeUsdcFees } from '../blockchain/onchain-fees';

Expand All @@ -21,17 +22,26 @@ export interface DepositIntent {
}

/**
* Direct USDC deposit checkout (MVP of #69).
*
* Builds a deposit instruction for a pending transaction: the buyer
* sends `amountUsdc` USDC to their own per-chain Circle wallet, and the
* `transactions.inbound` webhook settles the purchase (see the inbound
* handler in CircleWebhookProcessor). A `CryptoDeposit` row links the
* receiving wallet to the transaction so the webhook can match it.
* How a crypto purchase will settle:
* - `balance`: the buyer's custodial wallet already holds enough USDC;
* settle straight from balance (no deposit step).
* - `deposit`: top-up needed — `deposit.amountUsdc` is the SHORTFALL the
* buyer must send; the mint pulls the full total from the combined
* balance afterwards.
*/
export type CryptoSettlementPlan =
| { mode: 'balance'; requiredUsdc: string; walletBalanceUsdc: string }
| { mode: 'deposit'; deposit: DepositIntent; walletBalanceUsdc: string };

/**
* Crypto (USDC) checkout. Funds are always spent from the buyer's own
* HostIT-custodied Circle wallet — the settlement worker signs
* `approve` + `mintTicket` from it. This service decides whether that
* wallet can already cover the purchase (pay from balance) or needs a
* top-up deposit first.
*
* MVP scope: funds land in the buyer's (HostIT-custodied) wallet;
* sweeping to treasury / organizer settlement is a follow-up tied to
* payouts (#68). Pricing uses a flat NGN→USDC rate, not a live oracle.
* Pricing uses a flat NGN→USDC rate (not a live oracle); the on-chain
* split + settlement is handled by MintTicketProcessor.
*/
@Injectable()
export class CryptoCheckoutService {
Expand All @@ -40,6 +50,7 @@ export class CryptoCheckoutService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly circle: CircleService,
) {}

/** Convert an NGN amount to a 6-dp USDC Decimal using the flat rate. */
Expand All @@ -49,18 +60,22 @@ export class CryptoCheckoutService {
}

/**
* Create the deposit instruction + CryptoDeposit row for a pending
* crypto transaction. Throws if the buyer has no ready wallet on the
* event's chain (the webhook needs a known receiving wallet to match).
* Decide how a pending crypto transaction settles. Reads the buyer's
* custodial USDC balance: if it covers the required total, returns a
* `balance` plan (caller settles + enqueues mints immediately); else
* creates a `CryptoDeposit` for the shortfall and returns a `deposit`
* plan (the inbound webhook settles once the top-up lands).
*
* Throws if the buyer has no ready wallet on the event's chain.
*/
async createDepositIntent(input: {
async prepareCrypto(input: {
transactionId: string;
buyerId: string;
chain: string;
/** Unit ticket price in the event currency (NGN). */
priceNgn: Prisma.Decimal | string;
quantity: number;
}): Promise<DepositIntent> {
}): Promise<CryptoSettlementPlan> {
const chainCfg = getChain(input.chain);

const wallet = await this.prisma.userWallet.findFirst({
Expand All @@ -77,19 +92,40 @@ export class CryptoCheckoutService {
);
}

// The deposit must cover the on-chain `totalFee` (ticketFee + HostIT's
// cut) for every ticket, since `mintTicket` pulls totalFee per mint from
// this wallet. Estimated off-chain via the shared fee helper (same math
// that set the on-chain ticketFee at publish); the settlement worker
// approves the authoritative on-chain totalFee at mint time.
// Required = on-chain totalFee (ticketFee + HostIT's cut) per ticket,
// times quantity — exactly what `mintTicket` pulls across the N mints.
// Estimated off-chain via the shared fee helper (same math that set the
// on-chain ticketFee at publish); the worker approves the authoritative
// on-chain totalFee at mint time.
const usdcNgnRate = this.config.getOrThrow<number>('crypto.usdcNgnRate');
const totalFeeBaseUnits = computeUsdcFees(
input.priceNgn,
usdcNgnRate,
).totalFee;
const amountUsdc = new Prisma.Decimal(totalFeeBaseUnits)
const requiredUsdc = new Prisma.Decimal(totalFeeBaseUnits)
.mul(input.quantity)
.div(10 ** USDC_DECIMALS);

const walletBalanceUsdc = await this.getUsdcBalance(
wallet.circleWalletId,
chainCfg.usdcAddress,
);

// Enough already in the custodial wallet — pay from balance, no deposit.
if (walletBalanceUsdc.gte(requiredUsdc)) {
this.logger.log(
`Crypto pay-from-balance (txn=${input.transactionId}, required=${requiredUsdc.toFixed(USDC_DECIMALS)}, balance=${walletBalanceUsdc.toFixed(USDC_DECIMALS)})`,
);
return {
mode: 'balance',
requiredUsdc: requiredUsdc.toFixed(USDC_DECIMALS),
walletBalanceUsdc: walletBalanceUsdc.toFixed(USDC_DECIMALS),
};
}

// Short — deposit only the missing amount. The mint pulls the full
// total from the combined (existing + topped-up) balance.
const shortfall = requiredUsdc.sub(walletBalanceUsdc);
const expiryMinutes = this.config.getOrThrow<number>(
'crypto.depositExpiryMinutes',
);
Expand All @@ -101,23 +137,42 @@ export class CryptoCheckoutService {
chain: input.chain,
walletId: wallet.circleWalletId,
address: wallet.address,
amountUsdc,
amountUsdc: shortfall,
usdcAddress: chainCfg.usdcAddress,
expiresAt,
},
});

this.logger.log(
`Crypto deposit intent created (txn=${input.transactionId}, chain=${input.chain}, amountUsdc=${amountUsdc.toString()})`,
`Crypto deposit (shortfall) created (txn=${input.transactionId}, shortfall=${shortfall.toFixed(USDC_DECIMALS)}, balance=${walletBalanceUsdc.toFixed(USDC_DECIMALS)})`,
);

return {
chain: input.chain,
address: wallet.address,
amountUsdc: amountUsdc.toString(),
usdcAddress: chainCfg.usdcAddress,
decimals: USDC_DECIMALS,
expiresAt,
mode: 'deposit',
walletBalanceUsdc: walletBalanceUsdc.toFixed(USDC_DECIMALS),
deposit: {
chain: input.chain,
address: wallet.address,
amountUsdc: shortfall.toFixed(USDC_DECIMALS),
usdcAddress: chainCfg.usdcAddress,
decimals: USDC_DECIMALS,
expiresAt,
},
};
}

/** Current USDC balance of a Circle wallet, as a Decimal (0 if none). */
private async getUsdcBalance(
circleWalletId: string,
usdcAddress: string,
): Promise<Prisma.Decimal> {
const response = await this.circle.client.getWalletTokenBalance({
id: circleWalletId,
});
const balances = response.data?.tokenBalances ?? [];
const usdc = balances.find(
(b) => b.token?.tokenAddress?.toLowerCase() === usdcAddress.toLowerCase(),
);
return new Prisma.Decimal(usdc?.amount ?? 0);
}
}
Loading
Loading