Skip to content
Open
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
29 changes: 28 additions & 1 deletion src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
Request,
ParseUUIDPipe,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { AdminService, ReconcileOutcome } from './admin.service';
import { SuspendCampaignDto } from './dtos/suspend-campaign.dto';
import { ReconcileBalanceDto } from './dtos/reconcile-balance.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
Expand All @@ -29,8 +30,34 @@
return this.adminService.suspendCampaign(
id,
dto,
req.user.sub,

Check warning on line 33 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 33 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
req.user.email,

Check warning on line 34 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 34 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
);
}

/**
* POST /admin/campaigns/:id/reconcile-balance
*
* Reads the on-chain Stellar account and the APPROVED/RELEASED fund
* releases for this campaign, then either:
* * reports the figures (DRY_RUN) if `body.force !== true`, or
* * writes the canonical `Campaign.raisedAmount = netAvailableByAssetTotal`
* and records an AuditLog (mode: WRITE) when `body.force === true`.
*
* Body: { force: boolean, reason?: string }. The endpoint is the only
* safe path to correct a discrepancy and cannot be triggered silently.
*/
@Post('campaigns/:id/reconcile-balance')
async reconcileCampaignBalance(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReconcileBalanceDto,
@Request() req: any,
): Promise<ReconcileOutcome> {
return this.adminService.reconcileCampaignBalance(
id,
dto,
req.user.sub,

Check warning on line 59 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 59 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
req.user.email,

Check warning on line 60 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .user on an `any` value

Check warning on line 60 in src/admin/admin.controller.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
);
}
}
3 changes: 2 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { AdminController } from './admin.controller';
import { NotificationsModule } from '../notifications/notifications.module';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { CampaignsModule } from '../campaigns/campaigns.module';

/** Module providing admin campaign suspension, user moderation, and audit logging */
@Module({
imports: [PrismaModule, AuthModule, NotificationsModule],
imports: [PrismaModule, AuthModule, NotificationsModule, CampaignsModule],
controllers: [AdminController],
providers: [AdminService, JwtAuthGuard, RolesGuard],
})
Expand Down
100 changes: 100 additions & 0 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,121 @@
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CampaignsService } from '../campaigns/campaigns.service';
import { SuspendCampaignDto } from './dtos/suspend-campaign.dto';
import { ReconcileBalanceDto } from './dtos/reconcile-balance.dto';

export interface ReconcileOutcome {
campaignId: string;
storedRaisedAmount: string;
netAvailableByAssetTotal: string;
onChainTotal: string;
netReleasedAmount: string;
discrepancyDetected: boolean;
auditLogId: string;
applied: boolean;
reason?: string;
}

@Injectable()
export class AdminService {
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
private readonly campaignsService: CampaignsService,
) {}

/**
* Reconcile a campaign's stored `raisedAmount` against the on-chain Stellar
* account. This is the ONLY path that may write a corrected `raisedAmount`
* after accounting for approved/released `FundRelease` outflows.
*
* `dto.force` MUST be true to perform the write. Without it, the endpoint
* runs in dry-run mode: it returns the projected figures and an AuditLog
* row marked DRY_RUN so admins can inspect but nothing is mutated.
*
* Every invocation writes an `AuditLog` row with `action = ADMIN_ACTION`
* and `resourceType = 'campaign_balance_reconciliation'` so the trace is
* permanent and searchable.
*/
async reconcileCampaignBalance(
campaignId: string,
dto: ReconcileBalanceDto,
adminId: string,
adminEmail: string,
): Promise<ReconcileOutcome> {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
select: { id: true, contractId: true },
});
if (!campaign) {
throw new NotFoundException(`Campaign ${campaignId} not found`);
}
if (!campaign.contractId) {
throw new BadRequestException('Campaign has no contractId set');
}

// CampaignsService.getContractBalance is the single source of truth for
// the canonical per-asset net figure. It never writes to the database.
const report = await this.campaignsService.getContractBalance(campaignId);

const applied = dto.force === true && report.discrepancyDetected;

let auditLogId: string;
let writtenAmount: string | null = null;

if (applied) {
const updated = await this.prisma.campaign.update({
where: { id: campaignId },
data: { raisedAmount: report.netAvailableByAssetTotal },
select: { raisedAmount: true },
});
writtenAmount = updated.raisedAmount.toString();
}

