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
54 changes: 52 additions & 2 deletions src/blockchain/circle-contract.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CircleTransactionSnapshot> {
const interval = options.intervalMs ?? 3_000;
const timeout = options.timeoutMs ?? 120_000;
Expand All @@ -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));
Expand Down
25 changes: 1 addition & 24 deletions src/blockchain/event-publish.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
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';
Expand Down
123 changes: 123 additions & 0 deletions src/blockchain/mint-ticket.processor.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<MintTicketJobData> {
return {
name: MINT_TICKET_JOB,
data: { ticketId: 't-1', eventId: 'e-1', blockchainTxId: 'bt-1' },
attemptsMade: 0,
opts: { attempts: 5 },
} as Job<MintTicketJobData>;
}

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
}),
);
});
});
104 changes: 86 additions & 18 deletions src/blockchain/mint-ticket.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import { ConfigService } from '@nestjs/config';
import {
BlockchainTxStatus,
BlockchainTxType,
PaymentProvider,
TicketStatus,
WalletCreationStatus,
} from '@prisma/client';
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,
Expand Down Expand Up @@ -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();
}
Expand All @@ -69,6 +74,7 @@ export class MintTicketProcessor extends WorkerHost {
where: { id: ticketId },
include: {
ticketType: true,
transaction: { select: { provider: true } },
event: {
select: {
id: true,
Expand Down Expand Up @@ -143,43 +149,105 @@ 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
// completion: submit and return, and the circle-webhook processor
// reconciles + finalizes when the transaction notification lands.
if (this.config.get<boolean>('circle.webhooksEnabled')) {
this.logger.log(
`mintTicket awaiting Circle webhook for completion (ticket=${ticket.id})`,
`${method} awaiting Circle webhook for completion (ticket=${ticket.id})`,
);
return;
}
Expand Down
Loading
Loading