diff --git a/src/blockchain/circle-contract.service.ts b/src/blockchain/circle-contract.service.ts index 5ec5353..baa799e 100644 --- a/src/blockchain/circle-contract.service.ts +++ b/src/blockchain/circle-contract.service.ts @@ -148,6 +148,47 @@ export class CircleContractService { }; } + /** + * Approve an ERC-20 spender from a developer-controlled wallet. Used to + * let the Diamond pull USDC from the buyer's wallet before `mintTicket`. + * Runs against the token contract (not the Diamond), so it bypasses the + * diamond ABI / address resolution. Returns the Circle transaction id; + * poll it (persist: false) to confirm before the dependent transfer. + */ + async approveErc20(params: { + walletId: string; + tokenAddress: string; + spender: string; + /** Allowance in token base units. */ + amount: string; + chain: string; + feeLevel?: FeeLevel; + }): Promise<{ circleTransactionId: string }> { + const response = + await this.circle.client.createContractExecutionTransaction({ + walletId: params.walletId, + contractAddress: params.tokenAddress, + abiFunctionSignature: 'approve(address,uint256)', + abiParameters: [params.spender, params.amount], + fee: { + type: 'level', + config: { feeLevel: params.feeLevel ?? 'MEDIUM' }, + }, + }); + + const circleTransactionId = response.data?.id; + if (!circleTransactionId) { + throw new Error( + `Circle returned no transactionId for approve: ${JSON.stringify(response.data)}`, + ); + } + + this.logger.log( + `Submitted USDC approve via Circle (chain=${params.chain}, spender=${params.spender}, amount=${params.amount}, txId=${circleTransactionId})`, + ); + return { circleTransactionId }; + } + /** Estimate gas tiers for a Diamond method without sending. */ async estimateFee( method: string, @@ -191,7 +232,14 @@ export class CircleContractService { * this — they will move to webhook-driven completion in #65. */ async pollUntilTerminal( circleTransactionId: string, - options: { intervalMs?: number; timeoutMs?: number } = {}, + options: { + intervalMs?: number; + timeoutMs?: number; + /** Mirror the terminal state onto a BlockchainTransaction row. + * Set false for txs we don't track (e.g. the ERC-20 approve leg), + * where `reconcile` would fail to find a matching row. */ + persist?: boolean; + } = {}, ): Promise { const interval = options.intervalMs ?? 3_000; const timeout = options.timeoutMs ?? 120_000; @@ -200,7 +248,9 @@ export class CircleContractService { while (Date.now() < deadline) { const snapshot = await this.getTransactionStatus(circleTransactionId); if (TERMINAL_STATES.has(snapshot.state)) { - await this.reconcile(circleTransactionId, snapshot); + if (options.persist !== false) { + await this.reconcile(circleTransactionId, snapshot); + } return snapshot; } await new Promise((r) => setTimeout(r, interval)); diff --git a/src/blockchain/event-publish.processor.ts b/src/blockchain/event-publish.processor.ts index f862943..0315159 100644 --- a/src/blockchain/event-publish.processor.ts +++ b/src/blockchain/event-publish.processor.ts @@ -12,30 +12,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { diamondAbi } from './abis'; import { BlockchainReadService } from './blockchain-read.service'; import { CircleContractService } from './circle-contract.service'; - -/** - * Numeric codes from the Diamond's FeeType enum, matching the verified - * MarketplaceFacet deployed on Base (Blockscout-confirmed). Producer ships - * strings (`'USDC'`, `'NATIVE'`, ...) so the API stays symbolic; we map to - * the on-chain enum here. - * - * NOTE: this replaced the stale Lisk-era ordering (`ETH=1, USDT=3, USDC=4`). - * The live Base enum renamed `ETH`→`NATIVE`, inserted `FIAT` at 1, and - * shifted the token codes — so the old map encoded USDC as USDT on-chain. - * `FIAT` (1) is intentionally omitted: it is not mintable via `mintTicket` - * and not settable via `setTicketFees` (the contract reverts). - */ -const FEE_TYPE_BY_NAME: Record = { - NATIVE: 2, - WNATIVE: 3, - USDT: 4, - USDC: 5, - USDT0: 6, - EURC: 7, - GHO: 8, - LINK: 9, - LSK: 10, -}; +import { FEE_TYPE_BY_NAME } from './onchain-fees'; const EVENT_PUBLISH_QUEUE = 'event-publish'; const CREATE_EVENT_JOB = 'create-event'; diff --git a/src/blockchain/mint-ticket.processor.spec.ts b/src/blockchain/mint-ticket.processor.spec.ts new file mode 100644 index 0000000..519de92 --- /dev/null +++ b/src/blockchain/mint-ticket.processor.spec.ts @@ -0,0 +1,123 @@ +import { BlockchainTxType, WalletCreationStatus } from '@prisma/client'; +import type { Job } from 'bullmq'; +import { MintTicketProcessor } from './mint-ticket.processor'; +import { MINT_TICKET_JOB, MintTicketJobData } from './mint-queue.service'; + +// getChain is env-driven; stub the USDC + Diamond addresses. +jest.mock('./chains.config', () => ({ + getChain: () => ({ + usdcAddress: '0xUSDC', + diamondAddress: '0xDIAMOND', + }), +})); + +function ticketRow(provider: string, overrides: Record = {}) { + return { + id: 't-1', + reference: 'HOSTIT_TXN_1', + tokenId: null, + status: 'PENDING', + buyerEmail: 'b@x.com', + ticketType: { id: 'tt-1', onChainTicketId: 7n, price: 5000 }, + transaction: { provider }, + event: { id: 'e-1', name: 'E', chain: 'BASE-SEPOLIA', slug: 's' }, + buyer: { + id: 'b-1', + wallets: [ + { + id: 'w-1', + chain: 'BASE-SEPOLIA', + address: '0xBUYER', + circleWalletId: 'cw-buyer', + creationStatus: WalletCreationStatus.CREATED, + }, + ], + }, + ...overrides, + }; +} + +function setup(provider: string) { + const findUnique = jest.fn().mockResolvedValue(ticketRow(provider)); + const update = jest.fn().mockResolvedValue({}); + const approveErc20 = jest + .fn() + .mockResolvedValue({ circleTransactionId: 'approve-1' }); + const pollUntilTerminal = jest + .fn() + .mockResolvedValue({ state: 'CONFIRMED', txHash: '0xhash' }); + const executeContract = jest + .fn() + .mockResolvedValue({ circleTransactionId: 'mint-1' }); + const getAllFees = jest.fn().mockResolvedValue({ + ticketFee: 3033980n, + hostItFee: 91019n, + totalFee: 3124999n, + }); + const finalize = jest.fn().mockResolvedValue(true); + // Webhook authoritative → worker submits and returns (no inline finalize). + const get = jest.fn().mockReturnValue(true); + + const proc = new MintTicketProcessor( + { ticket: { findUnique }, blockchainTransaction: { update } } as never, + { approveErc20, pollUntilTerminal, executeContract } as never, + { finalize } as never, + { get } as never, + { getAllFees } as never, + ); + return { proc, approveErc20, pollUntilTerminal, executeContract, getAllFees }; +} + +function job(): Job { + return { + name: MINT_TICKET_JOB, + data: { ticketId: 't-1', eventId: 'e-1', blockchainTxId: 'bt-1' }, + attemptsMade: 0, + opts: { attempts: 5 }, + } as Job; +} + +describe('MintTicketProcessor', () => { + it('crypto ticket: approves USDC then mintTicket signed by the buyer wallet', async () => { + const m = setup('CRYPTO'); + + await m.proc.process(job()); + + // Authoritative on-chain fee read for the USDC feeType (5). + expect(m.getAllFees).toHaveBeenCalledWith('BASE-SEPOLIA', 7n, 5); + // Approve the Diamond to pull exactly totalFee from the buyer wallet. + expect(m.approveErc20).toHaveBeenCalledWith( + expect.objectContaining({ + walletId: 'cw-buyer', + tokenAddress: '0xUSDC', + spender: '0xDIAMOND', + amount: '3124999', + }), + ); + // mintTicket signed by the buyer wallet, buyer as NFT recipient. + expect(m.executeContract).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'mintTicket', + args: [7n, 5, '0xBUYER'], + walletId: 'cw-buyer', + txType: BlockchainTxType.MINT, + existingBlockchainTransactionId: 'bt-1', + }), + ); + }); + + it('fiat ticket: free mintFiatTicket via treasury, no approve', async () => { + const m = setup('PAYSTACK'); + + await m.proc.process(job()); + + expect(m.approveErc20).not.toHaveBeenCalled(); + expect(m.getAllFees).not.toHaveBeenCalled(); + expect(m.executeContract).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'mintFiatTicket', + walletId: undefined, // defaults to treasury + }), + ); + }); +}); diff --git a/src/blockchain/mint-ticket.processor.ts b/src/blockchain/mint-ticket.processor.ts index 81964c6..4ad43d4 100644 --- a/src/blockchain/mint-ticket.processor.ts +++ b/src/blockchain/mint-ticket.processor.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { BlockchainTxStatus, BlockchainTxType, + PaymentProvider, TicketStatus, WalletCreationStatus, } from '@prisma/client'; @@ -11,8 +12,11 @@ import { Job } from 'bullmq'; import { Prisma } from '@prisma/client'; import { id as keccak256Utf8 } from 'ethers'; import { PrismaService } from '../prisma/prisma.service'; +import { BlockchainReadService } from './blockchain-read.service'; +import { getChain } from './chains.config'; import { CircleContractService } from './circle-contract.service'; import { MintFinalizerService } from './mint-finalizer.service'; +import { FEE_TYPE_USDC } from './onchain-fees'; import { MINT_TICKET_JOB, MintTicketJobData, @@ -53,6 +57,7 @@ export class MintTicketProcessor extends WorkerHost { private readonly circle: CircleContractService, private readonly finalizer: MintFinalizerService, private readonly config: ConfigService, + private readonly read: BlockchainReadService, ) { super(); } @@ -69,6 +74,7 @@ export class MintTicketProcessor extends WorkerHost { where: { id: ticketId }, include: { ticketType: true, + transaction: { select: { provider: true } }, event: { select: { id: true, @@ -143,35 +149,97 @@ export class MintTicketProcessor extends WorkerHost { ); } + // Crypto purchases settle on-chain via the paid `mintTicket` (the + // buyer's own wallet pays USDC and the contract splits the revenue); + // fiat purchases issue a free `mintFiatTicket` (payment already split + // at the gateway). Routed by the originating transaction's provider. + const isCrypto = ticket.transaction?.provider === PaymentProvider.CRYPTO; + try { - // Off-chain price recorded on-chain in the smallest fiat unit - // (kobo for NGN). paymentId is deterministic per ticket so a - // retry reuses it — the contract's replay guard + our tokenId - // idempotency check keep a re-mint safe. - const amountMinor = new Prisma.Decimal(ticket.ticketType.price) - .mul(100) - .toFixed(0); - const paymentId = keccak256Utf8(ticket.reference); - - const args = [ - ticket.ticketType.onChainTicketId, - wallet.address, - amountMinor, - paymentId, - ]; + let method: string; + let args: unknown[]; + let signerWalletId: string | undefined; + + if (isCrypto) { + // Buyer's wallet (gas-sponsored, in the user set) pays the + // contract. Read the authoritative on-chain totalFee, approve the + // Diamond to pull it, then mintTicket — which routes the split by + // `isRefundable` (instant to the organizer for non-refundable, + // escrow for refundable) and mints the NFT to the buyer. + if (!wallet.circleWalletId) { + throw new Error( + `Buyer wallet ${wallet.id} has no circleWalletId; cannot sign crypto mint`, + ); + } + const chainCfg = getChain(ticket.event.chain); + const { totalFee } = await this.read.getAllFees( + ticket.event.chain, + ticket.ticketType.onChainTicketId, + FEE_TYPE_USDC, + ); + + const { circleTransactionId: approveTxId } = + await this.circle.approveErc20({ + walletId: wallet.circleWalletId, + tokenAddress: chainCfg.usdcAddress, + spender: chainCfg.diamondAddress, + amount: totalFee.toString(), + chain: ticket.event.chain, + }); + + // The allowance must be on-chain before mintTicket's transferFrom. + // Poll inline (persist: false — the approve isn't a tracked row). + const approved = await this.circle.pollUntilTerminal(approveTxId, { + intervalMs: 4_000, + timeoutMs: 180_000, + persist: false, + }); + if (approved.state !== 'CONFIRMED' && approved.state !== 'COMPLETE') { + throw new Error( + `USDC approve for ticket ${ticket.id} ended ${approved.state}: ${approved.errorReason ?? '(no reason)'}`, + ); + } + + method = 'mintTicket'; + args = [ + ticket.ticketType.onChainTicketId, // uint64 _ticketId + FEE_TYPE_USDC, // enum FeeType _feeType (USDC) + wallet.address, // address _buyer (NFT recipient) + ]; + signerWalletId = wallet.circleWalletId; + } else { + // Off-chain price recorded on-chain in the smallest fiat unit + // (kobo for NGN). paymentId is deterministic per ticket so a + // retry reuses it — the contract's replay guard + our tokenId + // idempotency check keep a re-mint safe. + const amountMinor = new Prisma.Decimal(ticket.ticketType.price) + .mul(100) + .toFixed(0); + const paymentId = keccak256Utf8(ticket.reference); + method = 'mintFiatTicket'; + args = [ + ticket.ticketType.onChainTicketId, + wallet.address, + amountMinor, + paymentId, + ]; + // signerWalletId undefined → executeContract defaults to treasury + // (the Diamond's trusted backend for the fiat mint path). + } const { circleTransactionId } = await this.circle.executeContract({ - method: 'mintFiatTicket', + method, args, chain: ticket.event.chain, txType: BlockchainTxType.MINT, eventId: ticket.event.id, ticketId: ticket.id, existingBlockchainTransactionId: blockchainTxId, + walletId: signerWalletId, }); this.logger.log( - `mintFiatTicket submitted (ticket=${ticket.id}, circleTxId=${circleTransactionId})`, + `${method} submitted (ticket=${ticket.id}, circleTxId=${circleTransactionId})`, ); // When the Circle webhook is wired (#65) it is authoritative for @@ -179,7 +247,7 @@ export class MintTicketProcessor extends WorkerHost { // reconciles + finalizes when the transaction notification lands. if (this.config.get('circle.webhooksEnabled')) { this.logger.log( - `mintTicket awaiting Circle webhook for completion (ticket=${ticket.id})`, + `${method} awaiting Circle webhook for completion (ticket=${ticket.id})`, ); return; } diff --git a/src/blockchain/onchain-fees.ts b/src/blockchain/onchain-fees.ts new file mode 100644 index 0000000..7d02c13 --- /dev/null +++ b/src/blockchain/onchain-fees.ts @@ -0,0 +1,69 @@ +import { Prisma } from '@prisma/client'; + +/** + * FeeType enum codes from the deployed Base MarketplaceFacet + * (Blockscout-verified): NONE=0, FIAT=1, then tokens. Single source of + * truth for both the publish producer (event-publish) and the crypto + * settlement worker. `FIAT` (1) is intentionally absent — it isn't + * mintable via mintTicket nor settable via setTicketFees. + */ +export const FEE_TYPE_BY_NAME: Record = { + NATIVE: 2, + WNATIVE: 3, + USDT: 4, + USDC: 5, + USDT0: 6, + EURC: 7, + GHO: 8, + LINK: 9, + LSK: 10, +}; + +/** On-chain FeeType code for USDC — HostIT's crypto settlement token. */ +export const FEE_TYPE_USDC = FEE_TYPE_BY_NAME.USDC; + +/** USDC is 6-decimal everywhere Circle issues it. */ +export const USDC_DECIMALS = 6; + +/** + * HostIT platform fee in basis points, matching the deployed + * MarketplaceFacet's `hostItFeeBps` (300 = 3%). Keep in sync with the + * contract; the authoritative value at settlement is always read on-chain + * via `getAllFees`, but this drives the off-chain publish + deposit math. + */ +export const HOSTIT_FEE_BPS = 300; + +export interface UsdcFees { + /** Organizer's cut, USDC base units (6dp) — the on-chain `ticketFee`. */ + ticketFee: string; + /** ticketFee + HostIT's cut, USDC base units — what the buyer pays. */ + totalFee: string; +} + +/** + * On-chain USDC fees for a ticket priced in NGN, under the ORGANIZER-BEARS + * model: the buyer pays the face price, so HostIT's cut is backed out of + * the price when setting `ticketFee` (ticketFee = price * 10000/(10000+bps)). + * Mirrors the contract's integer math (hostItFee = floor(ticketFee*bps/1e4)) + * so an off-chain estimate matches what the Diamond will charge. + */ +export function computeUsdcFees( + priceNgn: Prisma.Decimal | string | number, + usdcNgnRate: number, +): UsdcFees { + const facePrice = new Prisma.Decimal(priceNgn) + .div(usdcNgnRate) + .mul(10 ** USDC_DECIMALS); + const ticketFee = facePrice + .mul(10_000) + .div(10_000 + HOSTIT_FEE_BPS) + .toDecimalPlaces(0, Prisma.Decimal.ROUND_DOWN); + const hostItFee = ticketFee + .mul(HOSTIT_FEE_BPS) + .div(10_000) + .toDecimalPlaces(0, Prisma.Decimal.ROUND_DOWN); + return { + ticketFee: ticketFee.toFixed(0), + totalFee: ticketFee.add(hostItFee).toFixed(0), + }; +} diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 21b1176..0967c67 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -20,6 +20,7 @@ import { Prisma, } from '@prisma/client'; import * as crypto from 'crypto'; +import { computeUsdcFees } from '../blockchain/onchain-fees'; @Injectable() export class EventsService { @@ -515,14 +516,11 @@ export class EventsService { // BlockchainTransaction row per type so the worker can attach the // Circle transactionId to the right row when it runs. // On-chain fees are denominated in USDC (6-dp), converted from the - // event's NGN price via the flat rate. This is the `ticketFee` the - // contract stores; it adds HostIT's 3% on top as `hostItFee`, so the - // buyer pays ticketFee + 3% (buyer-bears model). Revisit if we move to - // organizer-selected fee tokens / fee-inclusive pricing. + // event's NGN price. `computeUsdcFees` runs the ORGANIZER-BEARS model + // (buyer pays face price; HostIT's cut is backed out of `ticketFee`) so + // the buyer sees the same number on the fiat and crypto rails. const usdcNgnRate = this.configService.getOrThrow('crypto.usdcNgnRate'); - const toUsdcBaseUnits = (priceNgn: Prisma.Decimal | string): string => - new Prisma.Decimal(priceNgn).div(usdcNgnRate).mul(1_000_000).toFixed(0); const ticketTypePayloads = event.ticketTypes.map((tt) => ({ ticketTypeId: tt.id, @@ -542,7 +540,7 @@ export class EventsService { // ignore fees on-chain (createTicket skips setTicketFees when // isFree), so the converted value is harmless there. feeTypes: ['USDC'], - prices: [toUsdcBaseUnits(tt.price)], + prices: [computeUsdcFees(tt.price, usdcNgnRate).ticketFee], })); // Atomic transition + per-type BlockchainTransaction rows diff --git a/src/payments/crypto-checkout.service.spec.ts b/src/payments/crypto-checkout.service.spec.ts index 873c25a..9e37da3 100644 --- a/src/payments/crypto-checkout.service.spec.ts +++ b/src/payments/crypto-checkout.service.spec.ts @@ -49,13 +49,17 @@ describe('CryptoCheckoutService', () => { transactionId: 'txn-1', buyerId: 'buyer-1', chain: 'BASE-SEPOLIA', - amountNgn: new Prisma.Decimal(5000), + priceNgn: new Prisma.Decimal(5000), + quantity: 1, }); + // 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.125', + amountUsdc: '3.124999', usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', decimals: 6, }); @@ -68,7 +72,7 @@ describe('CryptoCheckoutService', () => { address: '0xWALLET', chain: 'BASE-SEPOLIA', }); - expect(createArg.data.amountUsdc.toString()).toBe('3.125'); + expect(createArg.data.amountUsdc.toString()).toBe('3.124999'); }); it('rejects when the buyer wallet is not yet provisioned', async () => { @@ -83,7 +87,8 @@ describe('CryptoCheckoutService', () => { transactionId: 'txn-1', buyerId: 'buyer-1', chain: 'BASE-SEPOLIA', - amountNgn: new Prisma.Decimal(5000), + priceNgn: new Prisma.Decimal(5000), + quantity: 1, }), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.cryptoDeposit.create).not.toHaveBeenCalled(); @@ -98,7 +103,8 @@ describe('CryptoCheckoutService', () => { transactionId: 'txn-1', buyerId: 'buyer-1', chain: 'BASE-SEPOLIA', - amountNgn: new Prisma.Decimal(5000), + priceNgn: new Prisma.Decimal(5000), + quantity: 1, }), ).rejects.toBeInstanceOf(BadRequestException); }); diff --git a/src/payments/crypto-checkout.service.ts b/src/payments/crypto-checkout.service.ts index 3972882..1b5a565 100644 --- a/src/payments/crypto-checkout.service.ts +++ b/src/payments/crypto-checkout.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { Prisma, WalletCreationStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { getChain } from '../blockchain/chains.config'; +import { computeUsdcFees } from '../blockchain/onchain-fees'; /** USDC is 6-decimal everywhere Circle issues it. */ const USDC_DECIMALS = 6; @@ -56,7 +57,9 @@ export class CryptoCheckoutService { transactionId: string; buyerId: string; chain: string; - amountNgn: Prisma.Decimal; + /** Unit ticket price in the event currency (NGN). */ + priceNgn: Prisma.Decimal | string; + quantity: number; }): Promise { const chainCfg = getChain(input.chain); @@ -74,7 +77,19 @@ export class CryptoCheckoutService { ); } - const amountUsdc = this.ngnToUsdc(input.amountNgn); + // 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. + const usdcNgnRate = this.config.getOrThrow('crypto.usdcNgnRate'); + const totalFeeBaseUnits = computeUsdcFees( + input.priceNgn, + usdcNgnRate, + ).totalFee; + const amountUsdc = new Prisma.Decimal(totalFeeBaseUnits) + .mul(input.quantity) + .div(10 ** USDC_DECIMALS); const expiryMinutes = this.config.getOrThrow( 'crypto.depositExpiryMinutes', ); diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts index 948fc85..ea7db53 100644 --- a/src/tickets/tickets.service.ts +++ b/src/tickets/tickets.service.ts @@ -587,7 +587,8 @@ export class TicketsService { transactionId: transaction.id, buyerId, chain: event.chain, - amountNgn: totalAmount, + priceNgn: ticketType.price, + quantity: dto.quantity, }); return { reference: transaction.reference,