From 6f23ceedff49f1b8bda46af41b32ace583f2ecda Mon Sep 17 00:00:00 2001 From: Teo Gebhard Date: Fri, 25 Apr 2025 19:21:49 +0300 Subject: [PATCH 1/3] first draft --- packages/node/bin/autostaker-manual-test.sh | 52 +++++++++++ packages/node/src/config/validateConfig.ts | 1 + packages/node/src/pluginRegistry.ts | 3 + .../plugins/autostaker/AutostakerPlugin.ts | 87 +++++++++++++++++++ .../src/plugins/autostaker/config.schema.json | 29 +++++++ 5 files changed, 172 insertions(+) create mode 100755 packages/node/bin/autostaker-manual-test.sh create mode 100644 packages/node/src/plugins/autostaker/AutostakerPlugin.ts create mode 100644 packages/node/src/plugins/autostaker/config.schema.json diff --git a/packages/node/bin/autostaker-manual-test.sh b/packages/node/bin/autostaker-manual-test.sh new file mode 100755 index 0000000000..d2e9e25b8a --- /dev/null +++ b/packages/node/bin/autostaker-manual-test.sh @@ -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 diff --git a/packages/node/src/config/validateConfig.ts b/packages/node/src/config/validateConfig.ts index 8560c650e8..30fce45d09 100644 --- a/packages/node/src/config/validateConfig.ts +++ b/packages/node/src/config/validateConfig.ts @@ -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 + ': ') : '' diff --git a/packages/node/src/pluginRegistry.ts b/packages/node/src/pluginRegistry.ts index 1029d27d4c..2c795c5c35 100644 --- a/packages/node/src/pluginRegistry.ts +++ b/packages/node/src/pluginRegistry.ts @@ -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' @@ -27,6 +28,8 @@ export const createPlugin = (name: string, brokerConfig: StrictConfig): Plugin { + + private abortController: AbortController = new AbortController() + + async start(streamrClient: StreamrClient): Promise { + 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 { + 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: ` + { + 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( + operatorContract, + targetSponsorship.id, + STAKE_AMOUNT + ) + } + } + + async stop(): Promise { + logger.info('Stop autostaker plugin') + this.abortController.abort() + } + + // eslint-disable-next-line class-methods-use-this + override getConfigSchema(): Schema { + return PLUGIN_CONFIG_SCHEMA + } +} diff --git a/packages/node/src/plugins/autostaker/config.schema.json b/packages/node/src/plugins/autostaker/config.schema.json new file mode 100644 index 0000000000..f418d2e570 --- /dev/null +++ b/packages/node/src/plugins/autostaker/config.schema.json @@ -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 + } + } +} From d4968e593638d59a53e6b1927b8f7fc237af4239 Mon Sep 17 00:00:00 2001 From: Teo Gebhard Date: Mon, 19 May 2025 11:08:01 +0300 Subject: [PATCH 2/3] payoutProportional strategy (mostly copy-paste from autostaker repo) --- .../plugins/autostaker/AutostakerPlugin.ts | 110 ++++++++-- .../autostaker/payoutProportionalStrategy.ts | 113 ++++++++++ packages/node/src/plugins/autostaker/types.ts | 46 ++++ .../payoutProportionalStrategy.test.ts | 206 ++++++++++++++++++ packages/utils/src/exports.ts | 1 + packages/utils/src/sum.ts | 3 + 6 files changed, 459 insertions(+), 20 deletions(-) create mode 100644 packages/node/src/plugins/autostaker/payoutProportionalStrategy.ts create mode 100644 packages/node/src/plugins/autostaker/types.ts create mode 100644 packages/node/test/unit/plugins/autostaker/payoutProportionalStrategy.test.ts create mode 100644 packages/utils/src/sum.ts diff --git a/packages/node/src/plugins/autostaker/AutostakerPlugin.ts b/packages/node/src/plugins/autostaker/AutostakerPlugin.ts index 6df364d502..af7c4a08ff 100644 --- a/packages/node/src/plugins/autostaker/AutostakerPlugin.ts +++ b/packages/node/src/plugins/autostaker/AutostakerPlugin.ts @@ -1,10 +1,11 @@ import { _operatorContractUtils, SignerWithProvider, 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 { formatEther, Wallet } from 'ethers' import { Plugin } from '../../Plugin' import PLUGIN_CONFIG_SCHEMA from './config.schema.json' +import { adjustStakes } from './payoutProportionalStrategy' +import { Action, SponsorshipId, SponsorshipState } from './types' export interface AutostakerPluginConfig { operatorContractAddress: string @@ -14,10 +15,24 @@ export interface AutostakerPluginConfig { runIntervalInMs: number } -const STAKE_AMOUNT: WeiAmount = parseEther('10000') - const logger = new Logger(module) +const getStakeOrUnstakeFunction = (action: Action): ( + operatorOwnerWallet: SignerWithProvider, + operatorContractAddress: string, + sponsorshipContractAddress: string, + amount: WeiAmount +) => Promise => { + switch (action.type) { + case 'stake': + return _operatorContractUtils.stake + case 'unstake': + return _operatorContractUtils.unstake + default: + throw new Error('assertion failed') + } +} + export class AutostakerPlugin extends Plugin { private abortController: AbortController = new AbortController() @@ -38,11 +53,41 @@ export class AutostakerPlugin extends Plugin { const provider = (await streamrClient.getSigner()).provider const operatorContract = _operatorContractUtils.getOperatorContract(this.pluginConfig.operatorContractAddress) .connect(provider) - const stakedAmount = await operatorContract.totalStakedIntoSponsorshipsWei() - const availableBalance = (await operatorContract.valueWithoutEarnings()) - stakedAmount - logger.info(`Available balance: ${formatEther(availableBalance)} (staked=${formatEther(stakedAmount)})`) + const stakedWei = await operatorContract.totalStakedIntoSponsorshipsWei() + const unstakedWei = (await operatorContract.valueWithoutEarnings()) - stakedWei + logger.info(`Balance: unstaked=${formatEther(unstakedWei)}, staked=${formatEther(stakedWei)}`) + const stakeableSponsorships = await this.getStakeableSponsorships(streamrClient) + logger.info(`Stakeable sponsorships: ${[...stakeableSponsorships.keys()].join(',')}`) + const stakes = await this.getStakes(streamrClient) + const stakeDescription = [...stakes.entries()].map(([sponsorshipId, amountWei]) => `${sponsorshipId}=${formatEther(amountWei)}`).join(', ') + logger.info(`Stakes before adjustments: ${stakeDescription}`) + const actions = adjustStakes({ + operatorState: { + stakes, + unstakedWei + }, + operatorConfig: {}, // TODO add maxSponsorshipCount + stakeableSponsorships, + environmentConfig: { + minimumStakeWei: 5000000000000000000000n // TODO read from The Graph (network.minimumStakeWei) + } + }) + const operatorOwnerWallet = new Wallet(this.pluginConfig.operatorOwnerPrivateKey, provider) as SignerWithProvider + for (const action of actions) { + logger.info(`Action: ${action.type} ${formatEther(action.amount)} ${action.sponsorshipId}`) + await getStakeOrUnstakeFunction(action)(operatorOwnerWallet, + this.pluginConfig.operatorContractAddress, + action.sponsorshipId, + action.amount + ) + } + } + + // eslint-disable-next-line class-methods-use-this + private async getStakeableSponsorships(streamrClient: StreamrClient): Promise> { // 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 + // TODO what are good where conditions for the sponsorships query so that we get all stakeable sponsorships + // but no non-stakables (e.g. expired) // @ts-expect-error private const queryResult = streamrClient.theGraphClient.queryEntities((lastId: string, pageSize: number) => { return { @@ -57,23 +102,48 @@ export class AutostakerPlugin extends Plugin { first: ${pageSize} ) { id + totalPayoutWeiPerSec + totalStakedWei } } ` } }) - 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( - new Wallet(this.pluginConfig.operatorOwnerPrivateKey, provider) as SignerWithProvider, - this.pluginConfig.operatorContractAddress, - targetSponsorship.id, - STAKE_AMOUNT - ) - } + const sponsorships: { id: SponsorshipId, totalPayoutWeiPerSec: bigint, totalStakedWei: bigint }[] = await collect(queryResult) + return new Map(sponsorships.map( + (sponsorship) => [sponsorship.id, { + totalPayoutWeiPerSec: BigInt(sponsorship.totalPayoutWeiPerSec), + totalStakedWei: BigInt(sponsorship.totalStakedWei) + }]) + ) + } + + private async getStakes(streamrClient: StreamrClient): Promise> { + // TODO use StreamrClient#getTheGraphClient() + // @ts-expect-error private + const queryResult = streamrClient.theGraphClient.queryEntities((lastId: string, pageSize: number) => { + return { + query: ` + { + stakes( + where: { + operator: "${this.pluginConfig.operatorContractAddress.toLowerCase()}", + id_gt: "${lastId}" + }, + first: ${pageSize} + ) { + id + sponsorship { + id + } + amountWei + } + } + ` + } + }) + const stakes: { sponsorship: { id: SponsorshipId }, amountWei: bigint }[] = await collect(queryResult) + return new Map(stakes.map((stake) => [stake.sponsorship.id, BigInt(stake.amountWei) ])) } async stop(): Promise { diff --git a/packages/node/src/plugins/autostaker/payoutProportionalStrategy.ts b/packages/node/src/plugins/autostaker/payoutProportionalStrategy.ts new file mode 100644 index 0000000000..ce3c719813 --- /dev/null +++ b/packages/node/src/plugins/autostaker/payoutProportionalStrategy.ts @@ -0,0 +1,113 @@ +import { sum } from '@streamr/utils' +import partition from 'lodash/partition' +import { Action, AdjustStakesFn } from './types' + +/** + * Allocate stake in proportion to the payout each sponsorship gives. + * Detailed allocation formula: each sponsorship should get the stake M + P / T, + * where M is the environment-mandated minimum stake, + * P is `totalPayoutWeiPerSec` of the considered sponsorship, and + * T is sum of `totalPayoutWeiPerSec` of all sponsorships this operator stakes to. + * `totalPayoutWeiPerSec` is the total payout per second to all staked operators. + * + * In order to prevent that operators' actions cause other operators to change their plans, + * this strategy only takes into account the payout, not what others have staked. + * + * In order to also prevent too much churn (joining and leaving), + * this strategy will keep those sponsorships that they already have staked to, as long as they keep paying. + * Eventually sponsorships expire, and then the strategy will reallocate that stake elsewhere. + * + * This strategy tries to stake to as many sponsorships as it can afford. + * Since smaller amounts than the minimum stake can't be staked, + * the tokens available to the operator limits the number of sponsorships they can stake to. + * + * Example: + * - there are sponsorships that pay out 2, 4, and 6 DATA/sec respectively. + * - minimum stake is 5000 DATA + * - operator has 11000 DATA (tokens in the contract plus currently staked tokens) + * 1. it only has enough for 2 sponsorships, so it chooses the ones that pay 4 and 6 DATA/sec + * 2. it allocates 5000 DATA to each of the 2 sponsorships (minimum stake) + * 3. it allocates the remaining 1000 DATA in proportion to the payout: + * - 1000 * 4 / 10 = 400 DATA to the 4-paying sponsorship + * - 1000 * 6 / 10 = 600 DATA to the 6-paying sponsorship + * - final target stakes are: 0, 5400, and 5600 DATA to the three sponsorships + * - the algorithm then outputs the stake and unstake actions to change the allocation to the target + **/ +export const adjustStakes: AdjustStakesFn = ({ + operatorState, + operatorConfig, + stakeableSponsorships, + environmentConfig +}): Action[] => { + const { unstakedWei, stakes } = operatorState + const { minimumStakeWei } = environmentConfig + const totalStakeableWei = sum([...stakes.values()]) + unstakedWei + + if (Array.from(stakeableSponsorships.values()).find(({ totalPayoutWeiPerSec }) => totalPayoutWeiPerSec <= 0n)) { + throw new Error('payoutProportional: sponsorships must have positive totalPayoutWeiPerSec') + } + + // find the number of sponsorships that we can afford to stake to + const targetSponsorshipCount = Math.min( + stakeableSponsorships.size, + operatorConfig.maxSponsorshipCount ?? Infinity, + Math.floor(Number(totalStakeableWei) / Number(minimumStakeWei)), + ) + + const sponsorshipList = Array.from(stakeableSponsorships.entries()).map( + ([id, { totalPayoutWeiPerSec }]) => ({ id, totalPayoutWeiPerSec }) + ) + + // separate the stakeable sponsorships that we already have stakes in + const [ + keptSponsorships, + potentialSponsorships, + ] = partition(sponsorshipList, ({ id }) => stakes.has(id)) + + // TODO: add secondary sorting of potentialSponsorships based on operator ID + sponsorship ID + // idea is: in case of a tie, operators should stake to different sponsorships + + // pad the kept sponsorships to the target number, in the order of decreasing payout + potentialSponsorships.sort((a, b) => Number(b.totalPayoutWeiPerSec) - Number(a.totalPayoutWeiPerSec)) + const selectedSponsorships = keptSponsorships + .concat(potentialSponsorships) + .slice(0, targetSponsorshipCount) + + // calculate the target stakes for each sponsorship: minimum stake plus payout-proportional allocation + const minimumStakesWei = BigInt(selectedSponsorships.length) * minimumStakeWei + const payoutProportionalWei = totalStakeableWei - minimumStakesWei + const payoutSumWeiPerSec = sum(selectedSponsorships.map(({ totalPayoutWeiPerSec }) => totalPayoutWeiPerSec)) + + const targetStakes = new Map(payoutSumWeiPerSec > 0n ? selectedSponsorships.map(({ id, totalPayoutWeiPerSec }) => + [id, minimumStakeWei + payoutProportionalWei * totalPayoutWeiPerSec / payoutSumWeiPerSec]) : []) + + // calculate the stake differences for all sponsorships we have stakes in, or want to stake into + const sponsorshipIdList = Array.from(new Set([...stakes.keys(), ...targetStakes.keys()])) + const differencesWei = sponsorshipIdList.map((sponsorshipId) => ({ + sponsorshipId, + differenceWei: (targetStakes.get(sponsorshipId) ?? 0n) - (stakes.get(sponsorshipId) ?? 0n) + })).filter(({ differenceWei: difference }) => difference !== 0n) + + // TODO: filter out too small (TODO: decide what "too small" means) stakings and unstakings because those just waste gas + + // sort the differences in ascending order (unstakings first, then stakings) + differencesWei.sort((a, b) => Number(a.differenceWei) - Number(b.differenceWei)) + + // force the net staking to equal unstakedWei (fixes e.g. rounding errors) by adjusting the largest staking (last in list) + const netStakingWei = sum(differencesWei.map(({ differenceWei: difference }) => difference)) + if (netStakingWei !== unstakedWei && sponsorshipList.length > 0 && differencesWei.length > 0) { + const largestDifference = differencesWei.pop()! + largestDifference.differenceWei += unstakedWei - netStakingWei + // don't push back a zero difference + if (largestDifference.differenceWei !== 0n) { + differencesWei.push(largestDifference) + } + } + + // convert differences to actions + return differencesWei.map(({ sponsorshipId, differenceWei }) => ({ + type: differenceWei > 0n ? 'stake' : 'unstake', + sponsorshipId, + amount: differenceWei > 0n ? differenceWei : -differenceWei + })) +} diff --git a/packages/node/src/plugins/autostaker/types.ts b/packages/node/src/plugins/autostaker/types.ts new file mode 100644 index 0000000000..01441cb35b --- /dev/null +++ b/packages/node/src/plugins/autostaker/types.ts @@ -0,0 +1,46 @@ +export type SponsorshipId = string + +/** + * Actions that should be executed by the operator + */ +export interface Action { + type: 'stake' | 'unstake' + sponsorshipId: SponsorshipId + amount: bigint +} + +export type AdjustStakesFn = (opts: { + operatorState: OperatorState + operatorConfig: OperatorConfig + stakeableSponsorships: Map + environmentConfig: EnvironmentConfig +}) => Action[] + +/** + * Namings here reflect [thegraph schema](https://github.com/streamr-dev/network-contracts/blob/master/packages/network-subgraphs/schema.graphql#L435) + **/ +export interface SponsorshipState { + totalPayoutWeiPerSec: bigint + totalStakedWei: bigint +} + +/** + * Namings here reflect [thegraph schema](https://github.com/streamr-dev/network-contracts/blob/master/packages/network-subgraphs/schema.graphql#L435) + **/ +export interface OperatorState { + stakes: Map + unstakedWei: bigint +} + +export interface OperatorConfig { + maxSponsorshipCount?: number +} + +/** + * Network-wide constants, also available in thegraph + * @see https://github.com/streamr-dev/network-contracts/blob/master/packages/network-subgraphs/schema.graphql#L226 + */ +export interface EnvironmentConfig { + minimumStakeWei: bigint +} + diff --git a/packages/node/test/unit/plugins/autostaker/payoutProportionalStrategy.test.ts b/packages/node/test/unit/plugins/autostaker/payoutProportionalStrategy.test.ts new file mode 100644 index 0000000000..924c83102c --- /dev/null +++ b/packages/node/test/unit/plugins/autostaker/payoutProportionalStrategy.test.ts @@ -0,0 +1,206 @@ +import { adjustStakes } from '../../../../src/plugins/autostaker/payoutProportionalStrategy' + +describe('payoutProportionalStrategy', () => { + it('unstakes everything if no stakeable sponsorships', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 1234n, stakes: new Map([[ 'a', 1234n ]]) }, + operatorConfig: { }, + stakeableSponsorships: new Map(), + environmentConfig: { minimumStakeWei: 1234n }, + })).toEqual([ + { type: 'unstake', sponsorshipId: 'a', amount: 1234n }, + ]) + }) + + it('limits the targetSponsorshipCount to stakeableSponsorships.size', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 600n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }], + ['b', { totalPayoutWeiPerSec: 20n, totalStakedWei: 0n }], + ['c', { totalPayoutWeiPerSec: 30n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + }).sort((a, b) => a.sponsorshipId.localeCompare(b.sponsorshipId))).toEqual([ + { type: 'stake', sponsorshipId: 'a', amount: 100n }, + { type: 'stake', sponsorshipId: 'b', amount: 200n }, + { type: 'stake', sponsorshipId: 'c', amount: 300n }, + ]) + }) + + it('limits the targetSponsorshipCount to maxSponsorshipCount', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 500n, stakes: new Map() }, + operatorConfig: { maxSponsorshipCount: 2 }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }], // not included + ['b', { totalPayoutWeiPerSec: 20n, totalStakedWei: 0n }], // included + ['c', { totalPayoutWeiPerSec: 30n, totalStakedWei: 0n }], // included + ]), + environmentConfig: { minimumStakeWei: 0n }, + }).sort((a, b) => a.sponsorshipId.localeCompare(b.sponsorshipId))).toEqual([ + { type: 'stake', sponsorshipId: 'b', amount: 200n }, + { type: 'stake', sponsorshipId: 'c', amount: 300n }, + ]) + }) + + it('limits the targetSponsorshipCount to minimumStakeWei and available tokens', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 500n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }], // not included + ['b', { totalPayoutWeiPerSec: 20n, totalStakedWei: 0n }], // not included + ['c', { totalPayoutWeiPerSec: 30n, totalStakedWei: 0n }], // included + ]), + environmentConfig: { minimumStakeWei: 300n }, + }).sort((a, b) => a.sponsorshipId.localeCompare(b.sponsorshipId))).toEqual([ + { type: 'stake', sponsorshipId: 'c', amount: 500n }, + ]) + }) + + it('doesn\'t allocate tokens if less available than minimum stake', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 100n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }]]), + environmentConfig: { minimumStakeWei: 300n }, + })).toEqual([]) + }) + + // unstakes must happen first because otherwise there isn't enough tokens for staking + it('sends out unstakes before stakes', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 0n, stakes: new Map([ + [ 'a', 30n ], + [ 'b', 70n ], + ]) }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 40n, totalStakedWei: 1000n }], // add stake here + ['b', { totalPayoutWeiPerSec: 30n, totalStakedWei: 1000n }], // unstake from here + ['c', { totalPayoutWeiPerSec: 20n, totalStakedWei: 1000n }], // stake here + ['d', { totalPayoutWeiPerSec: 10n, totalStakedWei: 1000n }], // stake here + ]), + environmentConfig: { minimumStakeWei: 0n }, + }).map((a) => a.type)).toEqual([ + 'unstake', + 'stake', + 'stake', + 'stake', + ]) + }) + + it('unstakes from expired sponsorships', async () => { + // currently staked into b, but b has expired, so it's not included in the stakeableSponsorships + expect(adjustStakes({ + operatorState: { unstakedWei: 0n, stakes: new Map([[ 'b', 100n ]]) }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toEqual([ + { type: 'unstake', sponsorshipId: 'b', amount: 100n }, + { type: 'stake', sponsorshipId: 'a', amount: 100n }, + ]) + }) + + it('restakes expired sponsorship stakes into other sponsorships', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 0n, stakes: new Map([ + [ 'a', 100n ], + [ 'b', 100n ], + [ 'c', 100n ], + ]) }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 100n }], + ['b', { totalPayoutWeiPerSec: 10n, totalStakedWei: 100n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toEqual([ + { type: 'unstake', sponsorshipId: 'c', amount: 100n }, + { type: 'stake', sponsorshipId: 'a', amount: 50n }, + { type: 'stake', sponsorshipId: 'b', amount: 50n }, + ]) + }) + + it('handles rounding errors by adjusting the largest staking', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 1000n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 100n, totalStakedWei: 0n }], + ['b', { totalPayoutWeiPerSec: 100n, totalStakedWei: 0n }], + ['c', { totalPayoutWeiPerSec: 400n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toEqual([ + { type: 'stake', sponsorshipId: 'a', amount: 166n }, + { type: 'stake', sponsorshipId: 'b', amount: 166n }, + { type: 'stake', sponsorshipId: 'c', amount: 668n }, + ]) + }) + + it('rounding error no-op case', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 0n, stakes: new Map([ + ['a', 166n ], + ['b', 166n ], + ['c', 668n ], + ]) }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 100n, totalStakedWei: 166n }], + ['b', { totalPayoutWeiPerSec: 100n, totalStakedWei: 166n }], + ['c', { totalPayoutWeiPerSec: 400n, totalStakedWei: 668n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toEqual([]) + }) + + it('uses Infinity as default maxSponsorshipCount', async () => { + expect(adjustStakes({ + operatorState: { unstakedWei: 1000n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 10n, totalStakedWei: 0n }], + ['b', { totalPayoutWeiPerSec: 20n, totalStakedWei: 0n }], + ['c', { totalPayoutWeiPerSec: 30n, totalStakedWei: 0n }], + ['d', { totalPayoutWeiPerSec: 40n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toHaveLength(4) + }) + + describe('input validation', () => { + it('throws if there is a sponsorship with totalPayoutWeiPerSec == 0', async () => { + expect(() => adjustStakes({ + operatorState: { unstakedWei: 1000n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 123n, totalStakedWei: 0n }], + ['b', { totalPayoutWeiPerSec: 123n, totalStakedWei: 0n }], + ['c', { totalPayoutWeiPerSec: 0n, totalStakedWei: 0n }], + ['d', { totalPayoutWeiPerSec: 0n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toThrow('payoutProportional: sponsorships must have positive totalPayoutWeiPerSec') + }) + it('throws if there is a sponsorship with totalPayoutWeiPerSec < 0', async () => { + expect(() => adjustStakes({ + operatorState: { unstakedWei: 1000n, stakes: new Map() }, + operatorConfig: { }, + stakeableSponsorships: new Map([ + ['a', { totalPayoutWeiPerSec: 123n, totalStakedWei: 0n }], + ['b', { totalPayoutWeiPerSec: 123n, totalStakedWei: 0n }], + ['c', { totalPayoutWeiPerSec: -1n, totalStakedWei: 0n }], + ['d', { totalPayoutWeiPerSec: 234n, totalStakedWei: 0n }], + ]), + environmentConfig: { minimumStakeWei: 0n }, + })).toThrow('payoutProportional: sponsorships must have positive totalPayoutWeiPerSec') + }) + }) +}) diff --git a/packages/utils/src/exports.ts b/packages/utils/src/exports.ts index cbca783a42..d449928e4d 100644 --- a/packages/utils/src/exports.ts +++ b/packages/utils/src/exports.ts @@ -54,3 +54,4 @@ export type { ChangeFieldType, MapKey } from './types' export { type WeiAmount, multiplyWeiAmount } from './WeiAmount' export { getSubtle } from './crossPlatformCrypto' export { SigningUtil, EcdsaSecp256k1Evm, EcdsaSecp256r1, MlDsa87, type KeyType, KEY_TYPES } from './SigningUtil' +export { sum } from './sum' diff --git a/packages/utils/src/sum.ts b/packages/utils/src/sum.ts new file mode 100644 index 0000000000..b97cb710f6 --- /dev/null +++ b/packages/utils/src/sum.ts @@ -0,0 +1,3 @@ +export function sum(values: bigint[]): bigint { + return values.reduce((acc, value) => acc + value, 0n) +} From bd0d77b27675de1064a10f1ba0c7b0807931cb8e Mon Sep 17 00:00:00 2001 From: Teo Gebhard Date: Mon, 19 May 2025 11:08:01 +0300 Subject: [PATCH 3/3] manual test: mulltiple sponsorships --- packages/node/bin/autostaker-manual-test.sh | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/node/bin/autostaker-manual-test.sh b/packages/node/bin/autostaker-manual-test.sh index cb2c147472..330ffd2255 100755 --- a/packages/node/bin/autostaker-manual-test.sh +++ b/packages/node/bin/autostaker-manual-test.sh @@ -3,25 +3,33 @@ NODE_PRIVATE_KEY="1111111111111111111111111111111111111111111111111111111111111111" OWNER_PRIVATE_KEY="2222222222222222222222222222222222222222222222222222222222222222" SPONSORER_PRIVATE_KEY="3333333333333333333333333333333333333333333333333333333333333333" -EARNINGS_PER_SECOND=12 -STAKED_AMOUNT=500000 -SPONSOR_AMOUNT=1234567 +EARNINGS_PER_SECOND_1=1000 +EARNINGS_PER_SECOND_2=2000 +DELEGATED_AMOUNT=500000 +SPONSOR_AMOUNT=600000 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 --env dev2 npx tsx bin/streamr.ts internal token-mint $OWNER_ADDRESS 10000000 10000000 --env dev2 + 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 operator-delegate $OPERATOR_CONTRACT_ADDRESS $DELEGATED_AMOUNT --env dev2 --private-key $OWNER_PRIVATE_KEY + +echo 'Create sponsorships' npx tsx bin/streamr.ts internal token-mint $SPONSORER_ADDRESS 10000000 10000000 --env dev2 -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 +npx tsx bin/streamr.ts stream create /foo1 --env dev2 --private-key $SPONSORER_PRIVATE_KEY +SPONSORSHIP_CONTRACT_ADDRESS_1=$(npx tsx bin/streamr.ts internal sponsorship-create /foo1 -e $EARNINGS_PER_SECOND_1 --env dev2 --private-key $SPONSORER_PRIVATE_KEY | jq -r '.address') +npx tsx bin/streamr.ts internal sponsorship-sponsor $SPONSORSHIP_CONTRACT_ADDRESS_1 $SPONSOR_AMOUNT --env dev2 --private-key $SPONSORER_PRIVATE_KEY +npx tsx bin/streamr.ts stream create /foo2 --env dev2 --private-key $SPONSORER_PRIVATE_KEY +SPONSORSHIP_CONTRACT_ADDRESS_2=$(npx tsx bin/streamr.ts internal sponsorship-create /foo2 -e $EARNINGS_PER_SECOND_2 --env dev2 --private-key $SPONSORER_PRIVATE_KEY | jq -r '.address') +npx tsx bin/streamr.ts internal sponsorship-sponsor $SPONSORSHIP_CONTRACT_ADDRESS_2 $SPONSOR_AMOUNT --env dev2 --private-key $SPONSORER_PRIVATE_KEY jq -n \ --arg nodePrivateKey "$NODE_PRIVATE_KEY" \ @@ -45,7 +53,8 @@ jq -n \ jq -n \ --arg operatorContract "$OPERATOR_CONTRACT_ADDRESS" \ - --arg sponsorshipContract "$SPONSORSHIP_CONTRACT_ADDRESS" \ + --arg sponsorshipContract1 "$SPONSORSHIP_CONTRACT_ADDRESS_1" \ + --arg sponsorshipContract2 "$SPONSORSHIP_CONTRACT_ADDRESS_2" \ '$ARGS.named' cd ../node