Skip to content
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

balance stake for both ton pool contracts #15

Merged
merged 10 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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
32 changes: 19 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions packages/staking-cli/src/cmd/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async function runTx (
{
delegator: config.delegatorAddress,
validatorAddress: config.validatorAddress,
validatorAddress2: config.validatorAddress2,
messageType: msgType,
args: arg,
broadcast: broadcastEnabled
Expand All @@ -144,11 +145,13 @@ async function runTx (
cmd.error('second validator address is required for TON Pool', { exitCode: 1, code: `${msgType}.tx.abort` })
}
const validatorAddressPair: [string, string] = [config.validatorAddress, config.validatorAddress2]
const poolsInfo = await tonStaker.getPoolAddressForStake({ validatorAddressPair })
mikalsande marked this conversation as resolved.
Show resolved Hide resolved
const validatorIndex = validatorAddressPair.findIndex((addr: string) => addr === poolsInfo.SelectedPoolAddress)
if (validatorIndex === -1) {
cmd.error('validator address not found in the pool', { exitCode: 1, code: `${msgType}.tx.abort` })
}

const validatorToDelegate = await tonStaker.getPoolAddressForStake({ validatorAddressPair })
const validatorIndex = validatorToDelegate === config.validatorAddress ? 1 : 2

console.log('Delegating to validator #' + validatorIndex + ': ' + validatorToDelegate)
console.log('Delegating to validator #' + (validatorIndex+1) + ': ' + validatorAddressPair[validatorIndex])
mikalsande marked this conversation as resolved.
Show resolved Hide resolved

unsignedTx = (
await tonStaker.buildStakeTx({
Expand Down
2 changes: 1 addition & 1 deletion packages/ton/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@chorus-one/signer": "^1.0.0",
"@chorus-one/utils": "^1.0.0",
"@noble/curves": "^1.4.0",
"@ton/ton": "^13.11.2",
"@ton/ton": "^15.1.0",
"axios": "^1.7.2",
"tonweb-mnemonic": "^1.0.1"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ton/src/TonBaseStaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
import {
toNano,
fromNano,
TonClient,
WalletContractV4,
internal,
MessageRelaxed,
Expand All @@ -22,6 +21,7 @@ import {
beginCell,
storeMessage
} from '@ton/ton'
import { TonClient } from './TonClient'
import { createWalletTransferV4, externalMessage, sign } from './tx'
import * as tonMnemonic from 'tonweb-mnemonic'
import { ed25519 } from '@noble/curves/ed25519'
Expand Down
46 changes: 46 additions & 0 deletions packages/ton/src/TonClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
TonClient as NativeTonClient,
Cell,
} from '@ton/ton'
import axios from "axios";
import { z } from 'zod';

const configParamCodec = z.object({
ok: z.boolean(),
result: z.object({
'@type': z.string(),
config: z.object({
'@type': z.string(),
bytes: z.string(),
}),
'@extra': z.string(),
})
});

export class TonClient extends NativeTonClient {

async getConfigParam (config_id: number): Promise<Cell> {
const url = new URL(this.parameters.endpoint)
const base = url.pathname.split('/').slice(0, -1).join('/')
url.pathname = base + '/getConfigParam'
url.searchParams.set('config_id', config_id.toString())

const r = await axios.get(url.toString())
if (r.status !== 200) {
throw Error('Unable to fetch config param, error: ' + r.status + ' ' + r.statusText)
}

const configParam = configParamCodec.safeParse(r.data)
if (!configParam.success) {
throw Error('Unable to parse config param, error: ' + JSON.stringify(configParam.error))
}

const paramBytes = configParam.data?.result.config.bytes
if (paramBytes === undefined) {
throw Error('Failed to get config param bytes')
}

return Cell.fromBoc(Buffer.from(paramBytes, 'base64'))[0];
}
}

145 changes: 139 additions & 6 deletions packages/ton/src/TonPoolStaker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Address, beginCell, fromNano, toNano } from '@ton/ton'
import { Address, beginCell, fromNano, toNano, Slice, Builder, DictionaryValue, Dictionary, Cell, configParse17 } from '@ton/ton'
import { defaultValidUntil, getDefaultGas, getRandomQueryId, TonBaseStaker } from './TonBaseStaker'
import { UnsignedTx } from './types'
import { UnsignedTx, Election, FrozenSet, PoolStatus, GetPoolAddressForStakeResponse } from './types'

export class TonPoolStaker extends TonBaseStaker {
/**
Expand All @@ -24,7 +24,7 @@ export class TonPoolStaker extends TonBaseStaker {
validUntil?: number
}): Promise<{ tx: UnsignedTx }> {
const { validatorAddressPair, amount, validUntil, referrer } = params
const validatorAddress = await this.getPoolAddressForStake({ validatorAddressPair })
const validatorAddress = (await this.getPoolAddressForStake({ validatorAddressPair })).SelectedPoolAddress

mikalsande marked this conversation as resolved.
Show resolved Hide resolved
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(validatorAddress)
Expand Down Expand Up @@ -177,10 +177,143 @@ export class TonPoolStaker extends TonBaseStaker {
}

/** @ignore */
async getPoolAddressForStake (params: { validatorAddressPair: [string, string] }) {
async getPoolAddressForStake (params: { validatorAddressPair: [string, string] }): Promise<GetPoolAddressForStakeResponse> {
const { validatorAddressPair } = params
// The logic to be implemented, we return the first address for now
const client = this.getClient()

// fetch required data:
// 1. stake balance for both pools
// 2. stake limits from the onchain config
// 3. last election data to get the minimum stake for participation
const [ poolOneStatus, poolTwoStatus, elections, stakeLimitsCfgCell ] = await Promise.all([
this.getPoolStatus(validatorAddressPair[0]),
this.getPoolStatus(validatorAddressPair[1]),

// elector contract address
this.getPastElections('Ef8zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM0vF'),

// see full config: https://tonviewer.com/config#17
client.getConfigParam(17)
])

const [ poolOneBalance, poolTwoBalance ] = [poolOneStatus.Balance, poolTwoStatus.Balance]
const stakeLimitsCfg = configParse17(stakeLimitsCfgCell.beginParse())
const maxStake = stakeLimitsCfg.maxStake

// simple sanity validation
if (elections.length == 0) {
throw new Error('No elections found')
}

if (stakeLimitsCfg.minStake == BigInt(0)) {
throw new Error('Minimum stake is 0, that is not expected')
}

// iterate lastElection.frozen and find the lowest validator stake
const lastElection = elections[0]
const values = Array.from(lastElection.frozen.values())
const minStake = values.reduce((min, p) => p.stake < min ? p.stake : min, values[0].stake)

Copy link
Collaborator

Choose a reason for hiding this comment

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

QQ:
Some validators bid but don't get into an active set of validators because they propose too little. Does this min represent the min of the active set?

cc: @mikalsande coming from provider.get('past_elections', [])

Choose a reason for hiding this comment

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

Yes, it should be the active set. It fetches data from the last election, any validator that sends in too little stake should not have been part of that election. Actually, I have not verified whether this is true or not and it is difficult to verify because the elections are not full now :( Still, I think it is reasonable to use this.

If we want to really protect against using the wrong value is to check how long the list is. The chain config defines max validators in config option 16. So as long as the list is shorter or equal than that number (currently 400) it should be 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes this is the lowest of active set. For instance this is the data (using go code, but it's the same data):

 (electorcontract.Election) {
  ID: (*big.Int)(0xc0002a3460)(1736724232),
  UnfreezeAt: (*big.Int)(0xc0002a34a0)(1736822544),
  StakeHeld: (*big.Int)(0xc0002a3500)(32768),
  ValidatorSetHash: (*big.Int)(0xc0002a3560)(84901180556431012479828596589565587603167423808451322956044484867782103172852),
  FrozenDict: (map[string]electorcontract.ValidatorData) (len=386) {
   (string) (len=48) "Ef8EWBJwS-ClUAzpdk7x8acT6u_p7V1RUIdKQ1njUjU8KtTA": (electorcontract.ValidatorData) {
    Address: (*address.Address)(0xc000124500)(Ef8SgHecBgQgcpTvC_hKms7fr925q9jvWqi60d74_0Fmk7rO),
    Weight: (*big.Int)(0xc000160080)(2867459091096508),
    Stake: (*big.Int)(0xc0001600c0)(808364202020000),
    Banned: (bool) false
   },
   .... the rest of 386 vals

And then you look at: https://tonscan.com/validation/1736724232 and see the same data with non-zero wieght for each validator

Copy link
Contributor Author

@mkaczanowski mkaczanowski Jan 16, 2025

Choose a reason for hiding this comment

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

If we want to really protect against using the wrong value is to check how long the list is. The chain config defines max validators in config option 16. So as long as the list is shorter or equal than that number (currently 400) it should be 👍

That could work, but also the validators above 400 won't be in this past elections structure. The reason is simple, this there is a hash for a validator set is present in the structure, meaning that it is calculated (and verified!) using the pubkeys / addresses in the frozen set.

At least that's how 99% of the blockchains would do, but this is TON :)

const selectedPoolIndex = TonPoolStaker.selectPool(minStake, maxStake, [poolOneBalance, poolTwoBalance])

return {
SelectedPoolAddress: validatorAddressPair[selectedPoolIndex],
MinStake: minStake,
MaxStake: maxStake,
PoolStakes: [poolOneBalance, poolTwoBalance]
}
}

async getPoolStatus (validatorAddress: string): Promise<PoolStatus> {
const client = this.getClient()
const provider = client.provider(Address.parse(validatorAddress))
const res = await provider.get('get_pool_status', []);

return {
Balance: res.stack.readBigNumber(),
BalanceSent: res.stack.readBigNumber(),
BalancePendingDeposits: res.stack.readBigNumber(),
BalancePendingWithdrawals: res.stack.readBigNumber(),
BalanceWithdraw: res.stack.readBigNumber()
}
}

async getPastElections (electorContractAddress: string): Promise<Election[]> {
const client = this.getClient()
const provider = client.provider(Address.parse(electorContractAddress))
const res = await provider.get('past_elections', []);

const FrozenDictValue: DictionaryValue<FrozenSet> = {
serialize (_src: FrozenSet, _builder: Builder) {
throw Error("not implemented");
},
parse (src: Slice): FrozenSet {
const address = new Address(-1, src.loadBuffer(32));
const weight = src.loadUintBig(64);
const stake = src.loadCoins();
return { address, weight, stake };
}
};

// NOTE: In ideal case we would call `res.stack.readLispList()` however the library does not handle 'list' type well
// and exits with an error. This is alternative way to get election data out of the 'list' type.
const root = res.stack.readTuple()
const elections: Election[] = [];

while (root.remaining > 0) {
const electionsEntry = root.pop()
const id = electionsEntry[0]
const unfreezeAt = electionsEntry[1]
const stakeHeld = electionsEntry[2]
const validatorSetHash = electionsEntry[3]
const frozenDict: Cell = electionsEntry[4]
const totalStake = electionsEntry[5]
const bonuses = electionsEntry[6]
const frozen: Map<string, FrozenSet> = new Map();

const frozenData = frozenDict.beginParse().loadDictDirect(Dictionary.Keys.Buffer(32), FrozenDictValue);
for (const [key, value] of frozenData) {
frozen.set(BigInt("0x" + key.toString("hex")).toString(10), { address: value["address"], weight: value["weight"], stake: value["stake"] });
}
elections.push({ id, unfreezeAt, stakeHeld, validatorSetHash, totalStake, bonuses, frozen });
}

// return elections sorted by id (bigint) in descending order
return elections.sort((a, b) => (a.id > b.id ? -1 : 1));
}

/** @ignore */
static selectPool (
minStake: bigint, // minimum stake for participation (to be in the set)
maxStake: bigint, // maximum allowes stake per validator
currentBalances: [bigint, bigint] // current stake balances of the pools
mkaczanowski marked this conversation as resolved.
Show resolved Hide resolved
): number {
const [balancePool1, balancePool2] = currentBalances;

const hasReachedMinStake = (balance: bigint): boolean => balance >= minStake;
const hasReachedMaxStake = (balance: bigint): boolean => balance >= maxStake;

// prioritize filling a pool that hasn't reached the minStake
if (!hasReachedMinStake(balancePool1) && !hasReachedMinStake(balancePool2)) {
// if neither pool has reached minStake, prioritize the one with the higher balance
return balancePool1 >= balancePool2 ? 0 : 1;
} else if (!hasReachedMinStake(balancePool1)) {
return 0; // fill pool 1 to meet minStake
} else if (!hasReachedMinStake(balancePool2)) {
return 1; // fill pool 2 to meet minStake
}

// both pools have reached minStake, balance them until they reach maxStake
if (!hasReachedMaxStake(balancePool1) && !hasReachedMaxStake(balancePool2)) {
// distribute to balance the pools
return balancePool1 <= balancePool2 ? 0 : 1;
} else if (!hasReachedMaxStake(balancePool1)) {
return 0; // add to pool 1 until it reaches maxStake
} else if (!hasReachedMaxStake(balancePool2)) {
return 1; // add to pool 2 until it reaches maxStake
}

return validatorAddressPair[0]
// if both pools have reached maxStake, no more staking is allowed
throw new Error("Both pools have reached their maximum stake limits");
}
}
32 changes: 32 additions & 0 deletions packages/ton/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ export interface PoolData {
min_nominator_stake: bigint
}

// reference: https://github.com/ton-core/ton/blob/55c576dfc5976e1881180ee271ba8ec62d3f13d4/src/elector/ElectorContract.ts#L70C11-L70C27
export interface Election {
id: number;
unfreezeAt: number;
stakeHeld: number;
validatorSetHash: bigint;
totalStake: bigint;
bonuses: bigint;
frozen: Map<string, FrozenSet>;
}

export interface FrozenSet {
address: Address;
weight: bigint;
stake: bigint;
}

export interface PoolStatus {
Balance: bigint;
mkaczanowski marked this conversation as resolved.
Show resolved Hide resolved
BalanceSent: bigint;
BalancePendingDeposits: bigint;
BalancePendingWithdrawals: bigint;
BalanceWithdraw: bigint;
}

export interface GetPoolAddressForStakeResponse {
SelectedPoolAddress: string;
MinStake: bigint;
MaxStake: bigint;
PoolStakes: [biginy, bigint];
}

export interface AddressDerivationConfig {
walletContractVersion: number
workchain: number
Expand Down
Loading
Loading