Skip to content

Commit f492eb2

Browse files
feat: resolve issues #485 #486 #487 #488
#485: add serializeBigInt alias for sanitizeBigInts and wire into response serializer (sendSuccess, sendPaginatedSuccess); expand unit tests to cover all four acceptance criteria #486: emit structured debug log after each successful price snapshot write with creator_id, new_price, previous_price, ledger_sequence, written_at; skip log when price unchanged; error log on failure was already present #487: change webhook max-limit error from 422 (was 409); update integration test to assert 422 status, descriptive error message, and that webhook count stays at the limit after the failed attempt #488: add docs/architecture/ownership-read-model.md documenting KeyOwnership table schema, buy/sell/transfer update triggers, balance conservation invariant, and replay consistency behaviour chore: add package-lock.json to .gitignore; remove trailing artifact
1 parent ec7a86a commit f492eb2

8 files changed

Lines changed: 144 additions & 8 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ coverage/
2626
# TypeScript build info
2727
*.tsbuildinfo
2828

29-
....
29+
# Lock files — project uses pnpm; npm lock file is not committed
30+
package-lock.json
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Ownership Read Model
2+
3+
The ownership read model is the authoritative source of truth for wallet-level key holdings and per-creator holder lists. It is maintained by the indexer and consumed by API endpoints that return holder counts, balances, and holder lists.
4+
5+
## Table Schema
6+
7+
The ownership read model is stored in the `KeyOwnership` table.
8+
9+
| Field | Type | Description |
10+
|----------------|------------|-----------------------------------------------------------------------------|
11+
| `id` | `String` | Unique record identifier (cuid). |
12+
| `ownerAddress` | `String` | Stellar wallet address of the key holder. |
13+
| `creatorId` | `String` | ID of the creator whose keys are held. |
14+
| `balance` | `Decimal` | Number of keys currently held. Defaults to `0`. Never goes below `0`. |
15+
| `createdAt` | `DateTime` | Timestamp when this ownership record was first created. |
16+
| `updatedAt` | `DateTime` | Timestamp of the most recent balance update (auto-managed by Prisma). |
17+
18+
**Uniqueness constraint:** `(ownerAddress, creatorId)` — one record per wallet per creator.
19+
20+
**Indexes:** `ownerAddress`, `creatorId` — both indexed for efficient lookups by wallet or by creator.
21+
22+
## Update Triggers
23+
24+
The indexer updates the ownership read model in response to three on-chain trade event types:
25+
26+
### Buy
27+
28+
When a wallet purchases keys from a creator:
29+
30+
1. An `upsert` is performed on `(ownerAddress, creatorId)`.
31+
2. `balance` is incremented by the purchased amount.
32+
3. If no record exists, one is created with `balance = purchased amount`.
33+
34+
### Sell
35+
36+
When a wallet sells keys back to a creator:
37+
38+
1. The existing `KeyOwnership` record for `(ownerAddress, creatorId)` is located.
39+
2. `balance` is decremented by the sold amount.
40+
3. If `balance` reaches `0`, the record is retained at `0` (not deleted) to preserve audit history and simplify replay logic.
41+
42+
### Peer-to-Peer Transfer
43+
44+
When a wallet transfers keys directly to another wallet (without going through the bonding curve):
45+
46+
1. The sender's `KeyOwnership` record is decremented by the transferred amount.
47+
2. The recipient's `KeyOwnership` record is incremented by the same amount (upserted if it does not exist).
48+
3. Both updates are applied atomically where possible to prevent intermediate inconsistent states.
49+
50+
## Balance Conservation Invariant
51+
52+
At any point in time, the sum of all `balance` values across every `KeyOwnership` record for a given `creatorId` must equal that creator's total key supply as recorded on-chain:
53+
54+
```
55+
∑ balance(ownerAddress, creatorId) = creatorTotalSupply(creatorId)
56+
```
57+
58+
This invariant must hold after every trade event is processed. Any discrepancy indicates a missed or double-processed event and should trigger a reconciliation replay.
59+
60+
## Replay and Consistency Recovery
61+
62+
If the indexer misses one or more on-chain events (due to a crash, network gap, or RPC timeout), the ownership read model can fall out of sync with the chain state.
63+
64+
**Replay procedure:**
65+
66+
1. The admin replay endpoint (`POST /api/v1/admin/replay`) re-fetches the affected ledger range from the Stellar RPC and re-emits all trade events in order.
67+
2. Each event is processed with idempotency guards: an event with a ledger sequence already recorded is skipped without modifying the read model.
68+
3. After replay completes, the balance conservation invariant is re-validated. If the sum of balances still does not match the on-chain supply, the replay window is widened and the process repeats.
69+
4. Replay is safe to run at any time because all write paths are idempotent — re-processing a seen event produces no side effects.
70+
71+
Gaps detected by the ledger gap detection service (`LedgerGapDetectionService`) are automatically flagged and can trigger a targeted replay without requiring a full historical re-index.

