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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions packages/node/bin/autostaker-manual-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Do not merge this manual test to main

NODE_PRIVATE_KEY="1111111111111111111111111111111111111111111111111111111111111111"
OWNER_PRIVATE_KEY="2222222222222222222222222222222222222222222222222222222222222222"
Copy link
Contributor

@jtakalai jtakalai Apr 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We wouldn't like to require that operators provide their private key for this plugin. What other possibilities there are? The nodes can nowadays vote and flag behalf of the operator, so maybe they could also stake/unstake if that kind of permission could be added?

Nodes do node things; there's another role that can be used for staking/unstaking: CONTROLLER_ROLE. This is added by operator calling operatorContract.grantRole("0x7b765e0e932d348852a6f810bfa1ab891e259123f02db8cdcde614c570223357", controllerAddress) (where that hex mush can be read from operatorContract.CONTROLLER_ROLE())

This is probably preferable as a key management mechanism, so that the autostaker would be run with a private key of this kind of "controller".

There's revokeRole for removing that role.

SPONSORER_PRIVATE_KEY="3333333333333333333333333333333333333333333333333333333333333333"
EARNINGS_PER_SECOND=12
STAKED_AMOUNT=500000
SPONSOR_AMOUNT=1234567

NODE_ADDRESS=$(ethereum-address $NODE_PRIVATE_KEY | jq -r '.address')
OWNER_ADDRESS=$(ethereum-address $OWNER_PRIVATE_KEY | jq -r '.address')
SPONSORER_ADDRESS=$(ethereum-address $SPONSORER_PRIVATE_KEY | jq -r '.address')

cd ../../cli-tools
echo 'Mint tokens'
npx tsx bin/streamr.ts internal token-mint $NODE_ADDRESS 10000000 10000000
npx tsx bin/streamr.ts internal token-mint $OWNER_ADDRESS 10000000 10000000
echo 'Create operator'
OPERATOR_CONTRACT_ADDRESS=$(npx tsx bin/streamr.ts internal operator-create -c 10 --node-addresses $NODE_ADDRESS --env dev2 --private-key $OWNER_PRIVATE_KEY | jq -r '.address')
npx tsx bin/streamr.ts internal token-mint $SPONSORER_ADDRESS 10000000 10000000
npx tsx bin/streamr.ts stream create /foo --env dev2 --private-key $SPONSORER_PRIVATE_KEY
SPONSORSHIP_CONTRACT_ADDRESS=$(npx tsx bin/streamr.ts internal sponsorship-create /foo -e $EARNINGS_PER_SECOND --env dev2 --private-key $SPONSORER_PRIVATE_KEY | jq -r '.address')
npx tsx bin/streamr.ts internal sponsorship-sponsor $SPONSORSHIP_CONTRACT_ADDRESS $SPONSOR_AMOUNT --env dev2 --private-key $SPONSORER_PRIVATE_KEY
npx tsx bin/streamr.ts internal operator-delegate $OPERATOR_CONTRACT_ADDRESS $STAKED_AMOUNT --env dev2 --private-key $OWNER_PRIVATE_KEY

jq -n \
--arg nodePrivateKey "$NODE_PRIVATE_KEY" \
--arg operatorOwnerPrivateKey "$OWNER_PRIVATE_KEY" \
--arg operatorContractAddress "$OPERATOR_CONTRACT_ADDRESS" \
'{
"$schema": "https://schema.streamr.network/config-v3.schema.json",
client: {
auth: {
privateKey: $nodePrivateKey
},
environment: "dev2"
},
plugins: {
autostaker: {
operatorOwnerPrivateKey: $operatorOwnerPrivateKey,
operatorContractAddress: $operatorContractAddress
}
}
}' > ../node/configs/autostaker.json

jq -n \
--arg operatorContract "$OPERATOR_CONTRACT_ADDRESS" \
--arg sponsorshipContract "$SPONSORSHIP_CONTRACT_ADDRESS" \
'$ARGS.named'

