Skip to content

Commit 2518321

Browse files
hpihkalateogeb
andauthored
Autostaker uses fixed gas limit and nonce on stake/unstake, wait bugfix (#3237)
- Set a fixed gas limit in autostaker when doing stake/unstake transactions, because `estimateGas` on `stake` transactions will otherwise fail (with not enough balance) until `unstake` transactions have been mined. - Control nonce manually to prevent overlaps - Fix wait bug <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Autostaker switches stake/unstake to fixed gas limit with explicit nonces and longer timeouts, while SDK exposes TransactionOpts and updates staking helpers to accept tx options with improved receipt handling/logging. > > - **Node (`autostaker`)**: > - Use fixed `gasLimit` (`500000n`) and explicit `nonce` per action; increase `TRANSACTION_TIMEOUT` to `180s`. > - Change action submission to return `{ txResponse, txReceiptPromise }`; broadcast all, then await receipts with `Promise.allSettled` and summary logging. > - Pass `TransactionOpts` to stake/unstake; remove gas estimation/bump logic. > - **SDK**: > - Add and export `TransactionOpts` type; update `_operatorContractUtils.stake/unstake` to accept tx opts and forward to contract calls. > - Remove gas estimation/bump; add nonce-aware logging and error handling around `tx.wait`. > - **Changelog**: > - Note autostaker fixes and optimizations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3fe01a8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Teo Gebhard <[email protected]>
1 parent f2d1eca commit 2518321

File tree

4 files changed

+82
-49
lines changed

4 files changed

+82
-49
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Changes before Tatum release are not documented in this file.
1515
Autostaker changes:
1616
- transaction timeouts (https://github.com/streamr-dev/network/pull/3236)
1717
- queries filter by required block number (https://github.com/streamr-dev/network/pull/3238)
18+
- autostaker fixes and optimizations (https://github.com/streamr-dev/network/pull/3237)
1819

1920
#### Changed
2021

packages/node/src/plugins/autostaker/AutostakerPlugin.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { _operatorContractUtils, SignerWithProvider, SponsorshipCreatedEvent, StreamrClient } from '@streamr/sdk'
1+
import { _operatorContractUtils, SignerWithProvider, SponsorshipCreatedEvent, StreamrClient, TransactionOpts } from '@streamr/sdk'
22
import { collect, Logger, retry, scheduleAtApproximateInterval, TheGraphClient, toEthereumAddress, WeiAmount } from '@streamr/utils'
33
import { Schema } from 'ajv'
44
import { ContractTransactionReceipt, ContractTransactionResponse, formatEther, parseEther } from 'ethers'
@@ -52,8 +52,13 @@ const logger = new Logger(module)
5252
const MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND = 1000000000000n
5353
const ACTION_SUBMIT_RETRY_COUNT = 5
5454
const ACTION_SUBMIT_RETRY_DELAY_MS = 5000
55-
const ACTION_GAS_LIMIT_BUMP_PCT = 20
56-
const TRANSACTION_TIMEOUT = 60 * 1000
55+
const ACTION_GAS_LIMIT = 500000n
56+
const TRANSACTION_TIMEOUT = 180 * 1000
57+
58+
interface SubmitActionResult {
59+
txResponse: ContractTransactionResponse
60+
txReceiptPromise: Promise<ContractTransactionReceipt | null>
61+
}
5762

5863
const fetchMinStakePerSponsorship = async (theGraphClient: TheGraphClient): Promise<bigint> => {
5964
const queryResult = await theGraphClient.queryEntity<{ network: { minimumStakeWei: string } }>({
@@ -73,7 +78,7 @@ const getStakeOrUnstakeFunction = (action: Action): (
7378
operatorContractAddress: string,
7479
sponsorshipContractAddress: string,
7580
amount: WeiAmount,
76-
bumpGasLimitPct: number,
81+
txOpts: TransactionOpts,
7782
onSubmit: (tx: ContractTransactionResponse) => void,
7883
transactionTimeout?: number
7984
) => Promise<ContractTransactionReceipt | null> => {
@@ -138,28 +143,28 @@ export class AutostakerPlugin extends Plugin<AutostakerPluginConfig> {
138143
}
139144

140145
// Broadcasts a transaction corresponding to the action without waiting for it to be mined
141-
private async submitAction(action: Action, signer: SignerWithProvider): Promise<ContractTransactionResponse> {
146+
private async submitAction(action: Action, signer: SignerWithProvider, txOpts: TransactionOpts): Promise<SubmitActionResult> {
142147
logger.info(`Execute action: ${action.type} ${formatEther(action.amount)} ${action.sponsorshipId}`)
143148
const stakeOrUnstakeFunction = getStakeOrUnstakeFunction(action)
144149
return new Promise((resolve, reject) => {
145-
stakeOrUnstakeFunction(signer,
150+
const txReceiptPromise = stakeOrUnstakeFunction(signer,
146151
this.pluginConfig.operatorContractAddress,
147152
action.sponsorshipId,
148153
action.amount,
149-
// Gas limit needed for staking/unstaking is a little unstable because others might
150-
// be staking/unstaking at the same time, so we bump the gas limit to be safe
151-
ACTION_GAS_LIMIT_BUMP_PCT,
154+
txOpts,
152155
// resolve on the onSubmit callback (=tx is broadcasted) instead of when the stakeOrUnstakeFunction resolves (=tx is mined)
153-
(tx) => resolve(tx),
156+
(txResponse) => resolve({ txResponse, txReceiptPromise }),
154157
TRANSACTION_TIMEOUT
155-
).catch(reject)
158+
)
159+
// Propagate errors that occur before onSubmit is called
160+
txReceiptPromise.catch(reject)
156161
})
157162
}
158163

159164
// This will retry the transaction preflight checks, defends against various transient errors
160-
private async submitActionWithRetry(action: Action, signer: SignerWithProvider): Promise<ContractTransactionResponse> {
165+
private async submitActionWithRetry(action: Action, signer: SignerWithProvider, txOpts: TransactionOpts): Promise<SubmitActionResult> {
161166
return await retry(
162-
() => this.submitAction(action, signer),
167+
() => this.submitAction(action, signer, txOpts),
163168
(message, error) => {
164169
logger.error(message, { error })
165170
},
@@ -218,18 +223,36 @@ export class AutostakerPlugin extends Plugin<AutostakerPluginConfig> {
218223
...actions.filter((a) => a.type === 'unstake'),
219224
...actions.filter((a) => a.type === 'stake')
220225
]
221-
const allTxs: ContractTransactionResponse[] = []
226+
const allSubmitActionPromises: Promise<SubmitActionResult>[] = []
227+
228+
// Set nonce explicitly, because ethers tends to mess up nonce if we submit
229+
// multiple transactions in quick succession
230+
const address = await signer.getAddress()
231+
let nonce = await signer.provider.getTransactionCount(address)
222232

223-
// Broadcast each action sequentially (to avoid nonce overlap) but without waiting for each one to be mined
233+
// Broadcast each action but don't wait for them to be mined
224234
for (const action of orderedActions) {
225-
const tx = await this.submitActionWithRetry(action, signer)
226-
allTxs.push(tx)
235+
const submitActionPromise = this.submitActionWithRetry(action, signer, {
236+
// Use a fixed gas limit - gas estimation of stake transactions would fails
237+
// with "not enough balance", because we first need to unstake before we stake
238+
gasLimit: ACTION_GAS_LIMIT,
239+
// Explicit nonce
240+
nonce: nonce++,
241+
})
242+
allSubmitActionPromises.push(submitActionPromise)
227243
}
228244

245+
const allReceiptPromises = allSubmitActionPromises.map(async (p) => (await p).txReceiptPromise)
246+
229247
// Wait for all transactions to be mined (don't stop waiting if some of them fail)
230248
// Note that if the actual submitted transaction errors, it won't get retried. This should be rare.
231-
await Promise.allSettled(allTxs.map((tx) => tx.wait()))
232-
logger.info('All actions executed')
249+
const settledResults = await Promise.allSettled(allReceiptPromises)
250+
const successfulResults = settledResults.filter((r) => r.status === 'fulfilled')
251+
const failedResults = settledResults.filter((r) => r.status === 'rejected')
252+
logger.info(`All actions finished. Successful actions: ${successfulResults.length}, Failed actions: ${failedResults.length}`)
253+
if (failedResults.length > 0) {
254+
logger.error('Failed to execute some actions:', { failedResults })
255+
}
233256
}
234257

235258
private async getStakeableSponsorships(

packages/sdk/src/contracts/operatorContractUtils.ts

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { Logger, multiplyWeiAmount, WeiAmount } from '@streamr/utils'
1414
import {
1515
AbiCoder,
16+
BigNumberish,
1617
Contract,
1718
ContractTransactionReceipt,
1819
ContractTransactionResponse,
@@ -42,6 +43,16 @@ export interface DeployOperatorContractOpts {
4243
transactionTimeout?: number
4344
}
4445

46+
/**
47+
* @deprecated
48+
* @hidden
49+
*/
50+
export interface TransactionOpts {
51+
gasLimit?: BigNumberish
52+
gasPrice?: BigNumberish
53+
nonce?: number
54+
}
55+
4556
/**
4657
* @param opts.deployer should be the operator's Wallet
4758
* @returns Operator
@@ -170,32 +181,32 @@ export const stake = async (
170181
operatorContractAddress: string,
171182
sponsorshipContractAddress: string,
172183
amount: WeiAmount,
173-
bumpGasLimitPct: number = 0,
184+
txOpts: TransactionOpts = {},
174185
onSubmit: (tx: ContractTransactionResponse) => void = () => {},
175186
transactionTimeout?: number
176187
): Promise<ContractTransactionReceipt | null> => {
177188
logger.debug('Stake', { amount: formatEther(amount), sponsorshipContractAddress })
178189
const operatorContract = getOperatorContract(operatorContractAddress).connect(staker)
179-
180-
let gasLimit = await operatorContract.stake.estimateGas(sponsorshipContractAddress, amount)
181-
if (bumpGasLimitPct > 0) {
182-
gasLimit = bumpGasLimit(gasLimit, bumpGasLimitPct)
183-
}
184-
185-
const tx = await operatorContract.stake(sponsorshipContractAddress, amount, { gasLimit })
186-
logger.debug('Stake: transaction submitted', { tx: tx.hash })
190+
const tx = await operatorContract.stake(sponsorshipContractAddress, amount, txOpts)
191+
logger.debug('Stake: transaction submitted', { tx: tx.hash, nonce: tx.nonce })
187192
onSubmit(tx)
188-
const receipt = await tx.wait(undefined, transactionTimeout)
189-
logger.debug('Stake: confirmation received', { receipt: receipt?.hash })
190-
return receipt
193+
logger.debug('Stake: waiting for transaction to be mined', { tx: tx.hash, timeout: transactionTimeout })
194+
try {
195+
const receipt = await tx.wait(undefined, transactionTimeout)
196+
logger.debug('Stake: confirmation received', { receipt: receipt?.hash })
197+
return receipt
198+
} catch (error) {
199+
logger.error(`Stake: error waiting for tx to be mined`, { tx: tx.hash, error })
200+
throw error
201+
}
191202
}
192203

193204
export const unstake = async (
194205
staker: SignerWithProvider,
195206
operatorContractAddress: string,
196207
sponsorshipContractAddress: string,
197208
amount: WeiAmount,
198-
bumpGasLimitPct: number = 0,
209+
txOpts: TransactionOpts = {},
199210
onSubmit: (tx: ContractTransactionResponse) => void = () => {},
200211
transactionTimeout?: number
201212
): Promise<ContractTransactionReceipt | null> => {
@@ -205,17 +216,18 @@ export const unstake = async (
205216
const currentAmount = await sponsorshipContract.stakedWei(operatorContractAddress)
206217
const targetAmount = currentAmount - amount
207218

208-
let gasLimit = await operatorContract.reduceStakeTo.estimateGas(sponsorshipContractAddress, targetAmount)
209-
if (bumpGasLimitPct > 0) {
210-
gasLimit = bumpGasLimit(gasLimit, bumpGasLimitPct)
211-
}
212-
213-
const tx = await operatorContract.reduceStakeTo(sponsorshipContractAddress, targetAmount, { gasLimit })
214-
logger.debug('Unstake: transaction submitted', { tx: tx.hash })
219+
const tx = await operatorContract.reduceStakeTo(sponsorshipContractAddress, targetAmount, txOpts)
220+
logger.debug('Unstake: transaction submitted', { tx: tx.hash, nonce: tx.nonce })
215221
onSubmit(tx)
216-
const receipt = await tx.wait(undefined, transactionTimeout)
217-
logger.debug('Unstake: confirmation received', { receipt: receipt?.hash })
218-
return receipt
222+
logger.debug('Unstake: waiting for transaction to be mined', { tx: tx.hash, timeout: transactionTimeout })
223+
try {
224+
const receipt = await tx.wait(undefined, transactionTimeout)
225+
logger.debug('Unstake: confirmation received', { receipt: receipt?.hash })
226+
return receipt
227+
} catch (error) {
228+
logger.error(`Unstake: error waiting for tx to be mined`, { tx: tx.hash, error })
229+
throw error
230+
}
219231
}
220232

221233
export const sponsor = async (
@@ -252,7 +264,3 @@ const getSponsorshipContract = (sponsorshipAddress: string): SponsorshipContract
252264
const getTokenContract = (tokenAddress: string): DATATokenContract => {
253265
return new Contract(tokenAddress, DATATokenABI) as unknown as DATATokenContract
254266
}
255-
256-
const bumpGasLimit = (gasEstimate: bigint, increasePercentage: number): bigint => {
257-
return gasEstimate * BigInt(100 + increasePercentage) / 100n
258-
}

packages/sdk/src/exports.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ import {
111111
stake,
112112
unstake,
113113
DeploySponsorshipContractOpts,
114-
getOperatorContract
114+
getOperatorContract,
115+
TransactionOpts
115116
} from './contracts/operatorContractUtils'
116117

117118
/**
@@ -127,10 +128,10 @@ const _operatorContractUtils = {
127128
stake,
128129
unstake,
129130
deployOperatorContract,
130-
getOperatorContract
131+
getOperatorContract,
131132
}
132133
export { _operatorContractUtils }
133-
export type { DeployOperatorContractOpts, DeploySponsorshipContractOpts }
134+
export type { DeployOperatorContractOpts, DeploySponsorshipContractOpts, TransactionOpts }
134135

135136
export type { IceServer, PeerDescriptor, PortRange } from '@streamr/dht'
136137
export type { AbstractSigner, Eip1193Provider, Overrides } from 'ethers'

0 commit comments

Comments
 (0)