Skip to content

Commit f1492ae

Browse files
committed
Use native code for validating VAAs
1 parent 1c529dd commit f1492ae

File tree

6 files changed

+166
-16
lines changed

6 files changed

+166
-16
lines changed

package-lock.json

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

price_service/server/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
"license": "Apache-2.0",
1818
"devDependencies": {
1919
"@types/jest": "^29.4.0",
20+
"@types/keccak": "^3.0.1",
2021
"@types/long": "^4.0.1",
2122
"@types/node": "^16.6.1",
2223
"@types/node-fetch": "^2.6.2",
24+
"@types/secp256k1": "^4.0.3",
2325
"@types/supertest": "^2.0.12",
2426
"jest": "^29.4.0",
2527
"prettier": "^2.3.2",
@@ -41,16 +43,17 @@
4143
"@types/ws": "^8.5.3",
4244
"cors": "^2.8.5",
4345
"dotenv": "^10.0.0",
44-
"ethers": "^5.4.4",
4546
"express": "^4.17.2",
4647
"express-validation": "^4.0.1",
4748
"http-status-codes": "^2.2.0",
4849
"joi": "^17.6.0",
50+
"keccak": "^3.0.3",
4951
"lru-cache": "^7.14.1",
5052
"morgan": "^1.10.0",
5153
"node-fetch": "^2.6.1",
5254
"prom-client": "^14.0.1",
5355
"response-time": "^2.3.2",
56+
"secp256k1": "^5.0.0",
5457
"ts-retry-promise": "^0.7.0",
5558
"winston": "^3.3.3",
5659
"ws": "^8.12.0"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { isValidVaa } from "../vaa";
2+
import { GuardianSignature, parseVaa } from "@certusone/wormhole-sdk";
3+
import { randomBytes } from "crypto";
4+
5+
const VAA = Buffer.from(
6+
"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",
7+
"base64"
8+
);
9+
10+
describe("VAA validation works", () => {
11+
test("with valid signatures", async () => {
12+
let parsedVaa = parseVaa(VAA);
13+
14+
expect(isValidVaa(parsedVaa, "mainnet")).toBe(true);
15+
});
16+
17+
test("with a wrong address", async () => {
18+
let parsedVaa = parseVaa(VAA);
19+
const vaaIndex = 8;
20+
const setIndex1 = 4;
21+
const setIndex2 = 5;
22+
23+
// Replace the signature from guardian at setIndex1 with the one from
24+
// setIndex2.
25+
parsedVaa.guardianSignatures[vaaIndex] = {
26+
index: setIndex1,
27+
signature: parsedVaa.guardianSignatures[setIndex2].signature,
28+
};
29+
30+
expect(isValidVaa(parsedVaa, "mainnet")).toBe(false);
31+
});
32+
33+
test("with an invalid signature", async () => {
34+
let parsedVaa = parseVaa(VAA);
35+
const vaaIndex = 8;
36+
const setIndex = 4;
37+
38+
// Inject a random buffer as the signature of the guardian at setIndex.
39+
parsedVaa.guardianSignatures[vaaIndex] = {
40+
index: setIndex,
41+
signature: randomBytes(65), // invalid signature
42+
};
43+
44+
expect(isValidVaa(parsedVaa, "mainnet")).toBe(false);
45+
});
46+
});

price_service/server/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Listener } from "./listen";
33
import { initLogger } from "./logging";
44
import { PromClient } from "./promClient";
55
import { RestAPI } from "./rest";
6-
import { wormholeClusterFromString } from "./vaa";
76
import { WebSocketAPI } from "./ws";
87

98
let configFile: string = ".env";

price_service/server/src/listen.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
PriceAttestation,
1818
} from "@pythnetwork/wormhole-attester-sdk";
1919
import { HexString, PriceFeed } from "@pythnetwork/price-service-sdk";
20-
import { ethers } from "ethers";
2120
import LRUCache from "lru-cache";
2221
import { DurationInSec, sleep, TimestampInSec } from "./helpers";
2322
import { logger } from "./logging";

price_service/server/src/vaa.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { logger } from "./logging";
12
import { ParsedVaa } from "@certusone/wormhole-sdk";
23
import { GuardianSet } from "@certusone/wormhole-spydk/lib/cjs/proto/publicrpc/v1/publicrpc";
3-
import { ethers } from "ethers";
4+
import * as secp256k1 from "secp256k1";
5+
import * as keccak from "keccak";
46

57
const WormholeClusters = ["localnet", "testnet", "mainnet"] as const;
68
export type WormholeCluster = typeof WormholeClusters[number];
@@ -58,16 +60,59 @@ export function isValidVaa(vaa: ParsedVaa, cluster: WormholeCluster): boolean {
5860
return false;
5961
}
6062

61-
const digest = ethers.utils.keccak256(vaa.hash);
63+
// It's not possible to call a signature verification function directly
64+
// because we only have the addresses of the guardians and not their public
65+
// keys. Instead, we compare the address extracted from the public key that
66+
// signed the VAA with the corresponding address stored in the guardian set.
6267

63-
let validVaa = true;
64-
vaa.guardianSignatures.forEach((sig) => {
65-
if (
66-
ethers.utils.recoverAddress(digest, sig.signature) !==
67-
currentGuardianSet.addresses[sig.index]
68-
)
69-
validVaa = false;
70-
});
68+
const messageHash = keccak.default("keccak256").update(vaa.hash).digest();
69+
let counter = 0;
7170

72-
return validVaa;
71+
try {
72+
vaa.guardianSignatures.forEach((sig) => {
73+
// Each signature is a 65-byte secp256k1 signature with the recovery ID at
74+
// the last byte. It is not the compact representation from EIP-2098.
75+
const recoveryID = sig.signature[64] % 2;
76+
const signature = sig.signature.slice(0, 64);
77+
const publicKey = Buffer.from(
78+
secp256k1.ecdsaRecover(signature, recoveryID, messageHash, false)
79+
);
80+
// The first byte of the public key is the prefix (0x03 or 0x04)
81+
// indicating if the public key is compressed. Remove it before hashing.
82+
const publicKeyHash = keccak
83+
.default("keccak256")
84+
.update(publicKey.slice(1))
85+
.digest();
86+
// The last 20 bytes of the hash are the address.
87+
const address = publicKeyHash.slice(-20).toString("hex");
88+
89+
if (
90+
checksumAddress(address) === currentGuardianSet.addresses[sig.index]
91+
) {
92+
counter++;
93+
}
94+
});
95+
96+
return counter === vaa.guardianSignatures.length;
97+
} catch (error) {
98+
logger.warn("Error validating VAA signatures:", error);
99+
100+
return false;
101+
}
102+
}
103+
104+
function checksumAddress(address: string) {
105+
address = address.toLowerCase().replace("0x", "");
106+
const hash = keccak.default("keccak256").update(address).digest("hex");
107+
let ret = "0x";
108+
109+
for (let i = 0; i < address.length; i++) {
110+
if (parseInt(hash[i], 16) >= 8) {
111+
ret += address[i].toUpperCase();
112+
} else {
113+
ret += address[i];
114+
}
115+
}
116+
117+
return ret;
73118
}

0 commit comments

Comments
 (0)