cd ../node
npx tsx bin/streamr-node.ts configs/autostaker.json
1 change: 1 addition & 0 deletions packages/node/src/config/validateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const validateConfig = (data: unknown, schema: Schema, contextName?: stri
})
addFormats(ajv)
ajv.addFormat('ethereum-address', /^0x[a-zA-Z0-9]{40}$/)
ajv.addFormat('ethereum-private-key', /^(0x)?[a-zA-Z0-9]{64}$/)
ajv.addSchema(DEFINITIONS_SCHEMA)
if (!ajv.validate(schema, data)) {
const prefix = (contextName !== undefined) ? (contextName + ': ') : ''
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
87 changes: 87 additions & 0 deletions packages/node/src/plugins/autostaker/AutostakerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { _operatorContractUtils, StreamrClient } from '@streamr/sdk'
import { collect, Logger, scheduleAtInterval, WeiAmount } from '@streamr/utils'
import { Schema } from 'ajv'
import { formatEther, parseEther, Wallet } from 'ethers'
import sample from 'lodash/sample'
import { Plugin } from '../../Plugin'
import PLUGIN_CONFIG_SCHEMA from './config.schema.json'

export interface AutostakerPluginConfig {
operatorContractAddress: string
// TODO is it possible implement this without exposing the private key here?
// e.g. by configuring so that operator nodes can stake behalf of the operator?
operatorOwnerPrivateKey: string
runIntervalInMs: number
}

const STAKE_AMOUNT: WeiAmount = parseEther('10000')

const logger = new Logger(module)

export class AutostakerPlugin extends Plugin<AutostakerPluginConfig> {

private abortController: AbortController = new AbortController()

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

private async runActions(streamrClient: StreamrClient): Promise<void> {
logger.info('Run autostaker actions')
const provider = (await streamrClient.getSigner()).provider
const operatorContract = _operatorContractUtils.getOperatorContract(this.pluginConfig.operatorContractAddress)
.connect(new Wallet(this.pluginConfig.operatorOwnerPrivateKey, provider))
const stakedAmount = await operatorContract.totalStakedIntoSponsorshipsWei()
const availableBalance = (await operatorContract.valueWithoutEarnings()) - stakedAmount
logger.info(`Available balance: ${formatEther(availableBalance)} (staked=${formatEther(stakedAmount)})`)
// TODO is there a better way to get the client? Maybe we should add StreamrClient#getTheGraphClient()
// TODO what are good where consitions for the sponsorships query
// @ts-expect-error private
const queryResult = streamrClient.theGraphClient.queryEntities((lastId: string, pageSize: number) => {
return {
query: `
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How we should query the possible sponsorships, which we could stake to? Now we use projectedInsolvency_gt and spotAPY_gt fllters, but maybe these are not optimall.

remainingWei_gt: "0" is same as the projectedInsolvency, and clearer IMO. It's a good way to select only "active" sponsorships.

There might not need to be hard cutoffs for other this, just order by totalPayoutWeiPerSec desc should be fine, take some reasonable maximum number, e.g. 100.

{
sponsorships(
where: {
projectedInsolvency_gt: ${Math.floor(Date.now() / 1000)},
spotAPY_gt: 0
id_gt: "${lastId}"
},
first: ${pageSize}
) {
id
}
}
`
}
})
const sponsorships: { id: string }[] = await collect(queryResult)
logger.info(`Available sponsorships: ${sponsorships.map((s) => s.id).join(',')}`)
if ((sponsorships.length) > 0 && (availableBalance >= STAKE_AMOUNT)) {
const targetSponsorship = sample(sponsorships)!
logger.info(`Stake ${formatEther(STAKE_AMOUNT)} to ${targetSponsorship.id}`)
await _operatorContractUtils.stake(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "what" to do should be separated from the "when", like it is in the simulator

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. put "what to do with the sponsorships from thegraph" into a separate strategies directory

operatorContract,
targetSponsorship.id,
STAKE_AMOUNT
)
}
}

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
}
}
29 changes: 29 additions & 0 deletions packages/node/src/plugins/autostaker/config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$id": "config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"description": "Autostaker plugin configuration",
"additionalProperties": false,
"required": [
"operatorContractAddress",
"operatorOwnerPrivateKey"
],
"properties": {
"operatorContractAddress": {
"type": "string",
"description": "Operator contract Ethereum address",
"format": "ethereum-address"
},
"operatorOwnerPrivateKey": {
"type": "string",
"description": "Operator owner's private key",
"format": "ethereum-private-key"
},
"runIntervalInMs": {
"type": "integer",
"description": "The interval (in milliseconds) at which autostaking possibilities are analyzed and executed",
"minimum": 0,
"default": 30000
}
}
}