Skip to content

Commit 525e53d

Browse files
committed
feat: adds PRICE_UPDATE_METHOD config and fire-and-forget strategy
1 parent 8bb2645 commit 525e53d

7 files changed

Lines changed: 292 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ akash query wasm contract-state smart $CONTRACT_ADDRESS '{"get_config":{}}'
176176
| `HERMES_ENDPOINT` | No | `https://hermes.pyth.network` | Pyth Hermes API |
177177
| `PRICE_DEVIATION_TOLERANCE` | No | 0 | absolute or percentage value for price deviations which should be ignored (e.g., `100` or `10%`) |
178178
| `PRICE_FETCHING_METHOD` | No | polling | `polling` or `sse` |
179-
| `UPDATE_INTERVAL_MS` | No | `5000` | Update interval (default 5 sec) |
179+
| `PRICE_UPDATE_METHOD` | No | confirmed-tx | `confirmed-tx` or `fire-and-forget` |
180+
| `UPDATE_INTERVAL_MS` | No | `5000` | Update interval (default 5 sec, applies only when `PRICE_FETCHING_METHOD` = "polling") |
180181
| `GAS_PRICE` | No | `0.025uakt` | Gas price |
181182
| `DENOM` | No | `uakt` | Token denomination |
182183
| `HEALTHCHECK_PORT` | No | 3000 | healthcheck server port |

src/cli-commands/command-config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { validateContractAddress, validateWalletSecret } from "../validation.ts"
44
import type { PriceProducerFactoryOptions } from "../types.ts";
55
import { pollPriceStream } from "../price-stream/polling-price-stream/polling-price-stream.ts";
66
import { priceSSEStream } from "../price-stream/price-sse-stream/price-sse-stream.ts";
7+
import { PriceUpdateConfirmed } from "../price-update/price-update-confirmed/price-update-confirmed.ts";
8+
import { PriceUpdateFireAndForget } from "../price-update/price-update-fire-and-forget/price-update-fire-and-forget.ts";
79

810
export interface CommandConfig extends HermesConfig {
911
createHermesClient: (config: HermesConfig) => Promise<HermesClient>;
@@ -44,6 +46,7 @@ const configSchema = z.object({
4446
}
4547
}).optional(),
4648
PRICE_FETCHING_METHOD: z.enum(["polling", "sse"]).default("polling"),
49+
PRICE_UPDATE_METHOD: z.enum(["confirmed-tx", "fire-and-forget"]).default("confirmed-tx"),
4750
UPDATE_INTERVAL_MS: z.coerce.number().int().nonnegative().default(5 * 1000), // Default to 5 seconds
4851
HEALTHCHECK_PORT: z.coerce.number().int().min(1).max(65535).default(3000),
4952
GAS_PRICE: z.string().regex(/^(\d+)(\.\d+)?uakt$/, { message: 'GAS_PRICE must be a valid number with unit (e.g., "0.025uakt")' }).default("0.025uakt"),
@@ -90,6 +93,12 @@ export function parseConfig(config: Record<string, string | undefined>): ParseCo
9093
pollingIntervalMs: result.data.UPDATE_INTERVAL_MS,
9194
});
9295
},
96+
priceUpdaterFactory(client, signer) {
97+
if (result.data.PRICE_UPDATE_METHOD === "fire-and-forget") {
98+
return new PriceUpdateFireAndForget(client, signer);
99+
}
100+
return new PriceUpdateConfirmed(client);
101+
},
93102
createHermesClient: (cfg: HermesConfig) => HermesClient.connect(cfg),
94103
};
95104

src/hermes-client.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
33
import { mock } from "vitest-mock-extended";
44
import { HermesClient, HermesConfig, classifyError } from "./hermes-client";
55
import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts";
6+
import { PriceUpdateConfirmed } from "./price-update/price-update-confirmed/price-update-confirmed.ts";
67
import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "./types.ts";
78

