Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e5f0576
Add profile partner pricing assignments
ebma Jun 3, 2026
ef163f0
Make profile assignment replacement atomic
ebma Jun 3, 2026
ae137aa
Run audit/spec check
ebma Jun 3, 2026
6111f9f
Introduce separate cap for 'discount' post-swap subsidy
ebma Jun 9, 2026
dd2bc11
Adjust spec files
ebma Jun 9, 2026
9a8e089
Enforce maintenance windows on backend ramp operations
ebma Jun 9, 2026
19676d0
Document maintenance handling in quote and ramp specs
ebma Jun 9, 2026
00b36e3
Document maintenance handling on API surface
ebma Jun 9, 2026
dad786d
Make EVM subsidy caps configurable
ebma Jun 9, 2026
7f504d3
Document configurable EVM subsidy cap mechanics
ebma Jun 9, 2026
2dfc4d6
Update ramp phase cap documentation
ebma Jun 9, 2026
81189a3
Clarify quote lifecycle subsidy cap boundaries
ebma Jun 9, 2026
fc158b2
Update BRLA subsidy cap audit notes
ebma Jun 9, 2026
1d5889f
Address pr review comments
ebma Jun 9, 2026
998051f
Address pr review comments
ebma Jun 10, 2026
1f4147d
Add function to read and validate subsidy quote fractions from enviro…
ebma Jun 10, 2026
071eda9
Improve type for maintenance guard
ebma Jun 10, 2026
257f6be
Merge pull request #1212 from pendulum-chain/improve-subsidy-cap-hand…
ebma Jun 12, 2026
1c88bf2
Merge branch 'staging' into improve-maintenance-window-handling
ebma Jun 12, 2026
b633f4f
Add type to maintenanceGuard.ts
ebma Jun 12, 2026
3059ce9
Merge pull request #1214 from pendulum-chain/improve-maintenance-wind…
ebma Jun 12, 2026
1278ea6
Merge branch 'staging' into custom-rate-for-profile
ebma Jun 12, 2026
287bb1b
Rename migration
ebma Jun 15, 2026
4db2865
Add instruction to keep security-spec in sync to CLAUDE.md
ebma Jun 15, 2026
0e59536
Allow assigning partner to profile buy vs sell separately
ebma Jun 15, 2026
f5dc858
Update security-spec
ebma Jun 15, 2026
bbd724d
Fix type issues
ebma Jun 15, 2026
e031d94
Merge pull request #1194 from pendulum-chain/custom-rate-for-profile
ebma Jun 15, 2026
3a4a8be
Add dry run for nabla swap transaction
ebma Jun 15, 2026
f829813
Adjust security-spec
ebma Jun 15, 2026
7fa12c8
Address PR comments
ebma Jun 15, 2026
e7aff84
Make error recoverable
ebma Jun 15, 2026
7b59edb
Address PR review comments
ebma Jun 15, 2026
70b91ab
Address PR review comments
ebma Jun 15, 2026
cd3138b
Merge pull request #1223 from pendulum-chain/add-dry-run-for-nabla-swap
ebma Jun 15, 2026
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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ cd apps/frontend
bun test
```

## Security Spec Sync

`docs/security-spec/` is the audit-facing source of truth for security-sensitive behavior. When changing auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, migrations/schema that affect invariants, or cross-chain fund flow, do a quick targeted check for the matching spec file and update it in the same change if behavior changed.

Keep this lightweight: grep/read only the relevant spec path from `docs/security-spec/README.md`; skip this for cosmetic refactors, test-only changes, or implementation changes that do not alter security-relevant behavior.

## Type Issues

If IDE doesn't detect `@pendulum-chain/types` properly, ensure all `@polkadot/*` packages match versions in the types package. The root `package.json` uses `catalog:` for version management.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import {afterEach, describe, expect, it, mock} from "bun:test";
import {RampDirection} from "@vortexfi/shared";
import {Request, Response} from "express";
import httpStatus from "http-status";
import {Op, Transaction, UniqueConstraintError} from "sequelize";
import sequelize from "../../../config/database";
import Partner from "../../../models/partner.model";
import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model";
import User from "../../../models/user.model";
import {createProfilePartnerAssignment, listProfilePartnerAssignments} from "./profilePartnerAssignments.controller";

interface AssignmentFindAllOptions {
where: {
isActive?: boolean;
[Op.or]?: unknown[];
};
}

function createResponse() {
const res = {
body: undefined as unknown,
send: mock(() => res),
statusCode: Number(httpStatus.OK),
json: mock((body: unknown) => {
res.body = body;
return res;
}),
status: mock((statusCode: number) => {
res.statusCode = statusCode;
return res;
})
};

return res;
}

describe("createProfilePartnerAssignment", () => {
const originalTransaction = sequelize.transaction;
const originalUserFindByPk = User.findByPk;
const originalPartnerFindAll = Partner.findAll;
const originalAssignmentUpdate = ProfilePartnerAssignment.update;
const originalAssignmentCreate = ProfilePartnerAssignment.create;
const originalAssignmentFindAll = ProfilePartnerAssignment.findAll;

const transaction = { id: "profile-assignment-tx" };
const createdAt = new Date("2026-06-03T12:00:00.000Z");
const updatedAt = new Date("2026-06-03T12:00:01.000Z");

afterEach(() => {
sequelize.transaction = originalTransaction;
User.findByPk = originalUserFindByPk;
Partner.findAll = originalPartnerFindAll;
ProfilePartnerAssignment.update = originalAssignmentUpdate;
ProfilePartnerAssignment.create = originalAssignmentCreate;
ProfilePartnerAssignment.findAll = originalAssignmentFindAll;
});

function mockValidAssignmentDependencies() {
const transactionMock = mock(async (callback: (tx: unknown) => Promise<unknown>) => callback(transaction));
const userFindByPkMock = mock(async () => ({ id: "user-1" }));
const partnerFindAllMock = mock(async () => [
{ id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY },
{ id: "sell-partner-1", name: "Acme", rampType: RampDirection.SELL }
]);
const assignmentUpdateMock = mock(async () => [1]);
const assignmentCreateMock = mock(async () => ({
createdAt,
expiresAt: null,
buyPartnerId: "buy-partner-1",
id: "assignment-2",
isActive: true,
partnerName: "Acme",
sellPartnerId: "sell-partner-1",
updatedAt,
userId: "user-1"
}));

sequelize.transaction = transactionMock as unknown as typeof sequelize.transaction;
User.findByPk = userFindByPkMock as unknown as typeof User.findByPk;
Partner.findAll = partnerFindAllMock as unknown as typeof Partner.findAll;
ProfilePartnerAssignment.update = assignmentUpdateMock as unknown as typeof ProfilePartnerAssignment.update;
ProfilePartnerAssignment.create = assignmentCreateMock as unknown as typeof ProfilePartnerAssignment.create;

return {
assignmentCreateMock,
assignmentUpdateMock,
partnerFindAllMock,
transactionMock,
userFindByPkMock
};
}

it("replaces the active assignment inside a transaction after locking the profile row", async () => {
const { assignmentCreateMock, assignmentUpdateMock, transactionMock, userFindByPkMock } = mockValidAssignmentDependencies();
const res = createResponse();

await createProfilePartnerAssignment(
{
body: {
partnerName: "Acme",
userId: "user-1"
}
} as unknown as Request,
res as unknown as Response
);

expect(res.statusCode).toBe(httpStatus.CREATED);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(userFindByPkMock).toHaveBeenCalledWith("user-1", {
lock: Transaction.LOCK.UPDATE,
transaction
});
expect(assignmentUpdateMock).toHaveBeenCalledWith(
{ isActive: false },
{
transaction,
where: {
isActive: true,
userId: "user-1"
}
}
);
expect(assignmentCreateMock).toHaveBeenCalledWith(
{
buyPartnerId: "buy-partner-1",
expiresAt: null,
isActive: true,
partnerName: "Acme",
sellPartnerId: "sell-partner-1",
userId: "user-1"
},
{ transaction }
);
});

it("returns 409 when the active-assignment unique index rejects a concurrent replacement", async () => {
const { assignmentCreateMock } = mockValidAssignmentDependencies();
assignmentCreateMock.mockImplementation(async () => {
throw new UniqueConstraintError({ message: "active assignment already exists" });
});
const res = createResponse();

await createProfilePartnerAssignment(
{
body: {
partnerName: "Acme",
userId: "user-1"
}
} as unknown as Request,
res as unknown as Response
);

expect(res.statusCode).toBe(httpStatus.CONFLICT);
expect(res.body).toEqual({
error: {
code: "ASSIGNMENT_CONFLICT",
message: "An active assignment already exists for this profile. Please retry the request.",
status: httpStatus.CONFLICT
}
});
});

it("rejects ambiguous active partners for the same ramp type", async () => {
mockValidAssignmentDependencies();
Partner.findAll = mock(async () => [
{ id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY },
{ id: "buy-partner-2", name: "Acme", rampType: RampDirection.BUY }
]) as unknown as typeof Partner.findAll;
const res = createResponse();

await createProfilePartnerAssignment(
{
body: {
partnerName: "Acme",
userId: "user-1"
}
} as unknown as Request,
res as unknown as Response
);

expect(res.statusCode).toBe(httpStatus.CONFLICT);
expect(res.body).toEqual({
error: {
code: "AMBIGUOUS_PARTNER_ASSIGNMENT",
message: `Multiple active ${RampDirection.BUY} partners found with this name`,
status: httpStatus.CONFLICT
}
});
});

it("excludes expired assignments from the default list", async () => {
const assignmentFindAllMock = mock(async (_options: AssignmentFindAllOptions) => []);
ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll;
const res = createResponse();

await listProfilePartnerAssignments({ query: {} } as unknown as Request, res as unknown as Response);

expect(res.statusCode).toBe(httpStatus.OK);
expect(assignmentFindAllMock).toHaveBeenCalledTimes(1);
const findOptions = assignmentFindAllMock.mock.calls[0]?.[0];
expect(findOptions).toBeDefined();
if (!findOptions) {
throw new Error("ProfilePartnerAssignment.findAll was not called with options");
}
expect(findOptions.where.isActive).toBe(true);
expect(findOptions.where[Op.or]).toHaveLength(2);
});

it("includes expired assignments when includeInactive is true", async () => {
const assignmentFindAllMock = mock(async (_options: AssignmentFindAllOptions) => []);
ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll;
const res = createResponse();

await listProfilePartnerAssignments(
{ query: { includeInactive: "true" } } as unknown as Request<unknown, unknown, unknown, { includeInactive?: string }>,
res as unknown as Response
);

const findOptions = assignmentFindAllMock.mock.calls[0]?.[0];
expect(findOptions).toBeDefined();
if (!findOptions) {
throw new Error("ProfilePartnerAssignment.findAll was not called with options");
}
expect(findOptions.where).not.toHaveProperty("isActive");
expect(findOptions.where[Op.or]).toBeUndefined();
});
});
Loading
Loading