Skip to content
Open
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
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ HC_RPC_ENDPOINT=https://rpc.akashnet.net:443
# Pyth contract address on Akash
HC_CONTRACT_ADDRESS=akash1...

# Pyth API key used for price updates and router-set upgrade VAAs
HC_HERMES_API_KEY=your_pyth_api_key

# Wallet mnemonic for signing transactions (12 or 24 words)
# WARNING: Keep this secure! Never commit to version control.
HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic phrase here"
Expand All @@ -19,8 +22,10 @@ HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic phrase here"
# Optional Settings
# ===================

# Pyth Hermes API endpoint (default: https://hermes.pyth.network)
# HC_HERMES_ENDPOINT=https://hermes.pyth.network
# Pyth Hermes API endpoint (default: https://pyth.dourolabs.app/hermes)
# Price updates use /v2/updates/price/latest.
# Router rotations use /v1/guardian_set_upgrade_vaa on the same base URL.
# HC_HERMES_ENDPOINT=https://pyth.dourolabs.app/hermes

# Price deviation tolerance, default=0 (e.g., "100" or "5%")
# HC_PRICE_DEVIATION_TOLERANCE=0
Expand Down
8 changes: 6 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
---
version: 2

release:
prerelease: auto
make_latest: '{{ if .Prerelease }}false{{ else }}true{{ end }}'

builds:
- skip: true

Expand All @@ -10,8 +14,8 @@ dockers_v2:
- "ghcr.io/akash-network/hermes"
tags:
- "{{ .Tag }}"
- "v{{ .Major }}.{{ .Minor }}"
- "latest"
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}{{ end }}"
- "{{ if not .Prerelease }}latest{{ end }}"
dockerfile: Dockerfile
platforms:
- linux/amd64
Expand Down
51 changes: 44 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ cp .env.example .env
Edit `.env`:
```bash
HC_RPC_ENDPOINT=https://rpc.akashnet.net:443
HC_HERMES_ENDPOINT=https://pyth.dourolabs.app/hermes
HC_HERMES_API_KEY=your_pyth_api_key
HC_CONTRACT_ADDRESS=akash1your_contract_address
HC_MNEMONIC="your twelve or twenty four word mnemonic"
HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic"
```

### 3. Run
Expand Down Expand Up @@ -171,10 +173,10 @@ akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_config":{}}'
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `HC_RPC_ENDPOINT` | Yes | - | Akash RPC endpoint |
| `HC_HERMES_API_KEY` | No | - | optional hermes api key. After 31 Jul 2026 it will become required |
| `HC_HERMES_API_KEY` | Yes | - | Pyth API key sent as `Authorization: Bearer` |
| `HC_CONTRACT_ADDRESS` | Yes | - | Oracle contract address |
| `HC_WALLET_SECRET` | Yes | - | Either `privateKey:<private key in hex format>` or `mnemonic:<12/24 words>` |
| `HC_HERMES_ENDPOINT` | No | `https://hermes.pyth.network` | Pyth Hermes API |
| `HC_HERMES_ENDPOINT` | No | `https://pyth.dourolabs.app/hermes` | Upgraded Pyth Hermes API. Price updates and router-set upgrade VAAs use this base URL |
| `HC_PRICE_DEVIATION_TOLERANCE` | No | - | absolute or percentage value for price deviations which should be ignored (e.g., `100` or `10%`). When unset, every price with a newer publish time is submitted |
| `HC_PRICE_FETCHING_METHOD` | No | polling | `polling` or `sse` |
| `HC_PRICE_UPDATE_TX_METHOD` | No | ordered | `ordered` or `unordered` |
Expand Down Expand Up @@ -227,7 +229,7 @@ HC_UPDATE_INTERVAL_MS=600000 # 10 minutes
- Query contract for price_feed_id
↓
2. Fetch price from Pyth Hermes
GET https://hermes.pyth.network/v2/updates/price/latest?ids={price_feed_id}
GET https://pyth.dourolabs.app/hermes/v2/updates/price/latest?ids={price_feed_id}
↓
3. Query current price from contract
↓
Expand All @@ -239,6 +241,29 @@ HC_UPDATE_INTERVAL_MS=600000 # 10 minutes
- Repeat from step 2
```

If the price update fails with `InvalidRouterSetIndex`, the client queries the
`pyth_vaa` address from `pyth_pro.get_config`, fetches the signed router-set
upgrade VAA from:

```bash
GET $HC_HERMES_ENDPOINT/v1/guardian_set_upgrade_vaa
```

The endpoint returns `{ "vaa": "<hex-encoded serialized VAA bytes>" }` when an
upgrade is active and `404 no guardian set upgrade in progress` otherwise. The
client validates the VAA against the current `pyth_vaa` router config, converts
the hex bytes to base64, submits:

```json
{
"submit_v_a_a": {
"vaa": "<base64-encoded serialized VAA bytes>"
}
}
```

to `pyth_vaa`, then retries the original price update.

### Smart Update Logic

The client only submits transactions when:
Expand Down Expand Up @@ -317,12 +342,23 @@ akash tx bank send <FROM> <ORACLE_ADDRESS> 100000000uakt --gas auto
**"Failed to fetch from Hermes"**
```bash
# Test Hermes API
curl "https://hermes.pyth.network/v2/updates/price/latest?ids=<PRICE_FEED_ID>"
curl -H "Authorization: Bearer $HC_HERMES_API_KEY" \
"$HC_HERMES_ENDPOINT/v2/updates/price/latest?ids[]=<PRICE_FEED_ID>"