89
// ============================================================
@@ -1039,6 +1040,7 @@ function setup(input?: Partial<HermesConfig> & {
10391040
unsafeAllowInsecureEndpoints: input?.unsafeAllowInsecureEndpoints,
10401041
priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 },
10411042
priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory,
1043+
priceUpdaterFactory: input?.priceUpdaterFactory ?? ((client) => new PriceUpdateConfirmed(client)),
10421044
smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000,
10431045
insufficientBalanceRetryDelayMs: input?.insufficientBalanceRetryDelayMs,
10441046
});

src/hermes-client.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
1515
import { DirectSecp256k1HdWallet, DirectSecp256k1Wallet, type OfflineDirectSigner } from "@cosmjs/proto-signing";
1616
import { GasPrice } from "@cosmjs/stargate";
17-
import { priceUpdateCounter, blockchainPriceStaleness } from "./metrics.ts";
17+
import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts";
1818
import { latestValue } from "./price-stream/latest-value/latest-value.ts";
19-
import { PriceUpdateConfirmed } from "./price-update/price-update-confirmed/price-update-confirmed.ts";
2019
import type { Logger, PriceProducerFactory, PriceUpdate, PriceUpdater, PythPriceData } from "./types.ts";
2120
import {
2221
sanitizeErrorMessage,
@@ -67,6 +66,11 @@ export interface HermesConfig {
6766
* This allows for different implementations of price fetching logic (e.g. polling, SSE).
6867
*/
6968
priceProducerFactory: PriceProducerFactory;
69+
70+
/**
71+
* Factory function to create a PriceUpdater instance for submitting price updates to the chain.
72+
*/
73+
priceUpdaterFactory: (client: SigningCosmWasmClient, signer: OfflineDirectSigner) => PriceUpdater;
7074
/**
7175
* Optional logger for informational messages. Should implement log, error, and warn methods.
7276
*/
@@ -157,7 +161,9 @@ export class HermesClient {
157161
#cosmClient?: SigningCosmWasmClient;
158162
#wallet?: OfflineDirectSigner;
159163
#senderAddress?: string;
160-
readonly #config: Required<Omit<HermesConfig, "fetch" | "logger" | "connectWithSigner">>;
164+
readonly #config: Required<Omit<HermesConfig, "fetch" | "logger" | "connectWithSigner" | "gasPrice">> & {
165+
gasPrice: GasPrice;
166+
};
161167
#isRunning = false;
162168
#insufficientBalanceCooldownUntil: number | null = null;
163169
#lastPriceReceivedAt?: string;
@@ -186,7 +192,7 @@ export class HermesClient {
186192
this.#config = {
187193
...config,
188194
denom: config.denom ?? "uakt",
189-
gasPrice: config.gasPrice ?? "0.025uakt",
195+
gasPrice: GasPrice.fromString(config.gasPrice ?? "0.025uakt"),
190196
unsafeAllowInsecureEndpoints,
191197
priceDeviationTolerance: config.priceDeviationTolerance ?? DEFAULT_PRICE_DEVIATION_TOLERANCE,
192198
insufficientBalanceRetryDelayMs: config.insufficientBalanceRetryDelayMs ?? 60_000,
@@ -212,7 +218,7 @@ export class HermesClient {
212218
this.#config.rpcEndpoint,
213219
this.#wallet,
214220
{
215-
gasPrice: GasPrice.fromString(this.#config.gasPrice),
221+
gasPrice: this.#config.gasPrice,
216222
},
217223
);
218224

@@ -442,12 +448,13 @@ export class HermesClient {
442448

443449
this.#logger.log("Submitting VAA to Pyth contract...");
444450
this.#logger.log(` Wormhole contract: ${config.wormhole_contract}`);
445-
this.#priceUpdater ??= new PriceUpdateConfirmed(this.#getCosmClient());
451+
this.#priceUpdater ??= this.#config.priceUpdaterFactory(this.#getCosmClient(), this.#wallet!);
446452
const result = await this.#priceUpdater.updatePrice(priceUpdate, {
447453
senderAddress: this.#senderAddress,
448454
contractAddress: this.#config.contractAddress,
449455
denom: this.#config.denom,
450456
updateFee: config.update_fee,
457+
gasPrice: this.#config.gasPrice,
451458
});
452459

453460
const price = priceUpdate.priceData.price;
@@ -620,7 +627,7 @@ export class HermesClient {
620627
}
621628

622629
export type {
623-
ConfigResponse, DataSourceResponse, OracleParamsResponse, PriceFeedIdResponse, PriceFeedResponse, PriceResponse, RefreshOracleParamsMsg, TransferAdminMsg, UpdateFeeMsg,
630+
ConfigResponse, DataSourceResponse, OracleParamsResponse, PriceFeedIdResponse, PriceFeedResponse, PriceResponse, RefreshOracleParamsMsg, TransferAdminMsg, UpdateFeeMsg
624631
};
625632

626633
export type ErrorCode = "insufficient_balance" | "timeout" | "connection_issue" | "unknown";
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect } from "vitest";
2+
import { mock, mockDeep } from "vitest-mock-extended";
3+
import type { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
4+
import type { OfflineDirectSigner } from "@cosmjs/proto-signing";
5+
import type { Account } from "@cosmjs/stargate";
6+
import { GasPrice } from "@cosmjs/stargate";
7+
import { fromBase64, toBase64, toUtf8 } from "@cosmjs/encoding";
8+
import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx";
9+
import { PriceUpdateFireAndForget } from "./price-update-fire-and-forget";
10+
import type { PriceUpdate, PriceUpdateOptions } from "../../types";
11+
12+
describe(PriceUpdateFireAndForget.name, () => {
13+
it("returns the transaction hash from the broadcast", async () => {
14+
const { updater, signingClient } = setup();
15+
signingClient.broadcastTxSync.mockResolvedValue("broadcast-hash");
16+
17+
const result = await updater.updatePrice(priceUpdate, options);
18+
19+
expect(result).toEqual({ transactionHash: "broadcast-hash" });
20+
});
21+
22+
it("executes the price feed contract with the VAA and update fee", async () => {
23+
const { updater, signingClient } = setup();
24+
25+
await updater.updatePrice(priceUpdate, options);
26+
27+
expect(signingClient.registry.encodeAsAny).toHaveBeenCalledWith({
28+
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
29+
value: expect.objectContaining({
30+
sender: "akash1sender",
31+
contract: "akash1contract",
32+
msg: toUtf8(JSON.stringify({ update_price_feed: { vaa: "base64-encoded-vaa" } })),
33+
funds: [{ denom: "uakt", amount: "1000" }],
34+
}),
35+
});
36+
});
37+
38+
it("estimates the fee from a simulation with a 1.5x gas buffer", async () => {
39+
const { updater, signingClient } = setup();
40+
signingClient.simulate.mockResolvedValue(100_000);
41+
42+
await updater.updatePrice(priceUpdate, options);
43+
44+
expect(signingClient.simulate).toHaveBeenCalledWith("akash1sender", expect.any(Array), "");
45+
});
46+
47+
it("signs with the sender address and broadcasts the signed bytes", async () => {
48+
const { updater, signingClient, signer } = setup();
49+
50+
await updater.updatePrice(priceUpdate, options);
51+
52+
expect(signer.signDirect).toHaveBeenCalledWith("akash1sender", expect.anything());
53+
54+
const expectedTx = TxRaw.encode(TxRaw.fromPartial({
55+
bodyBytes: signedBodyBytes,
56+
authInfoBytes: signedAuthInfoBytes,
57+
signatures: [fromBase64(signatureBase64)],
58+
})).finish();
59+
expect(signingClient.broadcastTxSync).toHaveBeenCalledWith(expectedTx);
60+
});
61+
62+
it("caches the chain id and account across calls", async () => {
63+
const { updater, signingClient } = setup();
64+
65+
await updater.updatePrice(priceUpdate, options);
66+
await updater.updatePrice(priceUpdate, options);
67+
68+
expect(signingClient.getChainId).toHaveBeenCalledTimes(1);
69+
expect(signingClient.getAccount).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it("throws when the account is not found on chain", async () => {
73+
const { updater, signingClient } = setup();
74+
signingClient.getAccount.mockResolvedValue(null);
75+
76+
await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow(
77+
"Account akash1sender not found on chain",
78+
);
79+
});
80+
81+
it("throws when the account has no public key on chain", async () => {
82+
const { updater, signingClient } = setup();
83+
signingClient.getAccount.mockResolvedValue({ ...account, pubkey: null });
84+
85+
await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow(
86+
"has no public key on chain",
87+
);
88+
});
89+
90+
it("propagates broadcast errors", async () => {
91+
const { updater, signingClient } = setup();
92+
signingClient.broadcastTxSync.mockRejectedValue(new Error("mempool full"));
93+
94+
await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow("mempool full");
95+
});
96+
97+
const options: PriceUpdateOptions = {
98+
senderAddress: "akash1sender",
99+
contractAddress: "akash1contract",
100+
denom: "uakt",
101+
updateFee: "1000",
102+
gasPrice: GasPrice.fromString("0.025uakt"),
103+
};
104+
105+
const priceUpdate: PriceUpdate = {
106+
priceData: {
107+
id: "price-feed-id",
108+
price: { price: "100", conf: "1", expo: -8, publish_time: 1000 },
109+
ema_price: { price: "99", conf: "2", expo: -8, publish_time: 1000 },
110+
},
111+
vaa: "base64-encoded-vaa",
112+
};
113+
114+
// A valid compressed secp256k1 public key (33 bytes starting with 0x02) so the
115+
// real encodePubkey/makeAuthInfoBytes pipeline accepts it.
116+
const compressedPubkey = new Uint8Array(33);
117+
compressedPubkey[0] = 0x02;
118+
119+
const account: Account = {
120+
address: "akash1sender",
121+
pubkey: { type: "tendermint/PubKeySecp256k1", value: toBase64(compressedPubkey) },
122+
accountNumber: 7,
123+
sequence: 3,
124+
};
125+
126+
const signedBodyBytes = new Uint8Array([1, 2, 3]);
127+
const signedAuthInfoBytes = new Uint8Array([4, 5, 6]);
128+
const signatureBase64 = toBase64(new Uint8Array(64));
129+
130+
function setup() {
131+
const signingClient = mockDeep<SigningCosmWasmClient>();
132+
const signer = mock<OfflineDirectSigner>();
133+
134+
signingClient.getChainId.mockResolvedValue("akash-testnet");
135+
signingClient.getAccount.mockResolvedValue(account);
136+
signingClient.simulate.mockResolvedValue(100_000);
137+
signingClient.registry.encodeAsAny.mockReturnValue({ typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", value: new Uint8Array([9]) });
138+
signingClient.broadcastTxSync.mockResolvedValue("tx-hash");
139+
signer.signDirect.mockResolvedValue({
140+
signature: { pub_key: { type: "", value: "" }, signature: signatureBase64 },
141+
signed: {
142+
bodyBytes: signedBodyBytes,
143+
authInfoBytes: signedAuthInfoBytes,
144+
chainId: "akash-testnet",
145+
accountNumber: 7n,
146+
},
147+
});
148+
149+
const updater = new PriceUpdateFireAndForget(signingClient, signer);
150+
return { signingClient, signer, updater };
151+
}
152+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
2+
import { fromBase64, toUtf8 } from "@cosmjs/encoding";
3+
import { type EncodeObject, encodePubkey, makeAuthInfoBytes, makeSignDoc, type OfflineDirectSigner } from "@cosmjs/proto-signing";
4+
import { type Account, calculateFee, type GasPrice, type StdFee } from "@cosmjs/stargate";
5+
import { TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx";
6+
import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx";
7+
import { Timestamp } from "cosmjs-types/google/protobuf/timestamp";
8+
import type { PriceUpdate, PriceUpdateOptions, PriceUpdater, UpdatePriceFeedMsg } from "../../types.ts";
9+
10+
export class PriceUpdateFireAndForget implements PriceUpdater {
11+
readonly #signingClient: SigningCosmWasmClient;
12+
readonly #signer: OfflineDirectSigner;
13+
#chainId?: string;
14+
#account?: Account | null;
15+
16+
constructor(signingClient: SigningCosmWasmClient, signer: OfflineDirectSigner) {
17+
this.#signingClient = signingClient;
18+
this.#signer = signer;
19+
}
20+
21+
async updatePrice(priceUpdate: PriceUpdate, options: PriceUpdateOptions): Promise<{ transactionHash: string; gasUsed?: bigint }> {
22+
this.#chainId ??= await this.#signingClient.getChainId();
23+
this.#account ??= await this.#signingClient.getAccount(options.senderAddress);
24+
25+
if (!this.#account) {
26+
throw new Error(`Account ${options.senderAddress} not found on chain`);
27+
}
28+
29+
// Prepare execute message with VAA
30+
// The contract will:
31+
// 1. Verify VAA via Wormhole contract
32+
// 2. Parse Pyth price attestation from VAA payload
33+
// 3. Validate price feed ID matches expected
34+
// 4. Relay validated price to x/oracle module
35+
const msg: UpdatePriceFeedMsg = {
36+
update_price_feed: {
37+
vaa: priceUpdate.vaa,
38+
},
39+
};
40+
const messages: EncodeObject[] = [
41+
{
42+
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
43+
value: MsgExecuteContract.fromPartial({
44+
sender: options.senderAddress,
45+
contract: options.contractAddress,
46+
msg: toUtf8(JSON.stringify(msg)),
47+
funds: [{ denom: options.denom, amount: options.updateFee }],
48+
}),
49+
},
50+
];
51+
const fee = await this.#calcFee(messages, options.gasPrice);
52+
const tx = await this.#signUnordered(
53+
this.#account,
54+
messages,
55+
fee,
56+
);
57+
const transactionHash = await this.#signingClient.broadcastTxSync(TxRaw.encode(tx).finish());
58+
return {
59+
transactionHash,
60+
gasUsed: BigInt(fee.gas),
61+
};
62+
}
63+
64+
async #signUnordered(
65+
account: Account,
66+
messages: EncodeObject[],
67+
fee: StdFee,
68+
memo?: string | undefined,
69+
) {
70+
if (!account.pubkey) {
71+
throw new Error(`Account ${account.address} has no public key on chain (it must sign at least one tx first)`);
72+
}
73+
const pubkey = encodePubkey(account.pubkey);
74+
const ttlMs = 3 * 60_000;
75+
const futureMs = Date.now() + ttlMs;
76+
const txBodyBytes = TxBody.encode(TxBody.fromPartial({
77+
messages: messages.map((msg) => this.#signingClient.registry.encodeAsAny(msg)),
78+
memo: memo ?? "",
79+
unordered: true,
80+
timeoutTimestamp: Timestamp.fromPartial({
81+
seconds: BigInt(Math.floor(futureMs / 1000)),
82+
nanos: (futureMs % 1000) * 1_000_000,
83+
}),
84+
})).finish();
85+
86+
const authInfoBytes = makeAuthInfoBytes(
87+
[{ pubkey, sequence: 0 }],
88+
fee.amount, Number(fee.gas), fee.granter, fee.payer,
89+
);
90+
91+
const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, this.#chainId!, this.#account?.accountNumber!);
92+
const { signature, signed } = await this.#signer.signDirect(account.address, signDoc);
93+
94+
return TxRaw.fromPartial({
95+
bodyBytes: signed.bodyBytes,
96+
authInfoBytes: signed.authInfoBytes,
97+
signatures: [fromBase64(signature.signature)],
98+
});
99+
}
100+
101+
async #calcFee(messages: EncodeObject[], gasPrice: GasPrice, memo?: string): Promise<StdFee> {
102+
const gasEstimation = await this.#signingClient.simulate(
103+
this.#account!.address,
104+
messages,
105+
memo ?? "",
106+
);
107+
108+
return calculateFee(Math.ceil(gasEstimation * 1.5), gasPrice);
109+
}
110+
}

0 commit comments

Comments
 (0)