diff --git a/package.json b/package.json index 6e7e0a0..609809e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "bs58": "^5.0.0", "web3": "^4.14.0", "btc-staking-ts": "^0.2.10", + "superstruct": "^0.16.0", "ethereum-multicall": "^2.26.0" } } diff --git a/solana.js b/solana.js index a250435..ecad0da 100644 --- a/solana.js +++ b/solana.js @@ -6,6 +6,7 @@ const { Keypair, PublicKey, StakeProgram, + Lockup, Transaction, TransactionMessage, VersionedTransaction, @@ -13,6 +14,8 @@ const { TransactionInstruction } = require('@solana/web3.js'); +const BigNumber = require('bignumber.js'); +const {parsedAccountInfoToStakeAccount, isLockupInForce, stakeAccountState, StakeState} = require("./solana_stake_account.js"); const {CheckToken, ERROR_TEXT, SetStats} = require("./utils/api"); const NETWORKS = { @@ -43,18 +46,19 @@ async function connect() { * @param {string} address - account blockchain address (staker) * @param {number} lamports - lamport amount * @param {string | null} source - stake source + * @param {object | null} lockupParams - stake account lockup params * @returns {Promise} Promise object Tx */ -async function createAccount(address, lamports, source = '0') { +async function createAccount(address, lamports, source = '0', lockupParams = Lockup.default) { try { await connect(); const senderPublicKey = new PublicKey(address); const minimumRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); - + lockupParams = lockupParams === null ? Lockup.default : lockupParams; const [createStakeAccountTx, stakeAccountPublicKey, externalSigners] = source === null - ? await createAccountTx(senderPublicKey, lamports + minimumRent) - : await createWithSeedTx(senderPublicKey, lamports + minimumRent, source); + ? await createAccountTx(senderPublicKey, lamports + minimumRent, lockupParams) + : await createWithSeedTx(senderPublicKey, lamports + minimumRent, source, lockupParams); const versionedTX = await prepareTransaction(createStakeAccountTx.instructions, senderPublicKey, externalSigners); return { result: { versionedTX, stakeAccount: stakeAccountPublicKey.toString() } }; @@ -63,7 +67,7 @@ async function createAccount(address, lamports, source = '0') { } } -async function createAccountTx(address, lamports) { +async function createAccountTx(address, lamports, {unixTimestamp, epoch}) { const blockhash = await getBlockhash(); const stakeAccount = Keypair.generate(); let createStakeAccountTx = StakeProgram.createAccount({ @@ -71,6 +75,8 @@ async function createAccountTx(address, lamports) { fromPubkey: address, lamports: lamports, stakePubkey: stakeAccount.publicKey, + // SDK don't support custodian lockups + lockup: new Lockup(unixTimestamp, epoch, PublicKey.default) }); createStakeAccountTx.recentBlockhash = blockhash; createStakeAccountTx.sign(stakeAccount); @@ -78,8 +84,7 @@ async function createAccountTx(address, lamports) { return [createStakeAccountTx, stakeAccount.publicKey, [stakeAccount]] } -async function createWithSeedTx(authorityPublicKey, lamports, source) { - +async function createWithSeedTx(authorityPublicKey, lamports, source, {unixTimestamp, epoch}) { // Format source to seed = formatSource(source); @@ -96,6 +101,9 @@ async function createWithSeedTx(authorityPublicKey, lamports, source) { basePubkey: authorityPublicKey, stakePubkey: stakeAccountPubkey, + // SDK don't support custodian lockups + lockup: new Lockup(unixTimestamp, epoch, PublicKey.default), + seed: seed, lamports: lamports, })); @@ -103,6 +111,53 @@ async function createWithSeedTx(authorityPublicKey, lamports, source) { return [createStakeAccountTx, stakeAccountPubkey, []] } +async function split(authorityPublicKey, lamports, oldStakeAccountPubkey, source) { + // TODO add support of default split + // StakeProgram.split({ + // stakePubkey: stakeAccountPublicKey, + // authorizedPubkey: senderPublicKey, + // votePubkey: validatorPubkey, + // }); + + // Format source to + seed = formatSource(source); + + const newStakeAccountPubkey = await PublicKey.createWithSeed( + authorityPublicKey, + seed, + StakeProgram.programId, + ); + + const splitStakeAccountTx = new Transaction().add( + StakeProgram.splitWithSeed({ + stakePubkey: oldStakeAccountPubkey, + authorizedPubkey: authorityPublicKey, + splitStakePubkey: newStakeAccountPubkey, + basePubkey: authorityPublicKey, + seed: seed, + lamports: lamports + }) + ); + + return [splitStakeAccountTx, newStakeAccountPubkey, []] +} + +async function merge(authorityPublicKey, stakeAccount1, stakeAccount2) { + const mergeStakeAccountTx = StakeProgram.merge({ + stakePubkey: stakeAccount1, + sourceStakePubKey: stakeAccount2, + authorizedPubkey: authorityPublicKey + }) + + return [mergeStakeAccountTx] +} + +async function getBlockhash() { + return await connection + .getLatestBlockhash({commitment: 'max'}) + .then((res) => res.blockhash); +} + /** createAccount - create account * @param {string} token - auth API token * @param {string} address - account blockchain address (staker) @@ -145,7 +200,7 @@ async function delegate(token, address, lamports, stakeAccount) { } } -/** deactivate - deactivate stake +/** deactivate - deactivate stake account * @param {string} address - account blockchain address (staker) * @param {string} stakeAccountPublicKey - public key * @returns {Promise} Promise object deactivation Tx @@ -231,7 +286,7 @@ async function getDelegations(address) { let accounts = []; - accounts = await connection.getParsedProgramAccounts(new PublicKey("Stake11111111111111111111111111111111111111"), { + accounts = await connection.getParsedProgramAccounts(StakeProgram.programId, { filters: [ {dataSize: 200}, {memcmp: {offset: 44, bytes: address}}, @@ -244,14 +299,18 @@ async function getDelegations(address) { } } +// TODO add summarised balances +async function stakeBalances(address) {} + /** stake - list of delegations * @param {string} token - auth API token * @param {string} sender - account blockchain address (staker) * @param {number} lamports - lamport amount * @param {string | null} source - stake source + * @param {object | null} lockupParams - stake account lockup params * @returns {Promise} Promise object with Versioned Tx */ -async function stake(token, sender, lamports, source) { +async function stake(token, sender, lamports, source, lockupParams = Lockup.default) { if (!await CheckToken(token)) { throw new Error(ERROR_TEXT); } @@ -262,10 +321,10 @@ async function stake(token, sender, lamports, source) { // Calculate how much we want to stake const minimumRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); - + lockupParams = lockupParams === null ? Lockup.default : lockupParams; const [createStakeAccountTx, stakeAccountPublicKey, externalSigners] = source === null - ? await createAccountTx(senderPublicKey, lamports + minimumRent) - : await createWithSeedTx(senderPublicKey, lamports + minimumRent, source); + ? await createAccountTx(senderPublicKey, lamports + minimumRent, lockupParams) + : await createWithSeedTx(senderPublicKey, lamports + minimumRent, source, lockupParams); const tx = new Transaction().add( ComputeBudgetProgram.setComputeUnitPrice({microLamports: 50}), @@ -277,6 +336,11 @@ async function stake(token, sender, lamports, source) { }) ); + // cast instructions to correct JSON Serialization + tx.instructions = tx.instructions.map((instruction) => { + return new TransactionInstruction(instruction) + }) + const versionedTX = await prepareTransaction(tx.instructions, senderPublicKey, externalSigners); await SetStats(token, 'stake', lamports / LAMPORTS_PER_SOL, sender, versionedTX, chain); @@ -287,6 +351,157 @@ async function stake(token, sender, lamports, source) { } } + +/** unstake - unstake + * @param {string} token - auth API token + * @param {string} sender - account blockchain address (staker) + * @param {number} lamports - lamport amount + * @param {string} source - stake source + * @returns {Promise} Promise object with Versioned Tx + */ +async function unstake(token, sender, lamports, source) { + if (!await CheckToken(token)) { + throw new Error(ERROR_TEXT); + } + + try { + const delegations = await getDelegations(sender); + + let stakeAccounts = delegations.result.map((delegationAcc) => { + return {pubkey: delegationAcc.pubkey, account: parsedAccountInfoToStakeAccount(delegationAcc.account)}; + }); + + const epochInfo = await connection.getEpochInfo(); + // Timestamp in seconds + const tm = Date.now() / 1000 | 0; + + totalActiveStake = new BigNumber(0); + let activeStakeAccounts = stakeAccounts.filter((acc) => { + let isActive = !(isLockupInForce(acc.account.data.info.meta, epochInfo.epoch, tm) || + stakeAccountState(acc.account.data, epochInfo.epoch) !== StakeState.Active); + if (isActive) totalActiveStake = totalActiveStake.plus(acc.account.data.info.stake.delegation.stake); + return isActive + }); + + let lamportsBN = new BigNumber(lamports) + if (totalActiveStake.lt(lamportsBN)) throw new Error('Active stake less than requested'); + + // Desc sorting + activeStakeAccounts.sort(function(a, b){ + if (a.account.data.info.stake.delegation.stake.lte(b.account.data.info.stake.delegation.stake)) { + return a; + } + + return b; + }); + + let accountsToDeactivate = []; + let accountsToSplit = []; + let i = 0; + while (lamportsBN.gt(new BigNumber(0)) && i < activeStakeAccounts.length) { + const lBN = new BigNumber(lamports) + const acc = activeStakeAccounts[i]; + let stakeAmount = new BigNumber(acc.account.data.info.stake.delegation.stake); + + // If reminder amount less than min stake amount stake account automatically become disabled + if (stakeAmount.comparedTo(lBN) <= 0 || stakeAmount.minus(lBN).lt(new BigNumber (minAmount))) { + accountsToDeactivate.push(acc); + lamportsBN = lamportsBN.minus(stakeAmount); + i++; + continue; + } + + accountsToSplit.push({account: acc, lamports: lamportsBN.toNumber()}); + break; + } + + const senderPublicKey = new PublicKey(sender); + + var instructions = [ComputeBudgetProgram.setComputeUnitPrice({microLamports: 50})]; + for (var j in accountsToSplit) { + const [tx, newStakeAccountPubkey] = await split(senderPublicKey, accountsToSplit[j].lamports, accountsToSplit[j].account.pubkey, source); + + const deactivateTx = StakeProgram.deactivate({ + stakePubkey: newStakeAccountPubkey, + authorizedPubkey: senderPublicKey, + }); + + instructions.push(...tx.instructions, ...deactivateTx.instructions); + } + + for (var j in accountsToDeactivate) { + const deactivateTx = StakeProgram.deactivate({ + stakePubkey: accountsToDeactivate[j].pubkey, + authorizedPubkey: senderPublicKey, + }); + + instructions.push(...deactivateTx.instructions); + } + + // cast instructions to correct JSON Serialization + instructions = instructions.map((instruction) => { + return new TransactionInstruction(instruction) + }) + + const versionedTX = await prepareTransaction(instructions, senderPublicKey, []); + + await SetStats(token, 'unstake', lamports / LAMPORTS_PER_SOL, sender, versionedTX, chain); + + return {result: versionedTX}; + } catch (error) { + throw new Error(error); + } +} + +async function claim(sender) { + try { + const delegations = await getDelegations(sender); + + let stakeAccounts = delegations.result.map((delegationAcc) => { + return {pubkey: delegationAcc.pubkey, account: parsedAccountInfoToStakeAccount(delegationAcc.account)}; + }); + + const epochInfo = await connection.getEpochInfo(); + // Timestamp in seconds + const tm = Date.now() / 1000 | 0; + + totalClaimableStake = new BigNumber(0); + let deactivatedStakeAccounts = stakeAccounts.filter((acc) => { + let isDeactivated = (!isLockupInForce(acc.account.data.info.meta, epochInfo.epoch, tm) && + stakeAccountState(acc.account.data, epochInfo.epoch) === StakeState.Deactivated); + + if (isDeactivated) totalClaimableStake = totalClaimableStake.plus(acc.account.data.info.stake.delegation.stake); + return isDeactivated + }); + + if (deactivatedStakeAccounts.length === 0) throw new Error('Nothing to claim'); + + const senderPublicKey = new PublicKey(sender); + var instructions = [ComputeBudgetProgram.setComputeUnitPrice({microLamports: 50})]; + for (var j in deactivatedStakeAccounts) { + + const withdrawTx = StakeProgram.withdraw({ + stakePubkey: deactivatedStakeAccounts[j].pubkey, + authorizedPubkey: senderPublicKey, + toPubkey: senderPublicKey, + lamports: deactivatedStakeAccounts[j].account.lamports, + }); + instructions.push(...withdrawTx.instructions); + } + + // cast instructions to correct JSON Serialization + instructions = instructions.map((instruction) => { + return new TransactionInstruction(instruction) + }) + + const versionedTX = await prepareTransaction(instructions, senderPublicKey, []); + + return {result: versionedTX}; + } catch (error) { + throw new Error(error); + } +} + async function prepareTransaction(instructions, payer, externalSigners) { const blockhash = await getBlockhash(); const messageV0 = new TransactionMessage({ @@ -339,6 +554,8 @@ module.exports = { withdraw, getDelegations, stake, + unstake, + claim, selectNetwork, NETWORKS diff --git a/solana_stake_account.js b/solana_stake_account.js new file mode 100644 index 0000000..0fc70a2 --- /dev/null +++ b/solana_stake_account.js @@ -0,0 +1,162 @@ +// copied from https://github.com/solana-labs/solana/blob/master/explorer/src/validators/accounts/stake.ts + +/* eslint-disable @typescript-eslint/no-redeclare */ +const { PublicKey /*, ParsedAccountData, AccountInfo*/ } = require("@solana/web3.js"); +const { enums, nullable, number, type, coerce, instance, string, create /*Infer*/ } = require("superstruct"); +const BigNumber = require('bignumber.js'); + +const StakeState = { + Inactive: "inactive", + Activating: "activating", + Active: "active", + Deactivating: "deactivating", + Deactivated: "deactivated" +} + +const BigNumFromString = coerce(instance(BigNumber), string(), (value) => { + if (typeof value === "string") return new BigNumber(value, 10); + throw new Error("invalid big num"); + }); + +const PublicKeyFromString = coerce( + instance(PublicKey), + string(), + (value) => new PublicKey(value), + ); + +// type StakeAccountType = Infer; +const StakeAccountType = enums([ + "uninitialized", + "initialized", + "delegated", + "rewardsPool", +]); + +// type StakeMeta = Infer; +const StakeMeta = type({ + rentExemptReserve: BigNumFromString, + authorized: type({ + staker: PublicKeyFromString, + withdrawer: PublicKeyFromString, + }), + lockup: type({ + unixTimestamp: number(), + epoch: number(), + custodian: PublicKeyFromString, + }), +}); + +// type StakeAccountInfo = Infer; +const StakeAccountInfo = type({ + meta: StakeMeta, + stake: nullable( + type({ + delegation: type({ + voter: PublicKeyFromString, + stake: BigNumFromString, + activationEpoch: BigNumFromString, + deactivationEpoch: BigNumFromString, + warmupCooldownRate: number(), + }), + creditsObserved: number(), + }), + ), +}); + +// type StakeAccount = Infer; +const StakeAccount = type({ + type: StakeAccountType, + info: StakeAccountInfo, +}); + + +function isLockupInForce( + meta, + currEpoch, + currUnixTimestamp, +) { + return ( + meta.lockup.unixTimestamp > currUnixTimestamp || + meta.lockup.epoch > currEpoch + ); +} + +/** + * Converts an `AccountInfo` to an `AccountInfo` + * @param account raw accountinfo returned by getParsedProgramAccounts or getParsedAccountInfo + * @returns the parsed StakeAccount + * @throws ParseStakeAccountError if `account` is AccountInfo or if unable to parse account data + */ +function parsedAccountInfoToStakeAccount({ + executable, + owner, + lamports, + data, + rentEpoch, + }) { + // : AccountInfo + // return : AccountInfo + if (!("parsed" in data)) { + throw new ParseStakeAccountError( + "Raw AccountInfo, data not parsed", + ); + } + try { + return { + executable, + owner, + lamports, + data: create(data.parsed, StakeAccount), + rentEpoch, + }; + } catch (error) { + throw new Error(error); + } + } + +/** + * Determins the current state of a stake account given the current epoch + * @param stakeAccount + * @param currentEpoch + * @returns `stakeAccount`'s `StakeState` + */ +function stakeAccountState( + { type, info: { stake } }, + currentEpoch, + ) { + if (type !== "delegated" || stake === null) { + return StakeState.Inactive; + } + + const activationEpoch = new BigNumber(stake.delegation.activationEpoch); + const deactivationEpoch = new BigNumber(stake.delegation.deactivationEpoch); + currentEpoch = new BigNumber(currentEpoch); + + if (activationEpoch.gt(currentEpoch)) { + return StakeState.Inactive; + } + + if (activationEpoch.eq(currentEpoch)) { + // if you activate then deactivate in the same epoch, + // deactivationEpoch === activationEpoch. + // if you deactivate then activate again in the same epoch, + // the deactivationEpoch will be reset to EPOCH_MAX + if (deactivationEpoch.eq(activationEpoch)) return StakeState.Inactive; + return StakeState.Activating; + } + + // activationEpoch < currentEpoch + if (deactivationEpoch.gt(currentEpoch)) return StakeState.Active; + if (deactivationEpoch.eq(currentEpoch)) return StakeState.Deactivating; + + return StakeState.Deactivated; +} + +module.exports = { + StakeAccount, + StakeAccountType, + StakeState, + isLockupInForce, + parsedAccountInfoToStakeAccount, + stakeAccountState +}; \ No newline at end of file