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
25 changes: 23 additions & 2 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ All management requests require the following headers for wallet verification:

## 2. Webhook Event Payload

When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON body to the registered `callback_url`.
When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON body to the registered `callback_url`. There are two supported event types — `buy` and `sell` — and both use the same payload shape, distinguished only by the `event_type` field value.

### Payload Fields

Expand All @@ -101,7 +101,12 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON
| `fee_paid` | `string` | The protocol/creator fee paid for this trade in XLM. | `"0.4650000"` |
| `timestamp` | `string` | The ISO 8601 UTC timestamp of the trade event transaction. | `"2026-06-23T04:00:00.000Z"` |

### Example Payload
> [!NOTE]
> **Nullable/Optional Fields:** None of the fields above are ever `null` or absent. Every field is populated on every dispatch for both event types below — there is no partial or conditional payload shape to account for.

### `buy` Event

Fired when a trader purchases a creator's keys. `buyer_or_seller_address` holds the **buyer's** wallet address.

```json
{
Expand All @@ -115,6 +120,22 @@ When a trade occurs, AccessLayer sends an HTTP `POST` request containing a JSON
}
```

### `sell` Event

Fired when a trader sells a creator's keys. `buyer_or_seller_address` holds the **seller's** wallet address.

```json
{
"event_type": "sell",
"creator_id": "GCSW65D4G56DF8B2N7M9L3K4J2XDF",
"buyer_or_seller_address": "GDD3DDK4J5H5D9S8A7P6O5I4U8LKF",
"amount": "50.0000000",
"price": "12.2500000",
"fee_paid": "0.3062500",
"timestamp": "2026-06-23T05:15:00.000Z"
}
```

---

## 3. Delivery Guarantees & Ordering
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Integration test for the full price alert lifecycle: registering an alert,
// evaluating a price snapshot update that crosses the threshold, and
// confirming the alert is not re-queued on a subsequent snapshot update.

import { createAlert, evaluatePriceAlertsForMovement } from '../alert.service';
import { prisma } from '../../../utils/prisma.utils';

jest.mock('../../../utils/prisma.utils', () => ({
prisma: {
priceAlert: {
findFirst: jest.fn(),
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
},
}));

const mockPrisma = prisma as unknown as {
priceAlert: {
findFirst: jest.Mock;
create: jest.Mock;
findMany: jest.Mock;
update: jest.Mock;
};
};

const CREATOR_ID = 'creator-threshold-test';
const WALLET_ADDRESS =
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const CALLBACK_URL = 'https://hooks.example.com/price-alert';
const TARGET_PRICE = 500;

describe('price alert lifecycle: create -> threshold crossing -> delivery', () => {
let createdAlert: {
id: string;
creatorId: string;
walletAddress: string;
targetPrice: number;
direction: 'above' | 'below';
callbackUrl: string;
isActive: boolean;
triggeredAt: Date | null;
createdAt: Date;
};

beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn().mockResolvedValue({ ok: true });

createdAlert = {
id: 'alert-threshold-crossing',
creatorId: CREATOR_ID,
walletAddress: WALLET_ADDRESS,
targetPrice: TARGET_PRICE,
direction: 'above',
callbackUrl: CALLBACK_URL,
isActive: true,
triggeredAt: null,
createdAt: new Date('2026-07-01T00:00:00Z'),
};

mockPrisma.priceAlert.findFirst.mockResolvedValue(null);
mockPrisma.priceAlert.create.mockResolvedValue(createdAlert);
});

it('creates an alert at threshold 500, dispatches delivery when price moves from 400 to 600, and marks it triggered', async () => {
const alert = await createAlert({
creator_id: CREATOR_ID,
wallet_address: WALLET_ADDRESS,
target_price: TARGET_PRICE,
direction: 'above',
callback_url: CALLBACK_URL,
});

expect(mockPrisma.priceAlert.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
creatorId: CREATOR_ID,
targetPrice: TARGET_PRICE,
direction: 'above',
}),
})
);

// The snapshot update crosses the threshold (400 -> 600).
mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]);
mockPrisma.priceAlert.update.mockResolvedValue({});

await evaluatePriceAlertsForMovement({
creatorId: CREATOR_ID,
previousPrice: 400,
currentPrice: 600,
});

// Delivery job (webhook dispatch) was attempted for the crossing.
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
CALLBACK_URL,
expect.objectContaining({ method: 'POST' })
);
const body = JSON.parse(
(global.fetch as jest.Mock).mock.calls[0][1].body
);
expect(body.alert_id).toBe(alert.id);
expect(body.target_price).toBe(TARGET_PRICE);
expect(body.current_price).toBe(600);

// Alert is marked triggered.
expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith({
where: { id: createdAlert.id },
data: expect.objectContaining({
isActive: false,
triggeredAt: expect.any(Date),
}),
});
});

