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
21 changes: 20 additions & 1 deletion backend/src/creators/creators.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SearchCreatorsDto } from './dto/search-creators.dto';
import { PaginatedResponseDto } from '../common/dto';
import { PublicCreatorDto } from './dto/public-creator.dto';
import { BadRequestException } from '@nestjs/common';
import { JwtAuthGuard } from '../auth-module/guards/jwt-auth.guard';

describe('CreatorsController', () => {
let controller: CreatorsController;
Expand All @@ -15,6 +16,7 @@ describe('CreatorsController', () => {
createPlan: jest.fn(),
findAllPlans: jest.fn(),
findCreatorPlans: jest.fn(),
listCreators: jest.fn(),
};

beforeEach(async () => {
Expand All @@ -30,7 +32,10 @@ describe('CreatorsController', () => {
useValue: { getDashboard: jest.fn() },
},
],
}).compile();
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get<CreatorsController>(CreatorsController);
});
Expand All @@ -43,6 +48,20 @@ describe('CreatorsController', () => {
expect(controller).toBeDefined();
});

describe('listCreators', () => {
it('should call service.listCreators with mergeChain=false by default', async () => {
mockCreatorsService.listCreators.mockResolvedValue([]);
await controller.listCreators(undefined);
expect(mockCreatorsService.listCreators).toHaveBeenCalledWith(false);
});

it('should call service.listCreators with mergeChain=true when chain=true', async () => {
mockCreatorsService.listCreators.mockResolvedValue([]);
await controller.listCreators('true');
expect(mockCreatorsService.listCreators).toHaveBeenCalledWith(true);
});
});


describe('searchCreators', () => {
describe('GET /creators endpoint exists and is accessible', () => {
Expand Down
7 changes: 7 additions & 0 deletions backend/src/creators/creators.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export class CreatorsController {
return this.creatorsService.searchCreators(searchDto);
}

@Get('list')
@ApiOperation({ summary: 'List all creator plans, optionally merged with on-chain state' })
@ApiResponse({ status: 200, description: 'Array of plans with optional chain sync status' })
listCreators(@Query('chain') chain?: string): Promise<PlanDto[]> {
return this.creatorsService.listCreators(chain === 'true');
}

@Post('plans')
@ApiOperation({ summary: 'Create a new subscription plan' })
@ApiResponse({ status: 201, description: 'Plan created successfully' })
Expand Down
1 change: 0 additions & 1 deletion backend/src/creators/creators.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ describe('CreatorsService', () => {
});

describe('logging and resilience', () => {
it('createPlan logs when EventBus is not injected and still persists plan', async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CreatorsService,
Expand Down
59 changes: 59 additions & 0 deletions backend/src/creators/creators.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { User } from '../users/entities/user.entity';
import { PlanDto } from './dto/plan.dto';
import { PublicCreatorDto } from './dto/public-creator.dto';
import { SearchCreatorsDto } from './dto/search-creators.dto';
import { SubscriptionChainReaderService } from '../subscriptions/subscription-chain-reader.service';

export interface Plan {
id: number;
Expand All @@ -30,6 +31,8 @@ export class CreatorsService {
private readonly userRepository: Repository<User>,
@Optional()
private readonly eventBus?: EventBus,
@Optional()
private readonly chainReader?: SubscriptionChainReaderService,
) {}

createPlan(
Expand Down Expand Up @@ -135,6 +138,62 @@ export class CreatorsService {
);
}

/**
* Lists all in-memory plans, optionally merging chain state for each plan.
* Chain reads are best-effort: stale/disconnected results fall back to
* syncStatus='unknown' so callers always receive a valid response.
*/
async listCreators(mergeChain = false): Promise<PlanDto[]> {
const allPlans = Array.from(this.plans.values()).sort((a, b) => a.id - b.id);

if (!mergeChain) {
return allPlans.map((p) => Object.assign(new PlanDto(), p));
}

const contractId = this.chainReader?.getConfiguredContractId();
if (!contractId || !this.chainReader) {
this.logger.debug('listCreators: chain reader not configured, skipping merge');
return allPlans.map((p) =>
Object.assign(new PlanDto(), { ...p, syncStatus: 'unknown' }),
);
}

// Build planMap for O(1) lookup during merge
const planMap = new Map(allPlans.map((p) => [p.id, p]));

const merged = await Promise.all(
allPlans.map(async (plan) => {
const chainResult = await this.chainReader!.readPlan(contractId, plan.id);
if (!chainResult.ok) {
this.logger.warn(
`listCreators: chain read failed for plan ${plan.id}: ${chainResult.error}`,
);
const local = planMap.get(plan.id)!;
return Object.assign(new PlanDto(), {
...local,
syncStatus: 'unknown' as const,
});
}

const local = planMap.get(plan.id)!;
const chainPlan = chainResult.plan;
const isSynced =
local.creator === chainPlan.creator &&
local.asset === chainPlan.asset &&
local.amount === chainPlan.amount &&
local.intervalDays === chainPlan.intervalDays;

return Object.assign(new PlanDto(), {
...local,
syncStatus: isSynced ? ('synced' as const) : ('stale' as const),
lastSyncedAt: new Date(),
});
}),
);

return merged;
}

async searchCreators(
searchDto: SearchCreatorsDto,
): Promise<PaginatedResponseDto<PublicCreatorDto>> {
Expand Down
3 changes: 0 additions & 3 deletions backend/src/subscriptions/subscriptions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export enum CheckoutStatus {

export const SERVER_NETWORK = process.env.STELLAR_NETWORK ?? 'testnet';

treasury-deposit-event
interface Subscription {
id: string;
fan: string;
Expand All @@ -49,8 +48,6 @@ interface Subscription {
updatedAt?: Date;
}


main
interface Checkout {
id: string;
fanAddress: string;
Expand Down
26 changes: 17 additions & 9 deletions contract/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ severity_threshold = "high"

## To ignore a specific advisory, add it under `[advisories]` and include
## a justification comment explaining why it is safe in this project.
##
## Example:
##
## [advisories]
## # RUSTSEC-0000-0000: <short description>
## # Reason: <why this is acceptable here, link to issue / PR / upstream discussion>
## ignore = [
## "RUSTSEC-0000-0000",
## ]
[advisories]
# RUSTSEC-2024-0436: paste - no longer maintained
# Reason: Transitive dependency via soroban-wasmi → soroban-env-host → soroban-sdk 21.7.7.
# The `paste` crate is used only in macro expansion at compile time and has no runtime
# security surface. No upstream fix is available without upgrading soroban-sdk beyond the
# version pinned by this workspace. Tracked in: upgrade soroban-sdk when 22.x stabilises.
# Severity: warning (unmaintained), not high/critical — below our threshold.

# RUSTSEC-2026-0012: keccak 0.1.5 — unsoundness in opt-in ARMv8 assembly backend
# Reason: Transitive dependency via soroban-env-host → sha3 → keccak.
# The unsoundness only manifests when the `asm` feature is enabled on ARMv8 targets.
# Soroban contracts compile to Wasm (wasm32-unknown-unknown), not ARMv8 native code,
# so the vulnerable code path is never exercised. No upstream fix available without
# upgrading soroban-sdk. Severity: warning (unsound), not high/critical.
ignore = [
"RUSTSEC-2024-0436",
"RUSTSEC-2026-0012",
]
12 changes: 6 additions & 6 deletions contract/contracts/treasury/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use soroban_sdk::{
testutils::{Address as _, Events, MockAuth, MockAuthInvoke},
token::{StellarAssetClient, TokenClient},
xdr::SorobanAuthorizationEntry,
Address, Env, Error as SorobanError, IntoVal, Symbol, TryIntoVal,
Address, Env, IntoVal, Symbol, TryIntoVal,
};

fn create_token_contract<'a>(
Expand Down Expand Up @@ -63,7 +63,7 @@ fn test_withdraw_insufficient_balance() {
let result = treasury_client.try_withdraw(&user, &500);
assert_eq!(
result,
Err(Ok(SorobanError::from_contract_error(
Err(Ok(soroban_sdk::Error::from_contract_error(
Error::InsufficientBalance as u32,
)))
);
Expand Down Expand Up @@ -164,7 +164,7 @@ fn test_pause_blocks_deposit() {
let result = treasury_client.try_deposit(&user, &100);
assert_eq!(
result,
Err(Ok(SorobanError::from_contract_error(Error::Paused as u32)))
Err(Ok(soroban_sdk::Error::from_contract_error(Error::Paused as u32)))
);
}

Expand All @@ -190,7 +190,7 @@ fn test_pause_blocks_withdraw() {
let result = treasury_client.try_withdraw(&user, &100);
assert_eq!(
result,
Err(Ok(SorobanError::from_contract_error(Error::Paused as u32)))
Err(Ok(soroban_sdk::Error::from_contract_error(Error::Paused as u32)))
);
}

Expand Down Expand Up @@ -241,7 +241,7 @@ fn test_min_balance_blocks_withdraw() {
let result = treasury_client.try_withdraw(&user, &1); // would leave 299 < 300
assert_eq!(
result,
Err(Ok(SorobanError::from_contract_error(
Err(Ok(soroban_sdk::Error::from_contract_error(
Error::MinBalanceViolation as u32,
)))
);
Expand Down Expand Up @@ -285,7 +285,7 @@ fn test_set_min_balance_negative_reverts() {
let result = treasury_client.try_set_min_balance(&-1);
assert_eq!(
result,
Err(Ok(SorobanError::from_contract_error(
Err(Ok(soroban_sdk::Error::from_contract_error(
Error::NegativeMinBalance as u32,
)))
);
Expand Down
Loading