Skip to content

Commit 8afe29c

Browse files
authored
Merge pull request Junirezz#469 from UzyKhs/fix/440-442-443-445-backend-export-webhook-prisma-polling
Fix backend export, webhook verification, Prisma metrics, and polling tests
2 parents 3c93ca9 + bbeeffb commit 8afe29c

20 files changed

Lines changed: 988 additions & 49 deletions

backend/docs/WEBHOOK_SIGNATURES.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Webhook Signature Verification
2+
3+
YieldVault signs outbound webhook payloads with `HMAC-SHA256` whenever a webhook endpoint is configured with a secret.
4+
5+
## Signature Contract
6+
7+
- Header: `X-YieldVault-Signature`
8+
- Algorithm: `HMAC-SHA256`
9+
- Encoding: lowercase hexadecimal
10+
- Input: the exact JSON request body sent by YieldVault
11+
12+
For a delivery body like:
13+
14+
```json
15+
{
16+
"eventType": "transaction.deposit.created",
17+
"sentAt": "2026-05-26T00:00:00.000Z",
18+
"payload": {
19+
"transactionId": "tx_123",
20+
"amount": "125.00",
21+
"asset": "USDC",
22+
"walletAddress": "G...",
23+
"transactionHash": "0xabc",
24+
"status": "pending",
25+
"timestamp": "2026-05-26T00:00:00.000Z"
26+
}
27+
}
28+
```
29+
30+
Compute the signature from the raw body bytes using your shared secret:
31+
32+
```ts
33+
import crypto from 'crypto';
34+
35+
function verifyYieldVaultWebhook(rawBody: string, secret: string, providedSignature: string) {
36+
const expectedSignature = crypto
37+
.createHmac('sha256', secret)
38+
.update(rawBody)
39+
.digest('hex');
40+
41+
return crypto.timingSafeEqual(
42+
Buffer.from(providedSignature, 'utf8'),
43+
Buffer.from(expectedSignature, 'utf8'),
44+
);
45+
}
46+
```
47+
48+
## Test Endpoint
49+
50+
Use `POST /webhooks/verify` before going live to confirm your secret produces the expected signature.
51+
52+
Request body:
53+
54+
```json
55+
{
56+
"secret": "your-shared-secret",
57+
"payload": {
58+
"eventType": "transaction.deposit.created",
59+
"payload": {
60+
"transactionId": "tx_123"
61+
}
62+
},
63+
"signature": "optional-signature-to-verify"
64+
}
65+
```
66+
67+
Response body:
68+
69+
```json
70+
{
71+
"algorithm": "HMAC-SHA256",
72+
"signature": "computed-hex-signature",
73+
"verified": true
74+
}
75+
```
76+
77+
If you omit `signature`, YieldVault returns the computed signature and sets `verified` to `null`.

backend/package-lock.json

Lines changed: 99 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@
3232
"@opentelemetry/sdk-node": "^0.218.0",
3333
"@opentelemetry/semantic-conventions": "^1.41.1",
3434
"@prisma/client": "^5.10.0",
35+
"@prisma/instrumentation": "^7.8.0",
3536
"cors": "^2.8.6",
3637
"decimal.js": "^10.6.0",
3738
"dotenv": "^16.3.1",
3839
"express": "^4.18.2",
3940
"express-rate-limit": "^7.0.0",
41+
"fast-check": "^4.8.0",
4042
"ioredis": "^5.10.1",
4143
"node-cache": "^5.1.2",
4244
"prom-client": "^15.1.3",

backend/src/__tests__/adminFeatures.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,23 @@ describe('Admin backend features', () => {
2525
status: 200,
2626
text: async () => 'ok',
2727
} as Response);
28+
const secret = 'super-secret-webhook-key';
2829

2930
const webhookResponse = await request(app)
3031
.post('/admin/webhooks')
3132
.set(authHeader)
3233
.send({
3334
url: 'https://example.com/webhook',
3435
eventTypes: ['transaction.deposit.created'],
36+
secret,
3537
});
3638

3739
expect(webhookResponse.status).toBe(201);
40+
expect(webhookResponse.body.endpoint.secret).toBeUndefined();
41+
expect(webhookResponse.body.endpoint.hasSecret).toBe(true);
42+
43+
const previousAllowlistEnabled = process.env.ALLOWLIST_ENABLED;
44+
process.env.ALLOWLIST_ENABLED = 'false';
3845

3946
const depositResponse = await request(app)
4047
.post('/api/v1/vault/deposits')
@@ -44,6 +51,12 @@ describe('Admin backend features', () => {
4451
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567',
4552
});
4653

54+
if (typeof previousAllowlistEnabled === 'string') {
55+
process.env.ALLOWLIST_ENABLED = previousAllowlistEnabled;
56+
} else {
57+
delete process.env.ALLOWLIST_ENABLED;
58+
}
59+
4760
expect(depositResponse.status).toBe(201);
4861

4962
await new Promise((resolve) => setTimeout(resolve, 30));
@@ -55,10 +68,58 @@ describe('Admin backend features', () => {
5568
expect(deliveriesResponse.status).toBe(200);
5669
expect(deliveriesResponse.body.deliveries.length).toBeGreaterThan(0);
5770
expect(deliveriesResponse.body.deliveries[0].eventType).toBe('transaction.deposit.created');
71+
expect(fetchMock).toHaveBeenCalledWith(
72+
'https://example.com/webhook',
73+
expect.objectContaining({
74+
headers: expect.objectContaining({
75+
'X-YieldVault-Signature': expect.any(String),
76+
}),
77+
}),
78+
);
79+
80+
const listResponse = await request(app)
81+
.get('/admin/webhooks')
82+
.set(authHeader);
83+
84+
expect(listResponse.status).toBe(200);
85+
expect(listResponse.body.endpoints[0].secret).toBeUndefined();
86+
expect(listResponse.body.endpoints[0].hasSecret).toBe(true);
5887

5988
fetchMock.mockRestore();
6089
});
6190

91+
it('verifies webhook signatures without exposing secrets', async () => {
92+
const payload = {
93+
eventType: 'transaction.deposit.created',
94+
payload: {
95+
transactionId: 'tx-verify-1',
96+
},
97+
};
98+
99+
const signatureResponse = await request(app)
100+
.post('/webhooks/verify')
101+
.send({
102+
secret: 'verify-secret',
103+
payload,
104+
});
105+
106+
expect(signatureResponse.status).toBe(200);
107+
expect(signatureResponse.body.algorithm).toBe('HMAC-SHA256');
108+
expect(signatureResponse.body.signature).toHaveLength(64);
109+
expect(signatureResponse.body.verified).toBeNull();
110+
111+
const verificationResponse = await request(app)
112+
.post('/webhooks/verify')
113+
.send({
114+
secret: 'verify-secret',
115+
payload,
116+
signature: signatureResponse.body.signature,
117+
});
118+
119+
expect(verificationResponse.status).toBe(200);
120+
expect(verificationResponse.body.verified).toBe(true);
121+
});
122+
62123
it('returns audit logs for admin actions', async () => {
63124
const cacheStats = await request(app)
64125
.get('/admin/cache/stats')

0 commit comments

Comments
 (0)