it('does not re-queue delivery on the next snapshot update after the alert has triggered', async () => {
await createAlert({
creator_id: CREATOR_ID,
wallet_address: WALLET_ADDRESS,
target_price: TARGET_PRICE,
direction: 'above',
callback_url: CALLBACK_URL,
});

// First crossing: 400 -> 600 triggers delivery and marks the alert inactive.
mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]);
mockPrisma.priceAlert.update.mockResolvedValue({});

await evaluatePriceAlertsForMovement({
creatorId: CREATOR_ID,
previousPrice: 400,
currentPrice: 600,
});

expect(global.fetch).toHaveBeenCalledTimes(1);

// Next snapshot update: the alert is no longer active/untriggered, so the
// query that scopes to isActive+untriggered alerts returns nothing.
mockPrisma.priceAlert.findMany.mockResolvedValueOnce([]);

await evaluatePriceAlertsForMovement({
creatorId: CREATOR_ID,
previousPrice: 600,
currentPrice: 700,
});

// No additional delivery or update calls.
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(mockPrisma.priceAlert.update).toHaveBeenCalledTimes(1);
});

it('does not dispatch delivery when the price moves but does not cross the threshold', async () => {
await createAlert({
creator_id: CREATOR_ID,
wallet_address: WALLET_ADDRESS,
target_price: TARGET_PRICE,
direction: 'above',
callback_url: CALLBACK_URL,
});

mockPrisma.priceAlert.findMany.mockResolvedValueOnce([createdAlert]);

await evaluatePriceAlertsForMovement({
creatorId: CREATOR_ID,
previousPrice: 400,
currentPrice: 450,
});

expect(global.fetch).not.toHaveBeenCalled();
expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled();
});
});
22 changes: 22 additions & 0 deletions src/modules/wallets/wallet-activity.service.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,26 @@ describe('fetchWalletActivity type filter integration', () => {
})
);
});

it('returns an empty array when there are no activities, regardless of filter', async () => {
mockPrisma.activity.findMany.mockResolvedValue([]);
mockPrisma.activity.count.mockResolvedValue(0);

const [buyItems, buyTotal] = await fetchWalletActivity(WALLET_ADDRESS, {
type: 'buy',
limit: 20,
offset: 0,
});

expect(buyItems).toEqual([]);
expect(buyTotal).toBe(0);

const [allItems, allTotal] = await fetchWalletActivity(WALLET_ADDRESS, {
limit: 20,
offset: 0,
});

expect(allItems).toEqual([]);
expect(allTotal).toBe(0);
});
});
37 changes: 37 additions & 0 deletions src/utils/pagination.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,40 @@ export async function paginateQuery<T>(

return { data, nextCursor, hasMore };
}

export type PaginatedResponse<T> = {
items: T[];
has_more: boolean;
next_cursor: string | null;
};

/**
* Builds a paginated response envelope from an over-fetched result set.
*
* Callers fetch up to `limit + 1` items; if the extra item is present, it is
* popped off and used to derive `next_cursor` for the next page.
*
* @param items Result set, expected to contain up to `limit + 1` items
* @param limit The page size requested by the caller
* @param cursorFn Extracts the cursor value from the last item kept on the page
*/
export function buildPaginatedResponse<T>(
items: T[],
limit: number,
cursorFn: (item: T) => string
): PaginatedResponse<T> {
if (items.length > limit) {
const page = items.slice(0, limit);
return {
items: page,
has_more: true,
next_cursor: cursorFn(page[page.length - 1]),
};
}

return {
items,
has_more: false,
next_cursor: null,
};
}
43 changes: 42 additions & 1 deletion src/utils/test/pagination.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { paginateQuery } from '../pagination.utils';
import { paginateQuery, buildPaginatedResponse } from '../pagination.utils';

describe('paginateQuery', () => {
const mockData = [
Expand Down Expand Up @@ -62,3 +62,44 @@ describe('paginateQuery', () => {
expect(result.nextCursor).toBeUndefined();
});
});

describe('buildPaginatedResponse', () => {
const items = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
];
const cursorFn = (item: { id: number }) => String(item.id);

it('returns has_more: false and next_cursor: null when items are under the limit', () => {
const result = buildPaginatedResponse(items.slice(0, 2), 5, cursorFn);

expect(result.items).toEqual(items.slice(0, 2));
expect(result.has_more).toBe(false);
expect(result.next_cursor).toBeNull();
});

it('returns has_more: false and next_cursor: null when items exactly match the limit', () => {
const result = buildPaginatedResponse(items, 3, cursorFn);

expect(result.items).toEqual(items);
expect(result.has_more).toBe(false);
expect(result.next_cursor).toBeNull();
});

it('pops the extra item and sets next_cursor when items exceed the limit', () => {
const result = buildPaginatedResponse(items, 2, cursorFn);

expect(result.items).toEqual(items.slice(0, 2));
expect(result.has_more).toBe(true);
expect(result.next_cursor).toBe('2');
});

it('returns an empty items array with has_more: false for an empty result set', () => {
const result = buildPaginatedResponse([], 5, cursorFn);

expect(result.items).toEqual([]);
expect(result.has_more).toBe(false);
expect(result.next_cursor).toBeNull();
});
});
Loading