Skip to content

Commit 6d8f1d9

Browse files
authored
Merge pull request #1026 from openmina/feat/hearbeat-improvements
feat(heartbeat): Fixes and improvements
2 parents 4080095 + 56e15e9 commit 6d8f1d9

File tree

8 files changed

+105
-76
lines changed

8 files changed

+105
-76
lines changed

frontend/functions/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules/
22
*.local
33
coverage/
4+
lib/

frontend/functions/__tests__/signature.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { validateSignature } = require('../index');
1+
const { validateSignature } = require('../src/index');
22

33
describe('validateSignature', () => {
44
let signedHeartbeat;

frontend/functions/build.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const keysFilePath = path.resolve(__dirname, 'allowed_keys.txt');
5+
let keys = [];
6+
7+
if (fs.existsSync(keysFilePath)) {
8+
keys = fs.readFileSync(keysFilePath, 'utf-8')
9+
.split('\n')
10+
.map(key => key.trim())
11+
.filter(key => key.length > 0);
12+
13+
const validatorFilePath = path.resolve(__dirname, 'functions/submitterValidator.ts');
14+
let validatorFileContent = fs.readFileSync(validatorFilePath, 'utf-8');
15+
16+
const keysSetString = keys.map(key => `'${key}'`).join(',\n ');
17+
validatorFileContent = validatorFileContent.replace(
18+
'// ALLOWED_PUBLIC_KEYS_PLACEHOLDER',
19+
keysSetString,
20+
);
21+
22+
fs.writeFileSync(validatorFilePath, validatorFileContent);
23+
} else {
24+
console.warn('allowed_keys.txt not found. All submitters will be allowed.');
25+
}

frontend/functions/package-lock.json

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

frontend/functions/package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,26 @@
22
"name": "functions",
33
"description": "Cloud Functions for Firebase",
44
"scripts": {
5-
"serve": "firebase emulators:start --only functions",
5+
"serve": "npm run build && firebase emulators:start --only functions,firestore",
66
"shell": "firebase functions:shell",
7-
"start": "npm run shell",
7+
"start": "npm run build && node lib/index.js",
88
"deploy": "firebase deploy --only functions",
99
"logs": "firebase functions:log",
1010
"test": "jest",
1111
"test:watch": "jest --watch",
12-
"build": "tsc",
12+
"build": "node build.js && tsc -p tsconfig.json",
1313
"build:watch": "tsc --watch"
1414
},
1515
"engines": {
16-
"node": "18"
16+
"node": "22"
1717
},
18-
"main": "index.ts",
18+
"type": "commonjs",
19+
"main": "lib/index.js",
1920
"dependencies": {
2021
"blake2": "^5.0.0",
2122
"bs58check": "^3.0.1",
2223
"firebase-admin": "^12.1.0",
23-
"firebase-functions": "^5.0.0",
24+
"firebase-functions": "^6.2.0",
2425
"mina-signer": "^3.0.7"
2526
},
2627
"devDependencies": {
Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,32 @@ import * as blake2 from 'blake2';
44
import bs58check from 'bs58check';
55
import Client from 'mina-signer';
66
import { submitterAllowed } from './submitterValidator';
7+
import { CallableRequest, onCall } from 'firebase-functions/v2/https';
8+
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
79

810
interface SignatureJson {
911
field: string;
1012
scalar: string;
1113
}
1214

1315
interface HeartbeatData {
14-
publicKey: string;
15-
data: string;
16+
version: number;
17+
payload: string;
18+
submitter: string;
1619
signature: SignatureJson;
1720
}
1821

1922
const minaClient = new Client({ network: 'testnet' });
2023

2124
admin.initializeApp();
2225

26+
// Rate limit duration between heartbeats from the same submitter (15 seconds)
27+
const HEARTBEAT_RATE_LIMIT_MS = 15000;
28+
2329
function validateSignature(
2430
data: string,
2531
signature: SignatureJson,
26-
publicKeyBase58: string
32+
publicKeyBase58: string,
2733
): boolean {
2834
try {
2935
const h = blake2.createHash('blake2b', { digestLength: 32 });
@@ -62,49 +68,55 @@ function validateSignature(
6268
}
6369
}
6470

65-
export const handleValidationAndStore = functions
66-
.region('us-central1')
67-
.https.onCall(async (data: HeartbeatData, context: functions.https.CallableContext) => {
68-
console.log('Received data:', data);
69-
const { publicKey, data: inputData, signature } = data;
71+
export const handleValidationAndStore = onCall(
72+
{ region: 'us-central1', enforceAppCheck: false },
73+
async (request: CallableRequest<HeartbeatData>) => {
74+
console.log('Received data:', request.data);
75+
const data = request.data;
76+
const { submitter, payload, signature } = data;
7077

71-
if (!submitterAllowed(publicKey)) {
78+
if (!submitterAllowed(submitter)) {
7279
throw new functions.https.HttpsError(
7380
'permission-denied',
74-
'Public key not authorized'
81+
'Public key not authorized',
7582
);
7683
}
7784

78-
const rateLimitRef = admin.firestore().collection('publicKeyRateLimits').doc(publicKey);
85+
const db = getFirestore();
7986

8087
try {
81-
await admin.firestore().runTransaction(async (transaction) => {
88+
if (!validateSignature(payload, signature, submitter)) {
89+
throw new functions.https.HttpsError(
90+
'unauthenticated',
91+
'Signature validation failed',
92+
);
93+
}
94+
95+
const rateLimitRef = db.collection('publicKeyRateLimits').doc(submitter);
96+
const newHeartbeatRef = db.collection('heartbeats').doc();
97+
98+
await db.runTransaction(async (transaction) => {
8299
const doc = await transaction.get(rateLimitRef);
83100
const now = Date.now();
84-
const cutoff = now - 15 * 1000;
101+
const cutoff = now - HEARTBEAT_RATE_LIMIT_MS;
85102

86103
if (doc.exists) {
87-
const lastCall = doc.data()?.lastCall;
104+
const lastCall = doc.data()?.['lastCall'];
88105
if (lastCall > cutoff) {
89106
throw new functions.https.HttpsError(
90107
'resource-exhausted',
91-
'Rate limit exceeded for this public key'
108+
'Rate limit exceeded for this public key',
92109
);
93110
}
94111
}
95112

96-
transaction.set(rateLimitRef, { lastCall: now }, { merge: true });
113+
transaction.set(rateLimitRef, { lastCall: FieldValue.serverTimestamp() }, { merge: true });
114+
transaction.create(newHeartbeatRef, {
115+
...data,
116+
createTime: FieldValue.serverTimestamp(),
117+
});
97118
});
98119

99-
if (!validateSignature(inputData, signature, publicKey)) {
100-
throw new functions.https.HttpsError(
101-
'unauthenticated',
102-
'Signature validation failed'
103-
);
104-
}
105-
106-
await admin.firestore().collection('heartbeat').add(data);
107-
108120
return { message: 'Data validated and stored successfully' };
109121
} catch (error) {
110122
console.error('Error during data validation and storage:', error);
@@ -113,9 +125,10 @@ export const handleValidationAndStore = functions
113125
}
114126
throw new functions.https.HttpsError(
115127
'internal',
116-
'An error occurred during validation or storage'
128+
'An error occurred during validation or storage',
117129
);
118130
}
119-
});
131+
},
132+
);
120133

121134
export { validateSignature };

frontend/functions/submitterValidator.ts renamed to frontend/functions/src/submitterValidator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// base58 encoded public keys that are allowed to submit data
22
const allowedPublicKeys: Set<string> = new Set([
3+
// ALLOWED_PUBLIC_KEYS_PLACEHOLDER
34
]);
45

56
export function submitterAllowed(publicKeyBase58: string): boolean {

frontend/functions/tsconfig.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
{
22
"compilerOptions": {
3-
"module": "commonjs",
3+
"target": "ES2019",
4+
"module": "CommonJS",
5+
"moduleResolution": "Node",
46
"noImplicitReturns": true,
57
"noUnusedLocals": true,
68
"outDir": "lib",
79
"sourceMap": true,
810
"strict": true,
9-
"target": "es2017",
10-
"esModuleInterop": true
11+
"esModuleInterop": true,
12+
"skipLibCheck": true,
13+
"allowSyntheticDefaultImports": true
1114
},
1215
"compileOnSave": true,
1316
"include": [
14-
"src"
17+
"src/**/*"
18+
],
19+
"exclude": [
20+
"build.js",
21+
"jest.config.js"
22+
],
23+
"types": [
24+
"node",
25+
"jest"
1526
]
1627
}

0 commit comments

Comments
 (0)