Skip to content

Commit 2e55453

Browse files
authored
feat(node): [NET-1455] Autostaker plugin (#3086)
## Summary An experimental plugin that automatically manages sponsorship stakings. It periodically checks which sponsorships offer good payouts and stakes tokens to those sponsorships when possible (i.e. if there are unstaked tokens available or tokens can be unstaked from another sponsorship with a worse payout). ## Features - Uses a payout-proportional strategy, see details in the comment of `payoutProportionalStrategy.ts` - Simple error handling: if an error occurs during staking or unstaking, all subsequent actions in that run are skipped (this is acceptable because the actions will be retried in the next scheduled run) - Uses an approximate scheduler - Operators select different sponsorships if multiple sponsorships have the same `payoutPerSec` - Filters out: - small transactions (the minimum transaction size is configurable via `minTransactionDataTokenAmount`) - sponsorships with too small payouts - sponsorships with a non-zero `minimumStakingPeriodSeconds` - sponsorships that require too many operators to join before payouts start (configurable via `maxAcceptableMinOperatorCount`) - sponsorships that have already reached the maximum number of operator stakes ## Authentication As the plugin does transaction on behalf of the operator, it needs either operator's private key or a `CONTROLLER` role assigned to it so that it can act as a [staking agent](https://docs.streamr.network/streamr-network/network-roles/operators/#staking-agents). Granting the role is the preferred approach since controllers don't have permission to withdraw funds from the `Operator` contract ## Future Improvements - Support sponsorships with `minimumStakingPeriodSeconds` by implementing logic to prevent premature unstaking, instead of filtering them out. - Convert the smoke test to an integration test and run it in CI. The test is currently quite slow. - Consider combining the rounding logic and the filtering of small transactions. A possible approach: 1) skip rounding, 2) filter out transactions that are too small, 3) balance the net change by removing or reducing the smallest stakes. This would stake all available tokens but might do too many transactions overall.
1 parent 7e4c7a2 commit 2e55453

File tree

9 files changed

+1048
-0
lines changed

9 files changed

+1048
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Changes before Tatum release are not documented in this file.
4949

5050
#### Added
5151

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

5456
- **BREAKING CHANGE**: Node.js v20 or higher is required (https://github.com/streamr-dev/network/pull/3138)

packages/node/src/pluginRegistry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Plugin } from './Plugin'
22
import { StrictConfig } from './config/config'
3+
import { AutostakerPlugin } from './plugins/autostaker/AutostakerPlugin'
34
import { ConsoleMetricsPlugin } from './plugins/consoleMetrics/ConsoleMetricsPlugin'
45
import { HttpPlugin } from './plugins/http/HttpPlugin'
56
import { InfoPlugin } from './plugins/info/InfoPlugin'
@@ -27,6 +28,8 @@ export const createPlugin = (name: string, brokerConfig: StrictConfig): Plugin<a
2728
return new SubscriberPlugin(name, brokerConfig)
2829
case 'info':
2930
return new InfoPlugin(name, brokerConfig)
31+
case 'autostaker':
32+
return new AutostakerPlugin(name, brokerConfig)
3033
default:
3134
throw new Error(`Unknown plugin: ${name}`)
3235
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { _operatorContractUtils, SignerWithProvider, StreamrClient } from '@streamr/sdk'
2+
import { collect, Logger, scheduleAtApproximateInterval, TheGraphClient, WeiAmount } from '@streamr/utils'
3+
import { Schema } from 'ajv'
4+
import { formatEther, parseEther } from 'ethers'
5+
import { Plugin } from '../../Plugin'
6+
import PLUGIN_CONFIG_SCHEMA from './config.schema.json'
7+
import { adjustStakes } from './payoutProportionalStrategy'
8+
import { Action, SponsorshipConfig, SponsorshipID } from './types'
9+
10+
export interface AutostakerPluginConfig {
11+
operatorContractAddress: string
12+
maxSponsorshipCount: number
13+
minTransactionDataTokenAmount: number
14+
maxAcceptableMinOperatorCount: number
15+
runIntervalInMs: number
16+
}
17+
18+
interface SponsorshipQueryResultItem {
19+
id: SponsorshipID
20+
totalPayoutWeiPerSec: WeiAmount
21+
operatorCount: number
22+
maxOperators: number | null
23+
}
24+
25+
interface StakeQueryResultItem {
26+
id: string
27+
sponsorship: {
28+
id: SponsorshipID
29+
}
30+
amountWei: WeiAmount
31+
}
32+
33+
const logger = new Logger(module)
34+
35+
// 1e12 wei, i.e. one millionth of one DATA token (we can tweak this later if needed)
36+
const MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND = 1000000000000n
37+
38+
const fetchMinStakePerSponsorship = async (theGraphClient: TheGraphClient): Promise<bigint> => {
39+
const queryResult = await theGraphClient.queryEntity<{ network: { minimumStakeWei: string } }>({
40+
query: `
41+
{
42+
network (id: "network-entity-id") {
43+
minimumStakeWei
44+
}
45+
}
46+
`
47+
})
48+
return BigInt(queryResult.network.minimumStakeWei)
49+
}
50+
51+
const getStakeOrUnstakeFunction = (action: Action): (
52+
operatorOwnerWallet: SignerWithProvider,
53+
operatorContractAddress: string,
54+
sponsorshipContractAddress: string,
55+
amount: WeiAmount
56+
) => Promise<void> => {
57+
switch (action.type) {
58+
case 'stake':
59+
return _operatorContractUtils.stake
60+
case 'unstake':
61+
return _operatorContractUtils.unstake
62+
default:
63+
throw new Error('assertion failed')
64+
}
65+
}
66+
67+
export class AutostakerPlugin extends Plugin<AutostakerPluginConfig> {
68+
69+
private abortController: AbortController = new AbortController()
70+
71+
async start(streamrClient: StreamrClient): Promise<void> {
72+
logger.info('Start autostaker plugin')
73+
const minStakePerSponsorship = await fetchMinStakePerSponsorship(streamrClient.getTheGraphClient())
74+
scheduleAtApproximateInterval(async () => {
75+
try {
76+
await this.runActions(streamrClient, minStakePerSponsorship)
77+
} catch (err) {
78+
logger.warn('Error while running autostaker actions', { err })
79+
}
80+
}, this.pluginConfig.runIntervalInMs, 0.1, false, this.abortController.signal)
81+
}
82+
83+
private async runActions(streamrClient: StreamrClient, minStakePerSponsorship: bigint): Promise<void> {
84+
logger.info('Run autostaker analysis')
85+
const provider = (await streamrClient.getSigner()).provider
86+
const operatorContract = _operatorContractUtils.getOperatorContract(this.pluginConfig.operatorContractAddress)
87+
.connect(provider)
88+
const myCurrentStakes = await this.getMyCurrentStakes(streamrClient)
89+
const stakeableSponsorships = await this.getStakeableSponsorships(myCurrentStakes, streamrClient)
90+
const myStakedAmount = await operatorContract.totalStakedIntoSponsorshipsWei()
91+
const myUnstakedAmount = (await operatorContract.valueWithoutEarnings()) - myStakedAmount
92+
logger.debug('Analysis state', {
93+
stakeableSponsorships: [...stakeableSponsorships.entries()].map(([sponsorshipId, config]) => ({
94+
sponsorshipId,
95+
payoutPerSec: formatEther(config.payoutPerSec)
96+
})),
97+
myCurrentStakes: [...myCurrentStakes.entries()].map(([sponsorshipId, amount]) => ({
98+
sponsorshipId,
99+
amount: formatEther(amount)
100+
})),
101+
balance: {
102+
unstaked: formatEther(myUnstakedAmount),
103+
staked: formatEther(myStakedAmount)
104+
}
105+
})
106+
const actions = adjustStakes({
107+
myCurrentStakes,
108+
myUnstakedAmount,
109+
stakeableSponsorships,
110+
operatorContractAddress: this.pluginConfig.operatorContractAddress,
111+
maxSponsorshipCount: this.pluginConfig.maxSponsorshipCount,
112+
minTransactionAmount: parseEther(String(this.pluginConfig.minTransactionDataTokenAmount)),
113+
minStakePerSponsorship
114+
})
115+
const signer = await streamrClient.getSigner()
116+
for (const action of actions) {
117+
logger.info(`Execute action: ${action.type} ${formatEther(action.amount)} ${action.sponsorshipId}`)
118+
await getStakeOrUnstakeFunction(action)(signer,
119+
this.pluginConfig.operatorContractAddress,
120+
action.sponsorshipId,
121+
action.amount
122+
)
123+
}
124+
}
125+
126+
private async getStakeableSponsorships(
127+
stakes: Map<SponsorshipID, WeiAmount>,
128+
streamrClient: StreamrClient
129+
): Promise<Map<SponsorshipID, SponsorshipConfig>> {
130+
const queryResult = streamrClient.getTheGraphClient().queryEntities<SponsorshipQueryResultItem>((lastId: string, pageSize: number) => {
131+
// TODO add support spnsorships which have non-zero minimumStakingPeriodSeconds (i.e. implement some loggic in the
132+
// payoutPropotionalStrategy so that we ensure that unstaking doesn't happen too soon)
133+
return {
134+
query: `
135+
{
136+
sponsorships (
137+
where: {
138+
projectedInsolvency_gt: ${Math.floor(Date.now() / 1000)}
139+
minimumStakingPeriodSeconds: "0"
140+
minOperators_lte: ${this.pluginConfig.maxAcceptableMinOperatorCount}
141+
totalPayoutWeiPerSec_gte: "${MIN_SPONSORSHIP_TOTAL_PAYOUT_PER_SECOND.toString()}"
142+
id_gt: "${lastId}"
143+
},
144+
first: ${pageSize}
145+
) {
146+
id
147+
totalPayoutWeiPerSec
148+
operatorCount
149+
maxOperators
150+
}
151+
}
152+
`
153+
}
154+
})
155+
const sponsorships = await collect(queryResult)
156+
const hasAcceptableOperatorCount = (item: SponsorshipQueryResultItem) => {
157+
if (stakes.has(item.id)) {
158+
// this operator has already staked to the sponsorship: keep the sponsorship in the list so that
159+
// we don't unstake from it
160+
return true
161+
} else {
162+
return (item.maxOperators === null) || (item.operatorCount < item.maxOperators)
163+
}
164+
}
165+
return new Map(sponsorships.filter(hasAcceptableOperatorCount).map(
166+
(sponsorship) => [sponsorship.id, {
167+
payoutPerSec: BigInt(sponsorship.totalPayoutWeiPerSec),
168+
}])
169+
)
170+
}
171+
172+
private async getMyCurrentStakes(streamrClient: StreamrClient): Promise<Map<SponsorshipID, WeiAmount>> {
173+
const queryResult = streamrClient.getTheGraphClient().queryEntities<StakeQueryResultItem>((lastId: string, pageSize: number) => {
174+
return {
175+
query: `
176+
{
177+
stakes (
178+
where: {
179+
operator: "${this.pluginConfig.operatorContractAddress.toLowerCase()}",
180+
id_gt: "${lastId}"
181+
},
182+
first: ${pageSize}
183+
) {
184+
id
185+
sponsorship {
186+
id
187+
}
188+
amountWei
189+
}
190+
}
191+
`
192+
}
193+
})
194+
const stakes = await collect(queryResult)
195+
return new Map(stakes.map((stake) => [stake.sponsorship.id, BigInt(stake.amountWei) ]))
196+
}
197+
198+
async stop(): Promise<void> {
199+
logger.info('Stop autostaker plugin')
200+
this.abortController.abort()
201+
}
202+
203+
// eslint-disable-next-line class-methods-use-this
204+
override getConfigSchema(): Schema {
205+
return PLUGIN_CONFIG_SCHEMA
206+
}
207+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"$id": "config.schema.json",
3+
"$schema": "http://json-schema.org/draft-07/schema#",
4+
"type": "object",
5+
"description": "Autostaker plugin configuration",
6+
"additionalProperties": false,
7+
"required": [
8+
"operatorContractAddress"
9+
],
10+
"properties": {
11+
"operatorContractAddress": {
12+
"type": "string",
13+
"description": "Operator contract Ethereum address",
14+
"format": "ethereum-address"
15+
},
16+
"maxSponsorshipCount": {
17+
"type": "integer",
18+
"description": "Maximum count of sponsorships which are staked at any given time",
19+
"minimum": 1,
20+
"default": 100
21+
},
22+
"minTransactionDataTokenAmount": {
23+
"type": "integer",
24+
"description": "Minimum data token amount for stake/unstake transaction",
25+
"minimum": 0,
26+
"default": 1000
27+
},
28+
"maxAcceptableMinOperatorCount": {
29+
"type": "integer",
30+
"description": "Maximum acceptable value for a sponsorship's minOperatorCount config option",
31+
"minimum": 0,
32+
"default": 100
33+
},
34+
"runIntervalInMs": {
35+
"type": "integer",
36+
"description": "The interval (in milliseconds) at which autostaking possibilities are analyzed and executed",
37+
"minimum": 0,
38+
"default": 3600000
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)