@pythnetwork/price-pusher 10.4.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,8 +104,19 @@ pnpm run start evm --endpoint wss://example-rpc.com \
104
104
  [--override-gas-price-multiplier 1.1] \
105
105
  [--override-gas-price-multiplier-cap 5] \
106
106
  [--gas-limit 1000000] \
107
+ [--gas-pricing-strategy eip1559] \
108
+ [--base-fee-multiplier 1.2] \
109
+ [--priority-fee-multiplier 1] \
107
110
  [--gas-price 160000000]
108
111
 
112
+ # The EVM pusher prices transactions with the `eip1559` strategy by default. It
113
+ # computes `maxFeePerGas` from the chain base fee (padded by `--base-fee-multiplier`
114
+ # to leave headroom for the base fee growing between blocks) plus an estimated
115
+ # priority fee (scaled by `--priority-fee-multiplier`). This avoids the deprecated
116
+ # `eth_gasPrice` RPC, which some RPCs mishandle. For chains that do not support
117
+ # EIP-1559 transactions, pass `--gas-pricing-strategy legacy`, which falls back to
118
+ # the single `gasPrice` model and where `--gas-price` / `--custom-gas-station` apply.
119
+
109
120
  # For Injective
110
121
  pnpm run start injective --grpc-endpoint https://grpc-endpoint.com \
111
122
  --pyth-contract-address inj1z60tg0... --price-service-endpoint "https://example-hermes-rpc.com" \
@@ -129,19 +140,27 @@ pnpm run start aptos --endpoint https://fullnode.testnet.aptoslabs.com/v1 \
129
140
 
130
141
  # For Sui
131
142
  pnpm run start sui \
132
- --endpoint https://sui-testnet-rpc.allthatnode.com \
133
- --pyth-package-id 0x975e063f398f720af4f33ec06a927f14ea76ca24f7f8dd544aa62ab9d5d15f44 \
134
- --pyth-state-id 0xd8afde3a48b4ff7212bd6829a150f43f59043221200d63504d981f62bff2e27a \
135
- --wormhole-package-id 0xcc029e2810f17f9f43f52262f40026a71fbdca40ed3803ad2884994361910b7e \
136
- --wormhole-state-id 0xebba4cc4d614f7a7cdbe883acc76d1cc767922bc96778e7b68be0d15fce27c02 \
137
- --price-feed-to-price-info-object-table-id 0xf8929174008c662266a1adde78e1e8e33016eb7ad37d379481e860b911e40ed5 \
143
+ --endpoint https://fullnode.testnet.sui.io:443 \
144
+ --network testnet \
145
+ --pyth-package-id 0xabf837e98c26087cba0883c0a7a28326b1fa3c5e1e2c5abdb486f9e8f594c837 \
146
+ --pyth-state-id 0x243759059f4c3111179da5878c12f68d612c21a8d54d85edc86164bb18be1c7c \
147
+ --wormhole-package-id 0x21473617f3565d704aa67be73ea41243e9e34a42d434c31f8182c67ba01ccf49 \
148
+ --wormhole-state-id 0x31358d198147da50db32eda2562951d53973a0c0ad5ed738e9b17d88b213d790 \
149
+ --price-feed-to-price-info-object-table-id 0xcb858b77d8068c6c8c0d8a4ddfba95053268e4a31f8ecc49adccc4ec1570d3a7 \
138
150
  --price-service-endpoint https://example-hermes-rpc.com \
139
151
  --mnemonic-file ./mnemonic \
140
152
  --price-config-file ./price-config.beta.sample.yaml \
153
+ [--endpoint-type json-rpc] \
141
154
  [--pushing-frequency 10] \
142
155
  [--polling-frequency 5] \
143
156
  [--num-gas-objects 30]
144
157
 
158
+ # `--endpoint-type` selects the Sui RPC transport: `json-rpc` (default) or `grpc`.
159
+ # Sui Foundation is deprecating JSON-RPC (public endpoints off July 2026, removed
160
+ # from full nodes by mid-Oct 2026), so set `--endpoint-type grpc` and point
161
+ # `--endpoint` at a gRPC-web base URL to migrate. `grpc` uses @mysten/sui's
162
+ # experimental SuiGrpcClient.
163
+
145
164
  # For Near
146
165
  pnpm run start near \
147
166
  --node-url https://rpc.testnet.near.org \