src/modules/indexer/price-snapshot.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export async function upsertPriceSnapshot(event: TradeEventPayload): Promise<voi
4040
lastTradeAt: tradeAt,
4141
},
4242
});
43+
logger.debug(
44+
{
45+
creator_id: creatorId,
46+
new_price: price.toString(),
47+
previous_price: null,
48+
ledger_sequence: null,
49+
written_at: tradeAt.toISOString(),
50+
},
51+
'price-snapshot: written (first trade)'
52+
);
4353
return;
4454
}
4555

@@ -52,6 +62,11 @@ export async function upsertPriceSnapshot(event: TradeEventPayload): Promise<voi
5262
return;
5363
}
5464

65+
// Skip write when price is unchanged.
66+
if (existing.currentPrice.toString() === price.toString()) {
67+
return;
68+
}
69+
5570
// Promote currentPrice → price24hAgo when the snapshot is older than 24 h.
5671
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
5772
const shouldRotate24h =
@@ -65,6 +80,16 @@ export async function upsertPriceSnapshot(event: TradeEventPayload): Promise<voi
6580
lastTradeAt: tradeAt,
6681
},
6782
});
83+
logger.debug(
84+
{
85+
creator_id: creatorId,
86+
new_price: price.toString(),
87+
previous_price: existing.currentPrice.toString(),
88+
ledger_sequence: null,
89+
written_at: tradeAt.toISOString(),
90+
},
91+
'price-snapshot: written'
92+
);
6893
} catch (err) {
6994
logger.error({ err, creatorId }, 'price-snapshot: failed to upsert');
7095
throw err;

src/modules/webhooks/webhook.integration.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,26 +122,38 @@ describe('POST /api/v1/creators/:id/webhooks', () => {
122122
expect(res.status).toBe(400);
123123
});
124124

125-
it('returns 409 when max webhooks reached', async () => {
125+
it('returns 422 when max webhooks reached', async () => {
126126
const existingCount = await prisma.webhook.count({
127127
where: { creatorId, isActive: true },
128128
});
129129

130130
const remaining = envConfig.WEBHOOK_MAX_PER_CREATOR - existingCount;
131131
for (let i = 0; i < remaining; i++) {
132-
await supertest(app)
132+
const res = await supertest(app)
133133
.post(basePath)
134134
.set(authHeaders('POST', basePath, creatorId))
135135
.send({ callback_url: `https://example.com/hook-${i}`, events: ['buy'] });
136+
expect(res.status).toBe(201);
136137
}
137138

139+
const countAtLimit = await prisma.webhook.count({
140+
where: { creatorId, isActive: true },
141+
});
142+
expect(countAtLimit).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR);
143+
138144
const res = await supertest(app)
139145
.post(basePath)
140146
.set(authHeaders('POST', basePath, creatorId))
141147
.send({ callback_url: 'https://example.com/too-many', events: ['buy'] });
142148

143-
expect(res.status).toBe(409);
149+
expect(res.status).toBe(422);
144150
expect(res.body.error.code).toBe('MAX_WEBHOOKS_REACHED');
151+
expect(res.body.error.message).toMatch(/maximum/i);
152+
153+
const countAfter = await prisma.webhook.count({
154+
where: { creatorId, isActive: true },
155+
});
156+
expect(countAfter).toBe(envConfig.WEBHOOK_MAX_PER_CREATOR);
145157
});
146158
});
147159