# Check price feed ID
akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_price_feed_id":{}}'
```

**"InvalidRouterSetIndex"**
```bash
# Check whether Pyth is serving a router-set upgrade VAA
curl -i -H "Authorization: Bearer $HC_HERMES_API_KEY" \
"$HC_HERMES_ENDPOINT/v1/guardian_set_upgrade_vaa"
```

`404 no guardian set upgrade in progress` means Pyth is not serving a rotation
VAA at that moment.

**"Price already up to date"**
- Not an error! Contract already has the latest price
- Client will try again on next interval
Expand All @@ -342,7 +378,8 @@ npm run cli:daemon
curl $HC_RPC_ENDPOINT/status

# Test Hermes
curl "https://hermes.pyth.network/api/latest_price_feeds?ids[]=<FEED_ID>"
curl -H "Authorization: Bearer $HC_HERMES_API_KEY" \
"$HC_HERMES_ENDPOINT/v2/updates/price/latest?ids[]=<FEED_ID>"

# Test contract
akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_config":{}}'
Expand All @@ -356,7 +393,7 @@ For more detailed information:
- **API Reference** - See `src/hermes-client.ts` JSDoc comments
- **CLI Reference** - Run `npm run cli -- --help`
- **Pyth Network** - https://docs.pyth.network/
- **Hermes API** - https://hermes.pyth.network/docs/
- **Hermes API** - https://pyth.dourolabs.app/docs/?urls.primaryName=Hermes%20API

## 🆘 Support

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@cosmjs/proto-signing": "^0.38.0",
"@cosmjs/stargate": "^0.38.0",
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-prometheus": "^0.211.0",
"@opentelemetry/instrumentation-runtime-node": "^0.24.0",
Expand Down
35 changes: 26 additions & 9 deletions src/cli-commands/command-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock("../price-stream/price-sse-stream/price-sse-stream.ts", () => ({
describe("parseConfig", () => {
it("returns error when CONTRACT_ADDRESS is missing", () => {
const result = parseConfig({
HERMES_API_KEY: "secret-token",
WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
});

Expand All @@ -21,7 +22,10 @@ describe("parseConfig", () => {
});

it("returns error when WALLET_SECRET is missing", () => {
const result = parseConfig({ CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu" });
const result = parseConfig({
CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",
HERMES_API_KEY: "secret-token",
});

expect(result.ok).toBe(false);
expect((result as Extract<typeof result, { ok: false }>).error).toContain("WALLET_SECRET");
Expand Down Expand Up @@ -85,6 +89,18 @@ describe("parseConfig", () => {

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.priceProducerFactory).toBeTypeOf("function");
expect((result as Extract<typeof result, { ok: true }>).value.rawConfig.HERMES_ENDPOINT).toBe(
"https://pyth.dourolabs.app/hermes",
);
});

it("ignores legacy PYTH_ROUTER_ENDPOINTS", () => {
const result = parseConfig(validEnv({
PYTH_ROUTER_ENDPOINTS: "https://router-0.example/v1, https://router-1.example/v1,https://router-2.example/v1",
}));

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.rawConfig).not.toHaveProperty("PYTH_ROUTER_ENDPOINTS");
});

it("accepts UPDATE_INTERVAL_MS and produces priceProducerFactory", () => {
Expand Down Expand Up @@ -223,18 +239,18 @@ describe("parseConfig", () => {
vi.clearAllMocks();
});

it("is optional and config parses when it is not provided", () => {
const result = parseConfig(validEnv());
it("returns error when HERMES_API_KEY is not provided", () => {
const result = parseConfig(validEnv({ HERMES_API_KEY: undefined }));

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.rawConfig.HERMES_API_KEY).toBeUndefined();
expect(result.ok).toBe(false);
expect((result as Extract<typeof result, { ok: false }>).error).toContain("HERMES_API_KEY");
});

it("parses config when HERMES_API_KEY is provided", () => {
const result = parseConfig(validEnv({ HERMES_API_KEY: "secret-token" }));
const result = parseConfig(validEnv({ HERMES_API_KEY: "other-token" }));

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.rawConfig.HERMES_API_KEY).toBe("secret-token");
expect((result as Extract<typeof result, { ok: true }>).value.rawConfig.HERMES_API_KEY).toBe("other-token");
});

it("passes authenticationToken to pollPriceStream when method is polling", () => {
Expand All @@ -259,21 +275,22 @@ describe("parseConfig", () => {
);
});

it("passes undefined authenticationToken when HERMES_API_KEY is not set", () => {
it("passes authenticationToken to pollPriceStream from the required API key", () => {
const result = parseConfig(validEnv());

expect(result.ok).toBe(true);
(result as Extract<typeof result, { ok: true }>).value.priceProducerFactory({ priceFeedId: "abc123" });

expect(pollPriceStream).toHaveBeenCalledWith(
expect.objectContaining({ authenticationToken: undefined }),
expect.objectContaining({ authenticationToken: "secret-token" }),
);
});
});

function validEnv(overrides: Record<string, string | undefined> = {}) {
return {
CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",
HERMES_API_KEY: "secret-token",
WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
...overrides,
};
Expand Down
17 changes: 15 additions & 2 deletions src/cli-commands/command-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { PriceProducerFactoryOptions } from "../types.ts";
import { pollPriceStream } from "../price-stream/polling-price-stream/polling-price-stream.ts";
import { priceSSEStream } from "../price-stream/price-sse-stream/price-sse-stream.ts";
import { ContractClientService, type SigningClientServiceConfig } from "../services/contract-client/contract-client.service.ts";
import { DEFAULT_HERMES_ENDPOINT, RouterSetUpdater } from "../services/router-set-updater/router-set-updater.ts";

export interface CommandConfig extends HermesConfig {
createHermesClient: (config: HermesConfig) => HermesClient;
Expand All @@ -17,8 +18,8 @@ export interface CommandConfig extends HermesConfig {

const configSchema = z.object({
RPC_ENDPOINT: z.url().default("https://rpc.akashnet.net:443"),
HERMES_ENDPOINT: z.url().default("https://hermes.pyth.network"),
HERMES_API_KEY: z.string().optional(),
HERMES_ENDPOINT: z.url().default(DEFAULT_HERMES_ENDPOINT),
HERMES_API_KEY: z.string().nonempty(),
CONTRACT_ADDRESS: z.string().nonempty().superRefine(propagateError(validateContractAddress)),
WALLET_SECRET: z.string()
.regex(/^(mnemonic|privateKey):.+$/, { message: 'must be in the format "mnemonic:<12/24 word phrase>" or "privateKey:<hex format>"' })
Expand Down Expand Up @@ -69,6 +70,17 @@ export function parseConfig(config: Record<string, string | undefined>): ParseCo
}

const unsafeAllowInsecureEndpoints = result.data.NODE_ENV === "development"; // Enforce secure endpoints in production
let routerSetUpdater: RouterSetUpdater;
try {
routerSetUpdater = new RouterSetUpdater({
endpoint: result.data.HERMES_ENDPOINT,
authenticationToken: result.data.HERMES_API_KEY,
unsafeAllowInsecureEndpoints,
});
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "Invalid HERMES_ENDPOINT" };
}

const parsedConfig: ParsedConfig = {
rawConfig: result.data,
unsafeAllowInsecureEndpoints,
Expand All @@ -84,6 +96,7 @@ export function parseConfig(config: Record<string, string | undefined>): ParseCo
insufficientBalanceRetryDelayMs: result.data.INSUFFICIENT_BALANCE_RETRY_DELAY_MS,
priceUpdateTxMethod: result.data.PRICE_UPDATE_TX_METHOD,
unorderedTxTtlMs: result.data.UNORDERED_TX_TTL_MS,
routerSetUpdater,
priceProducerFactory(options: PriceProducerFactoryOptions) {
if (result.data.PRICE_FETCHING_METHOD === "sse") {
return priceSSEStream({
Expand Down
1 change: 1 addition & 0 deletions src/cli-commands/create-command-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ function setup(env?: Record<string, string>) {
const mockProcess = Object.assign(new EventEmitter(), {
env: env ?? {
HC_CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",
HC_HERMES_API_KEY: "secret-token",
HC_WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
},
exit: vi.fn(),
Expand Down
4 changes: 2 additions & 2 deletions src/cli-commands/query-command/query-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ describe("queryCommand", () => {
admin: "akash1admin",
update_fee: "100",
price_feed_id: "feed-123",
pyth_vaa_contract: "akash1vaa",
default_denom: "uakt",
default_base_denom: "akt",
wormhole_contract: "akash1wormhole",
data_sources: [],
});

await queryCommand(config, { config: true });
Expand All @@ -38,6 +37,7 @@ describe("queryCommand", () => {
expect(logger.log).toHaveBeenCalledWith("Admin: akash1admin");
expect(logger.log).toHaveBeenCalledWith("Update Fee: 100");
expect(logger.log).toHaveBeenCalledWith("Price Feed ID: feed-123");
expect(logger.log).toHaveBeenCalledWith("Pyth VAA: akash1vaa");
expect(logger.log).toHaveBeenCalledWith("Default Denom: uakt");
expect(logger.log).toHaveBeenCalledWith("Base Denom: akt");
});
Expand Down
1 change: 1 addition & 0 deletions src/cli-commands/query-command/query-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function queryCommand(config: CommandConfig, options: QueryOptions)
config.logger?.log(`Admin: ${cfg.admin}`);
config.logger?.log(`Update Fee: ${cfg.update_fee}`);
config.logger?.log(`Price Feed ID: ${cfg.price_feed_id}`);
config.logger?.log(`Pyth VAA: ${cfg.pyth_vaa_contract}`);
config.logger?.log(`Default Denom: ${cfg.default_denom}`);
config.logger?.log(`Base Denom: ${cfg.default_base_denom}`);
return;
Expand Down
8 changes: 4 additions & 4 deletions src/cli-commands/status-command/status-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function setup() {
rpcEndpoint: "https://rpc.akashnet.net:443",
contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",
rawConfig: {
HERMES_ENDPOINT: "https://hermes.pyth.network",
HERMES_ENDPOINT: "https://pyth.dourolabs.app/hermes",
},
logger,
createHermesClient: vi.fn(() => client),
Expand Down Expand Up @@ -64,12 +64,12 @@ describe("statusCommand", () => {
await statusCommand(config);

expect(logger.log).toHaveBeenCalledWith("RPC Endpoint: https://rpc.akashnet.net:443");
expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://hermes.pyth.network");
expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://pyth.dourolabs.app/hermes");
});

it("uses default Hermes endpoint when not configured", async () => {
const { config, client, logger } = setup();
(config.rawConfig as Record<string, unknown>).HERMES_ENDPOINT = "https://hermes.pyth.network";
(config.rawConfig as Record<string, unknown>).HERMES_ENDPOINT = "https://pyth.dourolabs.app/hermes";
client.getStatus.mockResolvedValueOnce({
address: "akash1sender",
contractAddress: "akash1contract",
Expand All @@ -79,6 +79,6 @@ describe("statusCommand", () => {

await statusCommand(config);

expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://hermes.pyth.network");
expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://pyth.dourolabs.app/hermes");
});
});
Loading
Loading