package/dist/common.cjs CHANGED
@@ -1,4 +1 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", {
3
- value: true
4
- });
package/dist/common.d.ts CHANGED
@@ -1,4 +0,0 @@
1
- export type PushAttempt = {
2
- nonce: number;
3
- gasPrice: number;
4
- };
@@ -28,6 +28,9 @@ class Controller {
28
28
  this.metrics = config.metrics;
29
29
  // Set the number of price feeds if metrics are enabled
30
30
  this.metrics?.setPriceFeedsTotal(this.priceConfigs.length);
31
+ // Report unhealthy on /live if no loop iteration completes within a few
32
+ // pushing cycles (min 60s), so the k8s livenessProbe restarts a hung pod.
33
+ this.metrics?.setLivenessThresholdSeconds(Math.max(this.pushingFrequency * 5, 60));
31
34
  }
32
35
  async start() {
33
36
  // start the listeners
@@ -49,13 +52,13 @@ class Controller {
49
52
  const targetLatestPrice = this.targetPriceListener.getLatestPriceInfo(priceId);
50
53
  const sourceLatestPrice = this.sourcePriceListener.getLatestPriceInfo(priceId);
51
54
  if (this.metrics && targetLatestPrice && sourceLatestPrice) {
52
- this.metrics.updateTimestamps(priceId, alias, targetLatestPrice.publishTime, sourceLatestPrice.publishTime, priceConfig.timeDifference);
53
- this.metrics.updatePriceValues(priceId, alias, sourceLatestPrice.price, targetLatestPrice.price);
55
+ this.metrics.updateTimestamps(alias, targetLatestPrice.publishTime, sourceLatestPrice.publishTime, priceConfig.timeDifference);
56
+ this.metrics.updatePriceValues(alias, sourceLatestPrice.price, targetLatestPrice.price);
54
57
  }
55
58
  const priceShouldUpdate = (0, _priceconfig.shouldUpdate)(priceConfig, sourceLatestPrice, targetLatestPrice, this.logger);
56
59
  // Record update condition in metrics
57
60
  if (this.metrics) {
58
- this.metrics.recordUpdateCondition(priceId, alias, priceShouldUpdate);
61
+ this.metrics.recordUpdateCondition(alias, priceShouldUpdate);
59
62
  }
60
63
  if (priceShouldUpdate == _priceconfig.UpdateCondition.YES) {
61
64
  pushThresholdMet = true;
@@ -80,7 +83,7 @@ class Controller {
80
83
  if (this.metrics) {
81
84
  for (const config of pricesToPush){
82
85
  const triggerValue = (0, _priceconfig.shouldUpdate)(config, this.sourcePriceListener.getLatestPriceInfo(config.id), this.targetPriceListener.getLatestPriceInfo(config.id), this.logger) === _priceconfig.UpdateCondition.YES ? "yes" : "early";
83
- this.metrics.recordPriceUpdate(config.id, config.alias, triggerValue);
86
+ this.metrics.recordPriceUpdate(config.alias, triggerValue);
84
87
  }
85
88
  }
86
89
  } catch (error) {
@@ -92,13 +95,15 @@ class Controller {
92
95
  if (this.metrics) {
93
96
  for (const config of pricesToPush){
94
97
  const triggerValue = (0, _priceconfig.shouldUpdate)(config, this.sourcePriceListener.getLatestPriceInfo(config.id), this.targetPriceListener.getLatestPriceInfo(config.id), this.logger) === _priceconfig.UpdateCondition.YES ? "yes" : "early";
95
- this.metrics.recordPriceUpdateError(config.id, config.alias, triggerValue);
98
+ this.metrics.recordPriceUpdateError(config.alias, triggerValue);
96
99
  }
97
100
  }
98
101
  }
99
102
  } else {
100
103
  this.logger.info("None of the checks were triggered. No push needed.");
101
104
  }
105
+ // Liveness heartbeat: mark this loop iteration as completed.
106
+ this.metrics?.recordLoopIteration();
102
107
  await (0, _utils.sleep)(this.pushingFrequency * 1000);
103
108
  }
104
109
  }
@@ -70,6 +70,12 @@ function _interop_require_wildcard(obj, nodeInterop) {
70
70
  }
71
71
  const _default = {
72
72
  builder: {
73
+ "base-fee-multiplier": {
74
+ default: 1.2,
75
+ description: "[eip1559 strategy only] Multiplier applied to the chain base fee when computing " + "maxFeePerGas. Values above 1 leave headroom for the base fee to grow between blocks. " + "Default to 1.2",
76
+ required: false,
77
+ type: "number"
78
+ },
73
79
  "custom-gas-station": {
74
80
  description: "If using a custom gas station, chainId of custom gas station to use",
75
81
  required: false,
@@ -86,10 +92,19 @@ const _default = {
86
92
  type: "number"
87
93
  },
88
94
  "gas-price": {
89
- description: "Override the gas price that would be received from the RPC",
95
+ description: "[legacy strategy only] Override the gas price (in wei) that would be received from the RPC",
90
96
  required: false,
91
97
  type: "number"
92
98
  },
99
+ "gas-pricing-strategy": {
100
+ choices: [
101
+ "eip1559",
102
+ "legacy"
103
+ ],
104
+ default: "eip1559",
105
+ description: "How to price transactions. 'eip1559' (default and recommended) sources the " + "gas from the chain base fee and a priority fee, which is more robust as it " + "avoids the deprecated eth_gasPrice RPC. Use 'legacy' for chains that do not " + "support eip1559 transactions.",
106
+ required: false
107
+ },
93
108
  "override-gas-price-multiplier": {
94
109
  default: 1.1,
95
110
  description: "Multiply the previous gas price by this number if the transaction is not landing to override. " + "Please note that the gas price can grow exponentially on consecutive failures; " + "to set a cap on the multiplier, use the `override-gas-price-multiplier-cap` option." + "Default to 1.1",
@@ -102,6 +117,24 @@ const _default = {
102
117
  required: false,
103
118
  type: "number"
104
119
  },
120
+ "priority-fee-multiplier": {
121
+ default: 1,
122
+ description: "[eip1559 strategy only] Multiplier applied to the priority fee (tip) estimated from " + "the RPC. Default to 1",
123
+ required: false,
124
+ type: "number"
125
+ },
126
+ "receipt-poll-interval": {
127
+ default: 2,
128
+ description: "How often (in seconds) to poll for a sent transaction's receipt while waiting for " + "it to land. Default to 2",
129
+ required: false,
130
+ type: "number"
131
+ },
132
+ "receipt-wait-timeout": {
133
+ default: 60,
134
+ description: "Maximum time (in seconds) to poll for a sent transaction's receipt before giving " + "up. Bounds the wait so a transaction that never lands cannot be polled forever. " + "Default to 60",
135
+ required: false,
136
+ type: "number"
137
+ },
105
138
  "tx-speed": {
106
139
  choices: [
107
140
  "slow",
@@ -133,7 +166,7 @@ const _default = {
133
166
  describe: "run price pusher for evm",
134
167
  handler: async function(argv) {
135
168
  // FIXME: type checks for this
136
- const { endpoint, priceConfigFile, priceServiceEndpoint, hermesAccessToken, mnemonicFile, pythContractAddress, pushingFrequency, pollingFrequency, customGasStation, txSpeed, overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, gasLimit, gasPrice, updateFeeMultiplier, logLevel, controllerLogLevel, enableMetrics, metricsPort } = argv;
169
+ const { endpoint, priceConfigFile, priceServiceEndpoint, hermesAccessToken, mnemonicFile, pythContractAddress, pushingFrequency, pollingFrequency, customGasStation, txSpeed, overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, gasLimit, gasPricingStrategy, gasPrice, baseFeeMultiplier, priorityFeeMultiplier, updateFeeMultiplier, receiptPollInterval, receiptWaitTimeout, logLevel, controllerLogLevel, enableMetrics, metricsPort } = argv;
137
170
  const logger = (0, _pino.default)({
138
171
  level: logLevel
139
172
  });
@@ -179,9 +212,22 @@ const _default = {
179
212
  const gasStation = (0, _customgasstation.getCustomGasStation)(logger.child({
180
213
  module: "CustomGasStation"
181
214
  }), customGasStation, txSpeed);
215
+ if (gasPricingStrategy === "eip1559" && (gasStation || gasPrice)) {
216
+ logger.warn("`--custom-gas-station`/`--tx-speed` and `--gas-price` only apply to the 'legacy' " + "gas pricing strategy and will be ignored. Use `--base-fee-multiplier` and " + "`--priority-fee-multiplier` to tune the 'eip1559' strategy.");
217
+ }
218
+ const gasPriceConfig = {
219
+ baseFeeMultiplier,
220
+ customGasStation: gasStation,
221
+ gasPrice,
222
+ priorityFeeMultiplier,
223
+ strategy: gasPricingStrategy
224
+ };
182
225
  const evmPusher = new _evm.EvmPricePusher(hermesClient, client, pythContract, logger.child({
183
226
  module: "EvmPricePusher"
184
- }), overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, updateFeeMultiplier, gasLimit, gasStation, gasPrice);
227
+ }), overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, updateFeeMultiplier, gasPriceConfig, gasLimit, // CLI values are in seconds; the pusher expects milliseconds. Defaults
228
+ // (60s / 2s) reproduce the pre-flag behaviour, so existing deployments
229
+ // that don't set these flags are unaffected.
230
+ receiptWaitTimeout * 1000, receiptPollInterval * 1000);
185
231
  const controller = new _controller.Controller(priceConfigs, pythListener, evmListener, evmPusher, logger.child({
186
232
  module: "Controller"
187
233
  }, {
@@ -12,12 +12,17 @@ declare const _default: {
12
12
  "hermes-access-token": Options;
13
13
  "price-service-endpoint": Options;
14
14
  "price-config-file": Options;
15
+ "base-fee-multiplier": Options;
15
16
  "custom-gas-station": Options;
16
17
  endpoint: Options;
17
18
  "gas-limit": Options;
18
19
  "gas-price": Options;
20
+ "gas-pricing-strategy": Options;
19
21
  "override-gas-price-multiplier": Options;
20
22
  "override-gas-price-multiplier-cap": Options;
23
+ "priority-fee-multiplier": Options;
24
+ "receipt-poll-interval": Options;
25
+ "receipt-wait-timeout": Options;
21
26
  "tx-speed": Options;
22
27
  "update-fee-multiplier": Options;
23
28
  };
package/dist/evm/evm.cjs CHANGED
@@ -19,6 +19,14 @@ _export(exports, {
19
19
  const _viem = require("viem");
20
20
  const _interface = require("../interface.cjs");
21
21
  const _utils = require("../utils.cjs");
22
+ const _gasprice = require("./gas-price.cjs");
23
+ // Some RPC providers surface the same underlying failure through different viem
24
+ // error classes. For example "replacement transaction underpriced" comes back as
25
+ // an `InternalRpcError` (code -32000) on most chains, but Soneium/Alchemy returns
26
+ // it as an `InvalidInputRpcError` (code -32602, "Missing or invalid parameters").
27
+ // Matching on the error text across the whole cause chain (rather than on a
28
+ // specific class) keeps the detection robust across providers.
29
+ const errorChainIncludes = (error, needle)=>error.walk((e)=>e instanceof _viem.BaseError && e.details.includes(needle) || e instanceof Error && e.message.includes(needle)) !== null;
22
30
  class EvmPriceListener extends _interface.ChainPriceListener {
23
31
  pythContract;
24
32
  watchEvents;
@@ -88,12 +96,31 @@ class EvmPricePusher {
88
96
  overrideGasPriceMultiplier;
89
97
  overrideGasPriceMultiplierCap;
90
98
  updateFeeMultiplier;
99
+ gasPriceConfig;
91
100
  gasLimit;
92
- customGasStation;
93
- gasPrice;
101
+ receiptWaitTimeoutMs;
102
+ receiptPollIntervalMs;
94
103
  pusherAddress;
95
104
  lastPushAttempt;
96
- constructor(hermesClient, client, pythContract, logger, overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, updateFeeMultiplier, gasLimit, customGasStation, gasPrice){
105
+ // Bounded, per-nonce receipt trackers. Each poll self-terminates on landing or
106
+ // at `receiptWaitTimeoutMs`. Same-nonce gas escalations ADD a tracker rather
107
+ // than replacing the previous one: the original and the repriced tx compete
108
+ // for the nonce and EITHER can land (a miner may include the old tx over the
109
+ // replacement), so all competing hashes stay watched and whichever lands is
110
+ // logged. A nonce advance means the previous nonce already resolved, so its
111
+ // now-doomed stragglers are aborted. This replaces viem's
112
+ // `waitForTransactionReceipt`, whose timeout (on the pinned version) rejects
113
+ // without tearing down its internal `eth_getTransactionByHash` / block-number
114
+ // poll — so a fire-and-forget wait for a tx that never becomes findable leaked
115
+ // a detached poller that ran for the pod's whole lifetime, one per push. Here
116
+ // the count is bounded: at most one nonce is tracked at a time, and each of
117
+ // its trackers self-terminates at the deadline.
118
+ receiptTrackers = new Set();
119
+ trackedNonce;
120
+ constructor(hermesClient, client, pythContract, logger, overrideGasPriceMultiplier, overrideGasPriceMultiplierCap, updateFeeMultiplier, gasPriceConfig, gasLimit, // Upper bound on how long to poll for a tx receipt before giving up. Keeps a
121
+ // tx that never lands from polling forever. Defaults to ~2 push cycles.
122
+ receiptWaitTimeoutMs = 60_000, // How often to poll `eth_getTransactionReceipt` while waiting.
123
+ receiptPollIntervalMs = 2000){
97
124
  this.hermesClient = hermesClient;
98
125
  this.client = client;
99
126
  this.pythContract = pythContract;
@@ -101,9 +128,10 @@ class EvmPricePusher {
101
128
  this.overrideGasPriceMultiplier = overrideGasPriceMultiplier;
102
129
  this.overrideGasPriceMultiplierCap = overrideGasPriceMultiplierCap;
103
130
  this.updateFeeMultiplier = updateFeeMultiplier;
131
+ this.gasPriceConfig = gasPriceConfig;
104
132
  this.gasLimit = gasLimit;
105
- this.customGasStation = customGasStation;
106
- this.gasPrice = gasPrice;
133
+ this.receiptWaitTimeoutMs = receiptWaitTimeoutMs;
134
+ this.receiptPollIntervalMs = receiptPollIntervalMs;
107
135
  }
108
136
  // The pubTimes are passed here to use the values that triggered the push.
109
137
  // This is an optimization to avoid getting a newer value (as an update comes)
@@ -129,11 +157,11 @@ class EvmPricePusher {
129
157
  this.logger.error(error, "An unidentified error has occured when getting the update fee.");
130
158
  throw error;
131
159
  }
132
- // Gas price in networks with transaction type eip1559 represents the
133
- // addition of baseFee and priorityFee required to land the transaction. We
134
- // are using this to remain compatible with the networks that doesn't
135
- // support this transaction type.
136
- let gasPrice = this.gasPrice ?? Number(await (this.customGasStation?.getCustomGasPrice() ?? this.client.getGasPrice()));
160
+ // Fetch a fresh gas price. With the eip1559 strategy this is sourced from
161
+ // the chain base fee and priority fee (avoiding the deprecated eth_gasPrice
162
+ // RPC); with the legacy strategy it falls back to the single gasPrice model
163
+ // for chains that don't support eip1559 transactions.
164
+ let gasPrice = await (0, _gasprice.getGasPrice)(this.client, this.gasPriceConfig, this.logger);
137
165
  // Try to re-use the same nonce and increase the gas if the last tx is not landed yet.
138
166
  this.pusherAddress ??= this.client.account.address;
139
167
  const lastExecutedNonce = await this.client.getTransactionCount({
@@ -144,14 +172,16 @@ class EvmPricePusher {
144
172
  if (this.lastPushAttempt.nonce <= lastExecutedNonce) {
145
173
  this.lastPushAttempt = undefined;
146
174
  } else {
147
- gasPriceToOverride = this.lastPushAttempt.gasPrice * this.overrideGasPriceMultiplier;
175
+ gasPriceToOverride = (0, _gasprice.scaleGasPrice)(this.lastPushAttempt.gasPrice, this.overrideGasPriceMultiplier);
148
176
  }
149
177
  }
150
- if (gasPriceToOverride !== undefined && gasPriceToOverride > Number(gasPrice)) {
151
- gasPrice = Math.min(gasPriceToOverride, gasPrice * this.overrideGasPriceMultiplierCap);
178
+ if (gasPriceToOverride !== undefined) {
179
+ // Bump every fee component to override the stuck transaction, capping the
180
+ // bump relative to the fresh gas price returned by the RPC.
181
+ gasPrice = (0, _gasprice.escalateGasPrice)(gasPrice, gasPriceToOverride, this.overrideGasPriceMultiplierCap);
152
182
  }
153
183
  const txNonce = lastExecutedNonce + 1;
154
- this.logger.debug(`Using gas price: ${gasPrice} and nonce: ${txNonce}`);
184
+ this.logger.debug(`Using ${(0, _gasprice.describeGasPrice)(gasPrice)} and nonce: ${txNonce}`);
155
185
  const pubTimesToPushParam = pubTimesToPush.map(BigInt);
156
186
  const priceIdsWith0x = priceIds.map((priceId)=>(0, _utils.addLeading0x)(priceId));
157
187
  // Update lastAttempt
@@ -166,7 +196,7 @@ class EvmPricePusher {
166
196
  pubTimesToPushParam
167
197
  ], {
168
198
  gas: this.gasLimit === undefined ? undefined : BigInt(Math.ceil(this.gasLimit)),
169
- gasPrice: BigInt(Math.ceil(gasPrice)),
199
+ ...(0, _gasprice.gasPriceToTxParams)(gasPrice),
170
200
  nonce: txNonce,
171
201
  value: updateFee
172
202
  });
@@ -174,17 +204,17 @@ class EvmPricePusher {
174
204
  request
175
205
  }, "Simulated request successfully");
176
206
  const hash = await this.client.writeContract(request);
177
- this.logger.info({
207
+ this.logger.debug({
178
208
  hash
179
209
  }, "Price update sent");
180
- void this.waitForTransactionReceipt(hash);
210
+ this.trackTransactionReceipt(hash, txNonce);
181
211
  } catch (error) {
182
212
  this.logger.debug({
183
213
  err: error
184
214
  }, "Simulating or sending transactions failed.");
185
215
  if (error instanceof _viem.BaseError) {
186
216
  if (error.walk((e)=>e instanceof _viem.ContractFunctionRevertedError && e.data?.errorName === "NoFreshUpdate")) {
187
- this.logger.info("Simulation reverted because none of the updates are fresh. This is an expected behaviour to save gas. Skipping this push.");
217
+ this.logger.debug("Simulation reverted because none of the updates are fresh. This is an expected behaviour to save gas. Skipping this push.");
188
218
  return;
189
219
  }
190
220
  if (error.walk((e)=>e instanceof _viem.InsufficientFundsError)) {
@@ -193,12 +223,12 @@ class EvmPricePusher {
193
223
  }, "Wallet doesn't have enough balance. In rare cases, there might be issues with gas price " + "calculation in the RPC.");
194
224
  throw error;
195
225
  }
196
- if (error.walk((e)=>e instanceof _viem.FeeCapTooLowError) || error.walk((e)=>e instanceof _viem.InternalRpcError && e.details.includes("replacement transaction underpriced"))) {
226
+ if (error.walk((e)=>e instanceof _viem.FeeCapTooLowError) || errorChainIncludes(error, "replacement transaction underpriced")) {
197
227
  this.logger.warn("The gas price of the transaction is too low or there is an existing transaction with higher gas with the same nonce. " + "The price will be increased in the next push. Skipping this push. " + "If this keeps happening or transactions are not landing you need to increase the override gas price " + "multiplier and the cap to increase the likelihood of the transaction landing on-chain.");
198
228
  return;
199
229
  }
200
230
  if (error.walk((e)=>e instanceof _viem.TransactionExecutionError && (e.details.includes("nonce too low") || e.message.includes("Nonce provided for the transaction")))) {
201
- this.logger.info("The nonce is incorrect. This is an expected behaviour in high frequency or multi-instance setup. Skipping this push.");
231
+ this.logger.debug("The nonce is incorrect. This is an expected behaviour in high frequency or multi-instance setup. Skipping this push.");
202
232
  return;
203
233
  }
204
234
  // Sometimes the contract function execution fails in simulation and this error is thrown.
@@ -211,7 +241,7 @@ class EvmPricePusher {
211
241
  // We normally crash on unknown failures but we believe that this type of error is safe to skip. The other reason is that
212
242
  // wometimes we see a TransactionExecutionError because of the nonce without any details and it is not catchable.
213
243
  if (error.walk((e)=>e instanceof _viem.TransactionExecutionError)) {
214
- this.logger.error({
244
+ this.logger.warn({
215
245
  err: error
216
246
  }, "Transaction execution failed. This is an expected behaviour in high frequency or multi-instance setup. " + "Please review this error and file an issue if it is a bug. Skipping this push.");
217
247
  return;
@@ -219,7 +249,7 @@ class EvmPricePusher {
219
249
  // The following errors are part of the legacy code and might not work as expected.
220
250
  // We are keeping them in case they help with handling what is not covered above.
221
251
  if (error.message.includes("the tx doesn't have the correct nonce.") || error.message.includes("nonce too low") || error.message.includes("invalid nonce")) {
222
- this.logger.info("The nonce is incorrect (are multiple users using this account?). Skipping this push.");
252
+ this.logger.debug("The nonce is incorrect (are multiple users using this account?). Skipping this push.");
223
253
  return;
224
254
  }
225
255
  if (error.message.includes("max fee per gas less than block base fee")) {
@@ -235,7 +265,7 @@ class EvmPricePusher {
235
265
  throw new Error("Please top up the wallet");
236
266
  }
237
267
  if (error.message.includes("could not replace existing tx")) {
238
- this.logger.error("A transaction with the same nonce has been mined and this one is no longer needed. Skipping this push.");
268
+ this.logger.debug("A transaction with the same nonce has been mined and this one is no longer needed. Skipping this push.");
239
269
  return;
240
270
  }
241
271
  }
@@ -246,37 +276,111 @@ class EvmPricePusher {
246
276
  throw error;
247
277
  }
248
278
  }
249
- async waitForTransactionReceipt(hash) {
250
- try {
251
- const receipt = await this.client.waitForTransactionReceipt({
252
- hash: hash
253
- });
254
- switch(receipt.status){
255
- case "success":
256
- {
257
- this.logger.debug({
258
- hash,
259
- receipt
260
- }, "Price update successful");
261
- this.logger.info({
262
- hash
263
- }, "Price update successful");
264
- break;
265
- }
266
- default:
267
- {
268
- this.logger.info({
269
- hash,
270
- receipt
271
- }, "Price update did not succeed or its transaction did not land. " + "This is an expected behaviour in high frequency or multi-instance setup.");
272
- }
279
+ // Stop all in-flight receipt trackers (graceful shutdown / tests). Safe to
280
+ // call at any time; leaves no pending poll behind.
281
+ dispose() {
282
+ for (const controller of this.receiptTrackers){
283
+ controller.abort();
284
+ }
285
+ this.receiptTrackers.clear();
286
+ this.trackedNonce = undefined;
287
+ }
288
+ // Start a receipt tracker for a freshly-sent tx. Fire-and-forget by design —
289
+ // the receipt is observational (it only produces the "Price update successful"
290
+ // log the Grafana Tx-Hash panel scrapes); the controller never blocks on it,
291
+ // and the re-send/nonce/gas decision is driven by a fresh `getTransactionCount`
292
+ // each cycle, not by the receipt.
293
+ trackTransactionReceipt(hash, nonce) {
294
+ // A new nonce means the previous nonce already resolved (a tx for it landed,
295
+ // advancing the on-chain count), so abort the stragglers still polling the
296
+ // old nonce's hashes. They each do one final lookup on abort, so the hash
297
+ // that actually landed is still logged before they stop. Same-nonce
298
+ // escalations fall through and ADD a tracker so every competing hash for the
299
+ // live nonce stays watched.
300
+ if (this.trackedNonce !== undefined && nonce !== this.trackedNonce) {
301
+ for (const controller of this.receiptTrackers){
302
+ controller.abort();
273
303
  }
274
- } catch (error) {
275
- this.logger.warn({
276
- err: error
277
- }, "Failed to get transaction receipt");
304
+ this.receiptTrackers.clear();
305
+ }
306
+ this.trackedNonce = nonce;
307
+ const controller = new AbortController();
308
+ this.receiptTrackers.add(controller);
309
+ void this.pollTransactionReceipt(hash, controller).finally(()=>{
310
+ this.receiptTrackers.delete(controller);
311
+ });
312
+ }
313
+ // Poll `eth_getTransactionReceipt` (one call per interval) until the tx lands,
314
+ // the tracker is superseded, or the deadline elapses. Unlike viem's
315
+ // `waitForTransactionReceipt` this issues no `eth_getTransactionByHash` and no
316
+ // per-block full-block fetch, and its teardown is guaranteed by the bound +
317
+ // AbortController — so an un-landing tx cannot leak a poller.
318
+ async pollTransactionReceipt(hash, controller) {
319
+ const deadline = Date.now() + this.receiptWaitTimeoutMs;
320
+ for(;;){
321
+ let receipt;
322
+ try {
323
+ receipt = await this.client.getTransactionReceipt({
324
+ hash
325
+ });
326
+ } catch {
327
+ // viem throws TransactionReceiptNotFoundError while the tx is unmined.
328
+ // Treat any lookup failure as "not landed yet" and keep polling.
329
+ receipt = undefined;
330
+ }
331
+ // A fetched receipt means the tx actually landed — it is never stale, so
332
+ // log it even if this tracker was superseded (otherwise the Grafana panel
333
+ // would miss the hash of a tx that landed just as the next push started).
334
+ if (receipt !== undefined) {
335
+ if (receipt.status === "success") {
336
+ // Keep one non-debug hash line per landed tx: the bundled Grafana
337
+ // "Tx Hash" panel scrapes this message from Loki and extracts {{.hash}}.
338
+ this.logger.info({
339
+ hash
340
+ }, "Price update successful");
341
+ } else {
342
+ this.logger.debug({
343
+ hash,
344
+ receipt
345
+ }, "Price update did not succeed or its transaction did not land. " + "This is an expected behaviour in high frequency or multi-instance setup.");
346
+ }
347
+ return;
348
+ }
349
+ // Not landed yet: stop if this tracker was superseded, or if we ran out
350
+ // of time (only these bounded paths end the loop, so it cannot leak).
351
+ if (controller.signal.aborted) {
352
+ return;
353
+ }
354
+ if (Date.now() >= deadline) {
355
+ this.logger.debug({
356
+ hash
357
+ }, "Gave up waiting for the transaction receipt within the timeout. " + "This is expected when a tx does not land; the next push re-derives " + "the nonce from the chain and replaces it if needed.");
358
+ return;
359
+ }
360
+ await this.interruptibleSleep(this.receiptPollIntervalMs, controller.signal);
278
361
  }
279
362
  }
363
+ // Sleep that resolves early if the signal aborts, so a superseded tracker
364
+ // stops promptly instead of waiting out its poll interval.
365
+ interruptibleSleep(ms, signal) {
366
+ return new Promise((resolve)=>{
367
+ if (signal.aborted) {
368
+ resolve();
369
+ return;
370
+ }
371
+ const onAbort = ()=>{
372
+ clearTimeout(timer);
373
+ resolve();
374
+ };
375
+ const timer = setTimeout(()=>{
376
+ signal.removeEventListener("abort", onAbort);
377
+ resolve();
378
+ }, ms);
379
+ signal.addEventListener("abort", onAbort, {
380
+ once: true
381
+ });
382
+ });
383
+ }
280
384
  async getPriceFeedsUpdateData(priceIds) {
281
385
  const response = await this.hermesClient.getLatestPriceUpdates(priceIds, {
282
386
  encoding: "hex",
package/dist/evm/evm.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
3
3
  import type { IPricePusher, PriceInfo, PriceItem } from "../interface.js";
4
4
  import { ChainPriceListener } from "../interface.js";
5
5
  import type { DurationInSeconds } from "../utils.js";
6
- import type { CustomGasStation } from "./custom-gas-station";
6
+ import type { GasPriceConfig } from "./gas-price.js";
7
7
  import type { PythContract } from "./pyth-contract.js";
8
8
  import type { SuperWalletClient } from "./super-wallet.js";
9
9
  export declare class EvmPriceListener extends ChainPriceListener {
@@ -26,13 +26,19 @@ export declare class EvmPricePusher implements IPricePusher {
26
26
  private overrideGasPriceMultiplier;
27
27
  private overrideGasPriceMultiplierCap;
28
28
  private updateFeeMultiplier;
29
+ private gasPriceConfig;
29
30
  private gasLimit?;
30
- private customGasStation?;
31
- private gasPrice?;
31
+ private receiptWaitTimeoutMs;
32
+ private receiptPollIntervalMs;
32
33
  private pusherAddress;
33
34
  private lastPushAttempt;
34
- constructor(hermesClient: HermesClient, client: SuperWalletClient, pythContract: PythContract, logger: Logger, overrideGasPriceMultiplier: number, overrideGasPriceMultiplierCap: number, updateFeeMultiplier: number, gasLimit?: number | undefined, customGasStation?: CustomGasStation | undefined, gasPrice?: number | undefined);
35
+ private receiptTrackers;
36
+ private trackedNonce;
37
+ constructor(hermesClient: HermesClient, client: SuperWalletClient, pythContract: PythContract, logger: Logger, overrideGasPriceMultiplier: number, overrideGasPriceMultiplierCap: number, updateFeeMultiplier: number, gasPriceConfig: GasPriceConfig, gasLimit?: number | undefined, receiptWaitTimeoutMs?: number, receiptPollIntervalMs?: number);
35
38
  updatePriceFeed(priceIds: string[], pubTimesToPush: UnixTimestamp[]): Promise<void>;
36
- private waitForTransactionReceipt;
39
+ dispose(): void;
40
+ private trackTransactionReceipt;
41
+ private pollTransactionReceipt;
42
+ private interruptibleSleep;
37
43
  private getPriceFeedsUpdateData;
38
44
  }