-
Notifications
You must be signed in to change notification settings - Fork 41
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" | ||
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 |
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: ` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There might not need to be hard cutoffs for other this, just order by |
||
{ | ||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} |
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 | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 fromoperatorContract.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.