const audit = await this.prisma.auditLog.create({
data: {
userId: adminId,
action: 'ADMIN_ACTION',
resourceType: 'campaign_balance_reconciliation',
resourceId: campaignId,
details: JSON.stringify({
kind: 'BALANCE_RECONCILED',
mode: applied ? 'WRITE' : 'DRY_RUN',
force: dto.force === true,
reason: dto.reason ?? null,
adminEmail,
contractId: campaign.contractId,
storedRaisedAmount: report.storedRaisedAmount,
netAvailableByAssetTotal: report.netAvailableByAssetTotal,
onChainTotal: report.onChainTotal,
netReleasedAmount: report.netReleasedAmount,
discrepancyDetected: report.discrepancyDetected,
writtenAmount,
}),
},
});
auditLogId = audit.id;

Check failure on line 102 in src/admin/admin.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'auditLogId' is never reassigned. Use 'const' instead

return {
campaignId,
storedRaisedAmount: report.storedRaisedAmount,
netAvailableByAssetTotal: report.netAvailableByAssetTotal,
onChainTotal: report.onChainTotal,
netReleasedAmount: report.netReleasedAmount,
discrepancyDetected: report.discrepancyDetected,
auditLogId,
applied,
reason: dto.reason,
};
}

/** Suspend a campaign with an audit log entry and creator notification */
async suspendCampaign(
campaignId: string,
dto: SuspendCampaignDto,
adminId: string,
adminEmail: string,

Check warning on line 122 in src/admin/admin.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'adminEmail' is defined but never used
): Promise<{ message: string }> {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
Expand Down
21 changes: 21 additions & 0 deletions src/admin/dtos/reconcile-balance.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';

/**
* Request body for POST /admin/campaigns/:id/reconcile-balance.
*
* `force` is REQUIRED. The endpoint refuses to write a corrected
* `Campaign.raisedAmount` unless the admin explicitly acknowledges that
* a non-readonly mutation is being performed. This is the audit gate that
* the issue (#1) asked us to add in place of the silent write.
*
* `reason` is optional human-readable text captured in the AuditLog row.
*/
export class ReconcileBalanceDto {
@IsBoolean()
force!: boolean;

@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
199 changes: 199 additions & 0 deletions src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,202 @@ describe('CampaignsService milestone target validation', () => {
);
});
});

import { NotFoundException } from '@nestjs/common';

type Balance = {
assetCode: string;
assetIssuer?: string;
balance: string;
isNative: boolean;
};

const createService = ({
campaign,
balances,
releases,
}: {
campaign: any;
balances: Balance[];
releases: Array<{ amount: string | number }>;
}) => {
const prisma: any = {
campaign: {
findUnique: jest.fn().mockResolvedValue(campaign),
update: jest.fn(),
},
fundRelease: {
findMany: jest.fn().mockResolvedValue(releases),
},
};

const stellarTransactions: any = {
getContractBalances: jest.fn().mockResolvedValue(balances),
};

const service = new CampaignsService(prisma, stellarTransactions);
return { service, prisma, stellarTransactions };
};

