Skip to content

feat(node): [NET-1455] Autostaker plugin #3086

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 64 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
6f23cee
first draft
teogeb Apr 25, 2025
252bd94
Merge branch 'main' into autostaker-plugin
teogeb May 15, 2025
d4968e5
payoutProportional strategy (mostly copy-paste from autostaker repo)
teogeb May 19, 2025
bd0d77b
manual test: mulltiple sponsorships
teogeb May 19, 2025
b6d76cf
use StreamrClient#getTheGraphClient()
teogeb May 28, 2025
a616556
cli-tools command
teogeb May 28, 2025
3e8bfcf
grant controller role to the node
teogeb May 28, 2025
49f673d
rm guard
teogeb Jun 2, 2025
f2a44c3
improve test
teogeb Jun 2, 2025
c08fc53
simpler comparison
teogeb Jun 2, 2025
4f6488f
refactor: getTargetStakes()
teogeb Jun 2, 2025
4159ba2
rm obsolete totalStakedWei field
teogeb Jun 2, 2025
6fcc2e9
rename SponsorshipId
teogeb Jun 2, 2025
d8a2156
use WeiAmount type
teogeb Jun 2, 2025
19a8134
rename SponsorshipState
teogeb Jun 2, 2025
8fc1e3e
tests
teogeb Jun 2, 2025
cf639ab
getSelectedSponsorships()
teogeb Jun 2, 2025
a7e262b
getTargetStakes() includes expired sponsorships
teogeb Jun 2, 2025
d3cd2dc
getTargetStakes() calls getSelectedSponsorships()
teogeb Jun 2, 2025
17c1a04
sort only by adjustment type
teogeb Jun 2, 2025
2b7b482
rename variable
teogeb Jun 2, 2025
6d583bb
filter out small transactions
teogeb Jun 2, 2025
263605f
rm comment
teogeb Jun 2, 2025
e3e05eb
scheduleAtApproximateInterval()
teogeb Jun 2, 2025
cc531fe
use scheduleAtApproximateInterval()
teogeb Jun 2, 2025
81dda13
select sponsorships based on operatorContractAddress if totalPayoutWe…
teogeb Jun 2, 2025
edcb598
smoke test
teogeb Jun 2, 2025
b622b47
eslint
teogeb Jun 2, 2025
9fe13e5
maxSponsorshipCount in plugin config
teogeb Jun 2, 2025
cfce154
minTransactionDataTokenAmount in plugin config
teogeb Jun 2, 2025
4b9f5a2
rename totalPayoutWeiPerSec -> payoutPerSec
teogeb Jun 2, 2025
4c3c0d7
rename unstakedWei -> unstakedAmount
teogeb Jun 2, 2025
e19898f
rename minTransactionWei -> minTransactionAmount
teogeb Jun 2, 2025
82c6177
rename minimumStakeWei -> minStakePerSponsorship
teogeb Jun 2, 2025
3b71b7b
rename local variables
teogeb Jun 2, 2025
4da3320
logging
teogeb Jun 2, 2025
6f0e77a
reduce logging in TheGraphClient
teogeb Jun 2, 2025
6cab147
rm manual test
teogeb Jun 2, 2025
b11a765
Merge branch 'main' into autostaker-plugin
teogeb Jun 2, 2025
82dfd25
rm ethereum-private-key addition (not needed as we don't configure pr…
teogeb Jun 2, 2025
03a3077
rm unnecessary filter in getStakeableSponsorships()
teogeb Jun 2, 2025
0a59667
Merge branch 'main' into autostaker-plugin
teogeb Jun 2, 2025
0a43315
improve logging
teogeb Jun 3, 2025
2e7fa15
fetchMinStakePerSponsorship()
teogeb Jun 3, 2025
95facd5
style
teogeb Jun 3, 2025
84775ae
move sum() to payoutProportionalStrategy.ts
teogeb Jun 3, 2025
70cf31d
minimumStakingPeriodSeconds
teogeb Jun 4, 2025
44028c2
maxAcceptableMinOperatorCount
teogeb Jun 4, 2025
6b53523
maxOperators
teogeb Jun 4, 2025
b85a3d2
MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND
teogeb Jun 5, 2025
9a84781
improve tooSmallAdjustments removal
teogeb Jun 5, 2025
5ddf14f
rm unnecessary variable
teogeb Jun 5, 2025
3ddfc92
add comment
teogeb Jun 5, 2025
a1c87b6
rm unnecessary Number -> bigint type conversions
teogeb Jun 5, 2025
9649cba
fix hasAcceptableOperatorCount() for sponsorships which we've already…
teogeb Jun 5, 2025
7caa8f0
always unstake from expired sponsorship
teogeb Jun 5, 2025
645a1ab
changelog
teogeb Jun 5, 2025
29cd6ce
rename stakes -> myCurrentStakes
teogeb Jun 5, 2025
a51bbc7
rename unstakedAmount -> myUnstakedAmount
teogeb Jun 5, 2025
a2d4c3a
inline OperatorState interface
teogeb Jun 5, 2025
3726cff
inline OperatorConfig and EnvironmentConfig interfaces
teogeb Jun 5, 2025
d0353de
AutostakerPluginConfig fields in same order as in adjustStakes()
teogeb Jun 5, 2025
49e6d96
config defaults
teogeb Jun 6, 2025
eec128f
Merge branch 'main' into autostaker-plugin
teogeb Jun 10, 2025
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Changes before Tatum release are not documented in this file.

#### Added

- Add experimental `autostaker` plugin that manages sponsorship staking and unstaking automatically for operators (https://github.com/streamr-dev/network/pull/3086)

#### Changed

- **BREAKING CHANGE**: Node.js v20 or higher is required (https://github.com/streamr-dev/network/pull/3138)
Expand Down
3 changes: 3 additions & 0 deletions packages/node/src/pluginRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Plugin } from './Plugin'
import { StrictConfig } from './config/config'
import { AutostakerPlugin } from './plugins/autostaker/AutostakerPlugin'
import { ConsoleMetricsPlugin } from './plugins/consoleMetrics/ConsoleMetricsPlugin'
import { HttpPlugin } from './plugins/http/HttpPlugin'
import { InfoPlugin } from './plugins/info/InfoPlugin'
Expand Down Expand Up @@ -27,6 +28,8 @@ export const createPlugin = (name: string, brokerConfig: StrictConfig): Plugin<a
return new SubscriberPlugin(name, brokerConfig)
case 'info':
return new InfoPlugin(name, brokerConfig)
case 'autostaker':
return new AutostakerPlugin(name, brokerConfig)
default:
throw new Error(`Unknown plugin: ${name}`)
}
Expand Down
207 changes: 207 additions & 0 deletions packages/node/src/plugins/autostaker/AutostakerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { _operatorContractUtils, SignerWithProvider, StreamrClient } from '@streamr/sdk'
import { collect, Logger, scheduleAtApproximateInterval, TheGraphClient, WeiAmount } from '@streamr/utils'
import { Schema } from 'ajv'
import { formatEther, parseEther } from 'ethers'
import { Plugin } from '../../Plugin'
import PLUGIN_CONFIG_SCHEMA from './config.schema.json'
import { adjustStakes } from './payoutProportionalStrategy'
import { Action, SponsorshipConfig, SponsorshipID } from './types'

export interface AutostakerPluginConfig {
operatorContractAddress: string
maxSponsorshipCount: number
minTransactionDataTokenAmount: number
maxAcceptableMinOperatorCount: number
runIntervalInMs: number
}

interface SponsorshipQueryResultItem {
id: SponsorshipID
totalPayoutWeiPerSec: WeiAmount
operatorCount: number
maxOperators: number | null
}

interface StakeQueryResultItem {
id: string
sponsorship: {
id: SponsorshipID
}
amountWei: WeiAmount
}

const logger = new Logger(module)

// 1e12 wei, i.e. one millionth of one DATA token (we can tweak this later if needed)
const MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND = 1000000000000n

const fetchMinStakePerSponsorship = async (theGraphClient: TheGraphClient): Promise<bigint> => {
const queryResult = await theGraphClient.queryEntity<{ network: { minimumStakeWei: string } }>({
query: `
{
network (id: "network-entity-id") {
minimumStakeWei
}
}
`
})
return BigInt(queryResult.network.minimumStakeWei)
}

const getStakeOrUnstakeFunction = (action: Action): (
operatorOwnerWallet: SignerWithProvider,
operatorContractAddress: string,
sponsorshipContractAddress: string,
amount: WeiAmount
) => Promise<void> => {
switch (action.type) {
case 'stake':
return _operatorContractUtils.stake
case 'unstake':
return _operatorContractUtils.unstake
default:
throw new Error('assertion failed')
}
}

export class AutostakerPlugin extends Plugin<AutostakerPluginConfig> {

private abortController: AbortController = new AbortController()

async start(streamrClient: StreamrClient): Promise<void> {
logger.info('Start autostaker plugin')
const minStakePerSponsorship = await fetchMinStakePerSponsorship(streamrClient.getTheGraphClient())
scheduleAtApproximateInterval(async () => {
try {
await this.runActions(streamrClient, minStakePerSponsorship)
} catch (err) {
logger.warn('Error while running autostaker actions', { err })
}
}, this.pluginConfig.runIntervalInMs, 0.1, false, this.abortController.signal)
}

private async runActions(streamrClient: StreamrClient, minStakePerSponsorship: bigint): Promise<void> {
logger.info('Run autostaker analysis')
const provider = (await streamrClient.getSigner()).provider
const operatorContract = _operatorContractUtils.getOperatorContract(this.pluginConfig.operatorContractAddress)
.connect(provider)
const myCurrentStakes = await this.getMyCurrentStakes(streamrClient)
const stakeableSponsorships = await this.getStakeableSponsorships(myCurrentStakes, streamrClient)
const myStakedAmount = await operatorContract.totalStakedIntoSponsorshipsWei()
const myUnstakedAmount = (await operatorContract.valueWithoutEarnings()) - myStakedAmount
logger.debug('Analysis state', {
stakeableSponsorships: [...stakeableSponsorships.entries()].map(([sponsorshipId, config]) => ({
sponsorshipId,
payoutPerSec: formatEther(config.payoutPerSec)
})),
myCurrentStakes: [...myCurrentStakes.entries()].map(([sponsorshipId, amount]) => ({
sponsorshipId,
amount: formatEther(amount)
})),
balance: {
unstaked: formatEther(myUnstakedAmount),
staked: formatEther(myStakedAmount)
}
})
const actions = adjustStakes({
myCurrentStakes,
myUnstakedAmount,
stakeableSponsorships,
operatorContractAddress: this.pluginConfig.operatorContractAddress,
maxSponsorshipCount: this.pluginConfig.maxSponsorshipCount,
minTransactionAmount: parseEther(String(this.pluginConfig.minTransactionDataTokenAmount)),
minStakePerSponsorship
})
const signer = await streamrClient.getSigner()
for (const action of actions) {
logger.info(`Execute action: ${action.type} ${formatEther(action.amount)} ${action.sponsorshipId}`)
await getStakeOrUnstakeFunction(action)(signer,
this.pluginConfig.operatorContractAddress,
action.sponsorshipId,
action.amount
)
}
}

private async getStakeableSponsorships(
stakes: Map<SponsorshipID, WeiAmount>,
streamrClient: StreamrClient
): Promise<Map<SponsorshipID, SponsorshipConfig>> {
const queryResult = streamrClient.getTheGraphClient().queryEntities<SponsorshipQueryResultItem>((lastId: string, pageSize: number) => {
// TODO add support spnsorships which have non-zero minimumStakingPeriodSeconds (i.e. implement some loggic in the
// payoutPropotionalStrategy so that we ensure that unstaking doesn't happen too soon)
return {
query: `
{
sponsorships (
where: {
projectedInsolvency_gt: ${Math.floor(Date.now() / 1000)}
minimumStakingPeriodSeconds: "0"
minOperators_lte: ${this.pluginConfig.maxAcceptableMinOperatorCount}
totalPayoutWeiPerSec_gte: "${MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND.toString()}"
id_gt: "${lastId}"
},
first: ${pageSize}
) {
id
totalPayoutWeiPerSec
operatorCount
maxOperators
}
}
`
}
})
const sponsorships = await collect(queryResult)
const hasAcceptableOperatorCount = (item: SponsorshipQueryResultItem) => {
if (stakes.has(item.id)) {
// this operator has already staked to the sponsorship: keep the sponsorship in the list so that
// we don't unstake from it
return true
} else {
return (item.maxOperators === null) || (item.operatorCount < item.maxOperators)
}
}
return new Map(sponsorships.filter(hasAcceptableOperatorCount).map(
(sponsorship) => [sponsorship.id, {
payoutPerSec: BigInt(sponsorship.totalPayoutWeiPerSec),
}])
)
}

private async getMyCurrentStakes(streamrClient: StreamrClient): Promise<Map<SponsorshipID, WeiAmount>> {
const queryResult = streamrClient.getTheGraphClient().queryEntities<StakeQueryResultItem>((lastId: string, pageSize: number) => {
return {
query: `
{
stakes (
where: {
operator: "${this.pluginConfig.operatorContractAddress.toLowerCase()}",
id_gt: "${lastId}"
},
first: ${pageSize}
) {
id
sponsorship {
id
}
amountWei
}
}
`
}
})
const stakes = await collect(queryResult)
return new Map(stakes.map((stake) => [stake.sponsorship.id, BigInt(stake.amountWei) ]))
}

async stop(): Promise<void> {
logger.info('Stop autostaker plugin')
this.abortController.abort()
}

// eslint-disable-next-line class-methods-use-this
override getConfigSchema(): Schema {
return PLUGIN_CONFIG_SCHEMA
}
}
41 changes: 41 additions & 0 deletions packages/node/src/plugins/autostaker/config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$id": "config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"description": "Autostaker plugin configuration",
"additionalProperties": false,
"required": [
"operatorContractAddress"
],
"properties": {
"operatorContractAddress": {
"type": "string",
"description": "Operator contract Ethereum address",
"format": "ethereum-address"
},
"maxSponsorshipCount": {
"type": "integer",
"description": "Maximum count of sponsorships which are staked at any given time",
"minimum": 1,
"default": 100
},
"minTransactionDataTokenAmount": {
"type": "integer",
"description": "Minimum data token amount for stake/unstake transaction",
"minimum": 0,
"default": 1000
},
"maxAcceptableMinOperatorCount": {
"type": "integer",
"description": "Maximum acceptable value for a sponsorship's minOperatorCount config option",
"minimum": 0,
"default": 100
},
"runIntervalInMs": {
"type": "integer",
"description": "The interval (in milliseconds) at which autostaking possibilities are analyzed and executed",
"minimum": 0,
"default": 3600000
}
}
}
Loading
Loading