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
13 changes: 10 additions & 3 deletions src/blockchain/circle-contract.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface ExecuteContractParams {
* BlockchainTransaction row instead of creating a new one. Used by
* workers that pre-record the queued transaction at enqueue time. */
existingBlockchainTransactionId?: string;
/** Circle wallet id that signs (and therefore is `msg.sender` for) this
* execution. Defaults to the platform treasury wallet. Callers pass an
* organizer/scanner wallet when the on-chain identity must be theirs —
* e.g. `createTicket` (organizer becomes the ticket admin + payout
* target) or `checkIn` (signed by the role-holding scanner). */
walletId?: string;
}

export interface ExecuteContractResult {
Expand Down Expand Up @@ -89,10 +95,11 @@ export class CircleContractService {
);
const chain = params.chain ?? this.circle.defaultChain;
const contractAddress = this.resolveDiamondAddress(chain);
const signerWalletId = params.walletId ?? this.circle.treasuryWalletId;

const response =
await this.circle.client.createContractExecutionTransaction({
walletId: this.circle.treasuryWalletId,
walletId: signerWalletId,
contractAddress,
abiFunctionSignature: signature,
abiParameters,
Expand All @@ -115,7 +122,7 @@ export class CircleContractService {
where: { id: params.existingBlockchainTransactionId },
data: {
circleTransactionId,
circleWalletId: this.circle.treasuryWalletId,
circleWalletId: signerWalletId,
chain,
},
})
Expand All @@ -124,7 +131,7 @@ export class CircleContractService {
type: params.txType,
status: BlockchainTxStatus.PENDING,
circleTransactionId,
circleWalletId: this.circle.treasuryWalletId,
circleWalletId: signerWalletId,
chain,
ticketId: params.ticketId,
eventId: params.eventId,
Expand Down
60 changes: 48 additions & 12 deletions src/blockchain/event-publish.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BlockchainTxStatus,
BlockchainTxType,
EventStatus,
WalletCreationStatus,
} from '@prisma/client';
import { Interface, type LogDescription } from 'ethers';
import { PrismaService } from '../prisma/prisma.service';
Expand All @@ -13,20 +14,27 @@ import { BlockchainReadService } from './blockchain-read.service';
import { CircleContractService } from './circle-contract.service';

/**
* Numeric codes from the Diamond's FeeType enum (LibAddressesAndFees.sol).
* Producer ships strings (`'ETH'`, `'USDC'`, ...) so the API stays
* symbolic; we map to the on-chain enum here.
* 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> = {
ETH: 1,
WETH: 2,
USDT: 3,
USDC: 4,
USDT0: 5,
EURC: 6,
GHO: 7,
LINK: 8,
LSK: 9,
NATIVE: 2,
WNATIVE: 3,
USDT: 4,
USDC: 5,
USDT0: 6,
EURC: 7,
GHO: 8,
LINK: 9,
LSK: 10,
};

const EVENT_PUBLISH_QUEUE = 'event-publish';
Expand Down Expand Up @@ -103,6 +111,33 @@ export class EventPublishProcessor extends WorkerHost {
return;
}

// The organizer must sign createTicket so they become the ticket's
// on-chain admin: `ticketAdmin` (the instant crypto-payout target for
// non-refundable events + the mainAdmin who can withdraw refundable
// balances) and the `ticketAdminRole` holder that gates check-in.
// Signing with the treasury would make HostIT the admin instead.
const event = await this.prisma.event.findUnique({
where: { id: data.eventId },
select: { organizerId: true },
});
if (!event) {
throw new Error(`Event ${data.eventId} not found`);
}
const organizerWallet = await this.prisma.userWallet.findFirst({
where: {
userId: event.organizerId,
chain: data.chain,
creationStatus: WalletCreationStatus.CREATED,
},
});
if (!organizerWallet?.circleWalletId) {
// Transient during provisioning: throw so Bull retries with backoff
// while Circle finishes creating the organizer's wallet.
throw new Error(
`Organizer ${event.organizerId} has no ready wallet on ${data.chain} — cannot sign createTicket`,
);
}

try {
const args = this.buildCreateTicketArgs(data);

Expand All @@ -113,6 +148,7 @@ export class EventPublishProcessor extends WorkerHost {
txType: BlockchainTxType.CREATE_EVENT,
eventId: data.eventId,
existingBlockchainTransactionId: data.blockchainTxId,
walletId: organizerWallet.circleWalletId,
});

this.logger.log(
Expand Down
42 changes: 41 additions & 1 deletion src/blockchain/ticket-checkin.processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ function setup(
alreadyConfirmed?: { id: string } | null;
webhooksEnabled?: boolean;
poll?: { state: string; txHash?: string; errorReason?: string };
scannerWallet?: { circleWalletId: string; address: string } | null;
} = {},
) {
const findUnique = jest
.fn()
.mockResolvedValue(opts.ticket === undefined ? ticketRow() : opts.ticket);
const findFirst = jest.fn().mockResolvedValue(opts.alreadyConfirmed ?? null);
const userWalletFindFirst = jest
.fn()
.mockResolvedValue(
opts.scannerWallet === undefined
? { circleWalletId: 'scw-1', address: '0xscanner' }
: opts.scannerWallet,
);
const update = jest.fn().mockResolvedValue({});
const executeContract = jest
.fn()
Expand All @@ -44,6 +52,7 @@ function setup(
const prisma = {
ticket: { findUnique },
blockchainTransaction: { findFirst, update },
userWallet: { findFirst: userWalletFindFirst },
};
const circle = { executeContract, pollUntilTerminal };
const config = { get };
Expand All @@ -57,6 +66,7 @@ function setup(
proc,
findUnique,
findFirst,
userWalletFindFirst,
update,
executeContract,
pollUntilTerminal,
Expand All @@ -68,6 +78,7 @@ function job(
ticketId: 't-1',
eventId: 'e-1',
blockchainTxId: 'bt-1',
scannedBy: 'u-scanner',
},
attemptsMade = 0,
): Job<CheckinTicketJobData> {
Expand All @@ -93,12 +104,33 @@ describe('TicketCheckinProcessor', () => {
txType: BlockchainTxType.CHECKIN,
ticketId: 't-1',
existingBlockchainTransactionId: 'bt-1',
// Signed by the scanner's wallet (holds the ticketAdmin role),
// not the treasury.
walletId: 'scw-1',
}),
);
// Webhook authoritative → no polling.
expect(m.pollUntilTerminal).not.toHaveBeenCalled();
});

it('throws when the job has no scannedBy (cannot resolve a signer)', async () => {
const m = setup({ webhooksEnabled: true });

await expect(
m.proc.process(
job({ ticketId: 't-1', eventId: 'e-1', blockchainTxId: 'bt-1' }),
),
).rejects.toThrow(/no scannedBy/);
expect(m.executeContract).not.toHaveBeenCalled();
});

it('throws when the scanner has no ready wallet on the chain', async () => {
const m = setup({ webhooksEnabled: true, scannerWallet: null });

await expect(m.proc.process(job())).rejects.toThrow(/no ready wallet/);
expect(m.executeContract).not.toHaveBeenCalled();
});

it('is idempotent: skips when a CONFIRMED check-in already exists', async () => {
const m = setup({ alreadyConfirmed: { id: 'bt-old' } });

Expand Down Expand Up @@ -157,7 +189,15 @@ describe('TicketCheckinProcessor', () => {

await expect(
m.proc.process(
job({ ticketId: 't-1', eventId: 'e-1', blockchainTxId: 'bt-1' }, 2),
job(
{
ticketId: 't-1',
eventId: 'e-1',
blockchainTxId: 'bt-1',
scannedBy: 'u-scanner',
},
2,
),
),
).rejects.toThrow('circle 500');
expect(m.update).toHaveBeenCalledWith(
Expand Down
30 changes: 28 additions & 2 deletions src/blockchain/ticket-checkin.processor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BlockchainTxStatus, BlockchainTxType } from '@prisma/client';
import {
BlockchainTxStatus,
BlockchainTxType,
WalletCreationStatus,
} from '@prisma/client';
import { Job } from 'bullmq';
import { PrismaService } from '../prisma/prisma.service';
import { CircleContractService } from './circle-contract.service';
Expand Down Expand Up @@ -52,7 +56,7 @@ export class TicketCheckinProcessor extends WorkerHost {
return;
}

const { ticketId, blockchainTxId } = job.data;
const { ticketId, blockchainTxId, scannedBy } = job.data;

const ticket = await this.prisma.ticket.findUnique({
where: { id: ticketId },
Expand Down Expand Up @@ -108,6 +112,27 @@ export class TicketCheckinProcessor extends WorkerHost {
);
}

// On-chain checkIn is gated to `ticketAdminRole` holders and must be
// signed by the scanner (organizer by default, or a delegated admin) —
// not the treasury. Resolve the scanner's Circle wallet as the signer.
if (!scannedBy) {
throw new Error(
`Check-in job for ticket ${ticketId} has no scannedBy — cannot resolve a signer holding the on-chain ticketAdmin role`,
);
}
const scannerWallet = await this.prisma.userWallet.findFirst({
where: {
userId: scannedBy,
chain: ticket.event.chain,
creationStatus: WalletCreationStatus.CREATED,
},
});
if (!scannerWallet?.circleWalletId) {
throw new Error(
`Scanner ${scannedBy} has no ready wallet on ${ticket.event.chain} — cannot sign checkIn`,
);
}

try {
const args = [
ticket.ticketType.onChainTicketId, // uint64 _ticketId
Expand All @@ -123,6 +148,7 @@ export class TicketCheckinProcessor extends WorkerHost {
eventId: ticket.event.id,
ticketId: ticket.id,
existingBlockchainTransactionId: blockchainTxId,
walletId: scannerWallet.circleWalletId,
});

this.logger.log(
Expand Down
22 changes: 15 additions & 7 deletions src/events/events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,16 @@ export class EventsService {
// call per type. Build the per-type payloads + pre-create one
// 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.
const usdcNgnRate =
this.configService.getOrThrow<number>('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,
ticketData: {
Expand All @@ -528,13 +538,11 @@ export class EventsService {
symbol: `${eventSymbol}-${tt.name.replace(/\s+/g, '').slice(0, 4).toUpperCase()}`,
uri: `https://api.hostit.ng/events/${event.slug}/${tt.id}/metadata`,
},
// Placeholder — proper organizer-selected fee types + crypto-
// denominated prices land in a follow-up DTO/UI PR. For now every
// type defaults to ETH at the (incorrect) fiat-amount-as-wei value.
// Safe to ship because the on-chain mint flow isn't user-facing
// yet; we'll fix prices before the first real organizer publishes.
feeTypes: ['ETH'],
prices: [tt.price.toString()],
// Crypto checkout settles in USDC via `mintTicket`. Free events
// ignore fees on-chain (createTicket skips setTicketFees when
// isFree), so the converted value is harmless there.
feeTypes: ['USDC'],
prices: [toUsdcBaseUnits(tt.price)],
}));

// Atomic transition + per-type BlockchainTransaction rows
Expand Down
Loading