describe('CampaignsService.getContractBalance safety fixes (issue #1)', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('throws NotFoundException when the campaign does not exist', async () => {
const { service } = createService({
campaign: null,
balances: [],
releases: [],
});
await expect(service.getContractBalance('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});

it('throws BadRequestException when the campaign has no contractId', async () => {
const { service } = createService({
campaign: { id: 'c1', contractId: null, raisedAmount: 0 },
balances: [],
releases: [],
});
await expect(service.getContractBalance('c1')).rejects.toBeInstanceOf(
BadRequestException,
);
});

it('XLM-only case: a single native balance matching the stored amount reports NO discrepancy', async () => {
const { service, prisma } = createService({
campaign: { id: 'c1', contractId: 'CONTRACT', raisedAmount: 100 },
balances: [
{ assetCode: 'XLM', balance: '100', isNative: true },
],
releases: [],
});

const report = await service.getContractBalance('c1');

expect(report.discrepancyDetected).toBe(false);
expect(report.netAvailableByAssetTotal).toBe('100');
expect(report.netReleasedAmount).toBe('0');
expect(report.onChainTotal).toBe('100');
expect(report.perAsset).toHaveLength(1);
expect(report.perAsset[0]).toMatchObject({
assetCode: 'XLM',
isNative: true,
grossOnChain: '100',
released: '0',
netAvailable: '100',
});
// The fix to issue #1: never silently write raisedAmount.
expect(prisma.campaign.update).not.toHaveBeenCalled();
});

it('multi-asset case: an issued-asset balance is NOT mixed into the XLM counter', async () => {
// 50 XLM on-chain + 1_000 of an issued credit asset. If the buggy
// implementation summed numerically, netAvailableByAssetTotal would
// equal 1050 and the discrepancy flag would be wrong. The fix keeps
// XLM as the canonical XLM-denominated figure (Campaign.raisedAmount
// is XLM-denominated), so USDC appears in `perAsset` but does NOT
// fold into the canonical total.
const { service, prisma } = createService({
campaign: { id: 'c2', contractId: 'CONTRACT', raisedAmount: 80 },
balances: [
{ assetCode: 'XLM', balance: '50', isNative: true },
{
assetCode: 'USDC',
assetIssuer: 'G-ISSUER',
balance: '1000',
isNative: false,
},
],
releases: [],
});

const report = await service.getContractBalance('c2');

// XLM-only net figure is 50 vs stored 80 -> discrepancy, but the
// service MUST NOT silently write. The audit-gated write lives in
// AdminService.reconcileCampaignBalance, not here.
expect(report.discrepancyDetected).toBe(true);
expect(report.netAvailableByAssetTotal).toBe('50');
expect(report.onChainTotal).toBe('50');
expect(report.perAsset).toHaveLength(2);

const xlm = report.perAsset.find((p) => p.isNative);
const usdc = report.perAsset.find((p) => !p.isNative);
expect(xlm).toMatchObject({
assetCode: 'XLM',
grossOnChain: '50',
released: '0',
netAvailable: '50',
});
expect(usdc).toMatchObject({
assetCode: 'USDC',
assetIssuer: 'G-ISSUER',
grossOnChain: '1000',
released: '0',
netAvailable: '1000',
});

expect(prisma.campaign.update).not.toHaveBeenCalled();
});

it('post-release netted case: stored raisedAmount equals on-chain + APPROVED/RELEASED outflows', async () => {
// Campaign has 15 XLM of `raisedAmount` recorded in the DB. The contract
// account currently holds 10 XLM (5 XLM drained via an APPROVED release).
// naive on-chain comparison would flag this as a discrepancy; the fix
// nets against approved/released fund releases so 10 + 5 == 15.
const { service, prisma } = createService({
campaign: { id: 'c3', contractId: 'CONTRACT', raisedAmount: 15 },
balances: [
{ assetCode: 'XLM', balance: '10', isNative: true },
],
releases: [{ amount: 5 }],
});

const report = await service.getContractBalance('c3');

expect(report.discrepancyDetected).toBe(false);
expect(report.onChainTotal).toBe('10');
expect(report.netReleasedAmount).toBe('5');
expect(report.netAvailableByAssetTotal).toBe('15');
expect(prisma.campaign.update).not.toHaveBeenCalled();
});

it('releases that exceed on-chain holdings surface a discrepancy even after netting', async () => {
// Without any on-chain balance but with a prior APPROVED release of 5
// XLM, the canonical net is 0 + 5 = 5 while stored is 10. The flag
// surfaces the case for an admin override. The service still does not
// write to the DB.
const { service, prisma } = createService({
campaign: { id: 'c4', contractId: 'CONTRACT', raisedAmount: 10 },
balances: [{ assetCode: 'XLM', balance: '0', isNative: true }],
releases: [{ amount: 5 }],
});

const report = await service.getContractBalance('c4');

expect(report.netAvailableByAssetTotal).toBe('5');
expect(report.netReleasedAmount).toBe('5');
expect(report.discrepancyDetected).toBe(true);
expect(report.storedRaisedAmount).toBe('10');
expect(prisma.campaign.update).not.toHaveBeenCalled();
});

it('refuses to silently write raisedAmount when the figures diverge', async () => {
const { service, prisma } = createService({
campaign: { id: 'c5', contractId: 'CONTRACT', raisedAmount: 9999 },
balances: [{ assetCode: 'XLM', balance: '5', isNative: true }],
releases: [],
});

const report = await service.getContractBalance('c5');

expect(report.discrepancyDetected).toBe(true);
expect(report.netAvailableByAssetTotal).toBe('5');
expect(report.storedRaisedAmount).toBe('9999');
// The critical fix: this method must NEVER write to the DB.
expect(prisma.campaign.update).not.toHaveBeenCalled();
});
});
Loading
Loading