src/modules/webhooks/webhook.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export async function createWebhook(
2424
new Error(
2525
`Maximum of ${envConfig.WEBHOOK_MAX_PER_CREATOR} active webhooks per creator reached`
2626
),
27-
{ statusCode: 409, code: 'MAX_WEBHOOKS_REACHED' }
27+
{ statusCode: 422, code: 'MAX_WEBHOOKS_REACHED' }
2828
);
2929
}
3030

src/utils/api-response.utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Response } from 'express';
55
import { ZodIssue } from 'zod';
66
import { ErrorCode, ErrorCodeType } from '../constants/error.constants';
77
import { requestContextStorage } from './als.utils';
8+
import { serializeBigInt } from './bigint-serializer.utils';
89

910
/**
1011
* Standard API error response shape.
@@ -128,7 +129,7 @@ export function sendSuccess<T>(
128129
): void {
129130
const body: ApiSuccessResponse<T> = {
130131
success: true,
131-
data,
132+
data: serializeBigInt(data) as T,
132133
...(message ? { message } : {}),
133134
};
134135
res.setHeader('Content-Type', 'application/json');
@@ -147,7 +148,7 @@ export function sendPaginatedSuccess<T>(
147148
): void {
148149
const body: PaginatedResponse<T> = {
149150
success: true,
150-
data,
151+
data: serializeBigInt(data) as T[],
151152
meta,
152153
...(message ? { message } : {}),
153154
};

src/utils/bigint-serializer.utils.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { strict as assert } from 'assert';
2-
import { bigIntReplacer, safeJsonStringify, sanitizeBigInts } from './bigint-serializer.utils';
2+
import { bigIntReplacer, safeJsonStringify, sanitizeBigInts, serializeBigInt } from './bigint-serializer.utils';
33

44
function run() {
55
// bigIntReplacer converts BigInt to string
@@ -36,6 +36,21 @@ function run() {
3636
assert.equal(sanitizeBigInts(42), 42);
3737
assert.equal(sanitizeBigInts('str'), 'str');
3838

39+
// serializeBigInt – top-level BigInt converts to string
40+
assert.equal(serializeBigInt(9007199254740993n), '9007199254740993');
41+
42+
// serializeBigInt – nested BigInt in object converts correctly
43+
const serializedObj = serializeBigInt({ id: 1n, nested: { amount: 500n }, label: 'ok' });
44+
assert.deepEqual(serializedObj, { id: '1', nested: { amount: '500' }, label: 'ok' });
45+
46+
// serializeBigInt – BigInt inside an array converts correctly
47+
assert.deepEqual(serializeBigInt([1n, 2n, 3n]), ['1', '2', '3']);
48+
49+
// serializeBigInt – non-BigInt values pass through unchanged
50+
assert.equal(serializeBigInt(42), 42);
51+
assert.equal(serializeBigInt('hello'), 'hello');
52+
assert.deepEqual(serializeBigInt({ x: 1, y: 'str' }), { x: 1, y: 'str' });
53+
3954
console.log('bigint-serializer.utils tests passed');
4055
}
4156

src/utils/bigint-serializer.utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,14 @@ export function sanitizeBigInts(value: unknown): unknown {
5858
}
5959
return value;
6060
}
61+
62+
/**
63+
* Recursively converts BigInt values to their decimal string representation.
64+
* Alias for `sanitizeBigInts` — use this name when the intent is to prepare
65+
* a value for JSON serialization in API responses.
66+
*
67+
* @example
68+
* serializeBigInt({ amount: 1000000000000000000n });
69+
* // → { amount: "1000000000000000000" }
70+
*/
71+
export const serializeBigInt = sanitizeBigInts;

0 commit comments

Comments
 (0)