Skip to content

feat(sdk-coin-sol): implement staking activate for jito #6502

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

Merged
merged 1 commit into from
Aug 1, 2025
Merged
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
101 changes: 101 additions & 0 deletions examples/ts/sol/stake-jito.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Stakes JitoSOL tokens on Solana devnet.
*
* Copyright 2025, BitGo, Inc. All Rights Reserved.
*/
import { BitGoAPI } from '@bitgo/sdk-api'
import { TransactionBuilderFactory, Tsol } from '@bitgo/sdk-coin-sol'
import { coins } from '@bitgo/statics'
import { Connection, PublicKey, clusterApiUrl, Transaction, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"
import { getStakePoolAccount, updateStakePool } from '@solana/spl-stake-pool'
import * as bs58 from 'bs58';

require('dotenv').config({ path: '../../.env' })

const AMOUNT_LAMPORTS = 1000
const JITO_STAKE_POOL_ADDRESS = 'Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb'
const NETWORK = 'devnet'

const bitgo = new BitGoAPI({
accessToken: process.env.TESTNET_ACCESS_TOKEN,
env: 'test',
})
const coin = coins.get("tsol")
bitgo.register(coin.name, Tsol.createInstance)

async function main() {
const account = getAccount()
const connection = new Connection(clusterApiUrl(NETWORK), 'confirmed')
const recentBlockhash = await connection.getLatestBlockhash()
const stakePoolAccount = await getStakePoolAccount(connection, new PublicKey(JITO_STAKE_POOL_ADDRESS))


// Account should have sufficient balance
const accountBalance = await connection.getBalance(account.publicKey)
if (accountBalance < 0.1 * LAMPORTS_PER_SOL) {
console.info(`Your account balance is ${accountBalance / LAMPORTS_PER_SOL} SOL, requesting airdrop`)
const sig = await connection.requestAirdrop(account.publicKey, 2 * LAMPORTS_PER_SOL)
await connection.confirmTransaction(sig)
console.info(`Airdrop successful: ${sig}`)
}

// Stake pool should be up to date
const epochInfo = await connection.getEpochInfo()
if (stakePoolAccount.account.data.lastUpdateEpoch.ltn(epochInfo.epoch)) {
console.info('Stake pool is out of date.')
const usp = await updateStakePool(connection, stakePoolAccount)
const tx = new Transaction()
tx.add(...usp.updateListInstructions, ...usp.finalInstructions)
const signer = Keypair.fromSecretKey(account.secretKeyArray)
const sig = await connection.sendTransaction(tx, [signer])
await connection.confirmTransaction(sig)
console.info(`Stake pool updated: ${sig}`)
}

// Use BitGoAPI to build depositSol instruction
const txBuilder = new TransactionBuilderFactory(coin).getStakingActivateBuilder()
txBuilder
.amount(`${AMOUNT_LAMPORTS}`)
.sender(account.publicKey.toBase58())
.stakingAddress(JITO_STAKE_POOL_ADDRESS)
.validator(JITO_STAKE_POOL_ADDRESS)
.isJito(true)
.nonce(recentBlockhash.blockhash)
txBuilder.sign({ key: account.secretKey })
const tx = await txBuilder.build()
const serializedTx = tx.toBroadcastFormat()
console.info(`Transaction JSON:\n${JSON.stringify(tx.toJson(), undefined, 2)}`)

// Send transaction
try {
const sig = await connection.sendRawTransaction(Buffer.from(serializedTx, 'base64'))
await connection.confirmTransaction(sig)
console.log(`${AMOUNT_LAMPORTS / LAMPORTS_PER_SOL} SOL deposited`, sig)
} catch (e) {
console.log('Error sending transaction')
console.error(e)
if (e.transactionMessage === 'Transaction simulation failed: Error processing Instruction 0: Provided owner is not allowed') {
console.error('If you successfully staked JitoSOL once, you cannot stake again.')
}
}
}

const getAccount = () => {
const publicKey = process.env.ACCOUNT_PUBLIC_KEY
const secretKey = process.env.ACCOUNT_SECRET_KEY
if (publicKey === undefined || secretKey === undefined) {
const { publicKey, secretKey } = Keypair.generate()
console.log('Here is a new account to save into your .env file.')
console.log(`ACCOUNT_PUBLIC_KEY=${publicKey.toBase58()}`)
console.log(`ACCOUNT_SECRET_KEY=${bs58.encode(secretKey)}`)
throw new Error("Missing account information")
}

return {
publicKey: new PublicKey(publicKey),
secretKey,
secretKeyArray: new Uint8Array(bs58.decode(secretKey)),
}
}

main().catch((e) => console.error(e))
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@bitgo/sdk-core": "^36.0.0",
"@bitgo/sdk-lib-mpc": "^10.6.0",
"@bitgo/statics": "^57.0.0",
"@solana/spl-stake-pool": "1.1.8",
"@solana/spl-token": "0.3.1",
"@solana/web3.js": "1.92.1",
"bignumber.js": "^9.0.0",
Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-coin-sol/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ export const STAKE_ACCOUNT_RENT_EXEMPT_AMOUNT = 2282880;

export const UNAVAILABLE_TEXT = 'UNAVAILABLE';

export const JITO_STAKE_POOL_ADDRESS = 'Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb';
export const JITOSOL_MINT_ADDRESS = 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn';
export const JITO_STAKE_POOL_RESERVE_ACCOUNT = 'BgKUXdS29YcHCFrPm5M8oLHiTzZaMDjsebggjoaQ6KFL';
export const JITO_STAKE_POOL_RESERVE_ACCOUNT_TESTNET = 'rrWBQqRqBXYZw3CmPCCcjFxQ2Ds4JFJd7oRQJ997dhz';
export const JITO_MANAGER_FEE_ACCOUNT = 'feeeFLLsam6xZJFc6UQFrHqkvVt4jfmVvi2BRLkUZ4i';
export const JITO_MANAGER_FEE_ACCOUNT_TESTNET = 'DH7tmjoQ5zjqcgfYJU22JqmXhP5EY1tkbYpgVWUS2oNo';

// Sdk instructions, mainly to check decoded types.
export enum ValidInstructionTypesEnum {
AdvanceNonceAccount = 'AdvanceNonceAccount',
Expand All @@ -30,6 +37,7 @@ export enum ValidInstructionTypesEnum {
SetPriorityFee = 'SetPriorityFee',
MintTo = 'MintTo',
Burn = 'Burn',
DepositSol = 'DepositSol',
}

// Internal instructions types
Expand Down Expand Up @@ -72,6 +80,7 @@ export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [
ValidInstructionTypesEnum.SetPriorityFee,
ValidInstructionTypesEnum.MintTo,
ValidInstructionTypesEnum.Burn,
ValidInstructionTypesEnum.DepositSol,
];

/** Const to check the order of the Wallet Init instructions when decode */
Expand All @@ -96,6 +105,12 @@ export const marinadeStakingActivateInstructionsIndexes = {
Memo: 2,
} as const;

/** Const to check the order of the Jito Staking Activate instructions when decode */
export const jitoStakingActivateInstructionsIndexes = {
AtaInit: 0,
DepositSol: 1,
} as const;

/** Const to check the order of the Staking Authorize instructions when decode */
export const stakingAuthorizeInstructionsIndexes = {
Authorize: 0,
Expand Down
3 changes: 3 additions & 0 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TransactionSignature,
} from '@solana/web3.js';
import { InstructionBuilderTypes } from './constants';
import { StakePoolInstructionType } from '@solana/spl-stake-pool';

// TODO(STLX-9890): Add the interfaces for validityWindow and SequenceId
export interface SolanaKeys {
Expand Down Expand Up @@ -121,6 +122,7 @@ export interface StakingActivate {
amount: string;
validator: string;
isMarinade?: boolean;
isJito?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

Create an enum for marinade, jito. There might be new staking types later. You can refactor in a separate PR with WP changes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can I address this in a follow-up? If I create an enum now, then other Jito PRs will require a Marinade refactor as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is already being changed at the api layer, we might as well address it here as well.

https://github.com/BitGo/public-types/pull/213

I'm fine with doing this in a follow up, but the api will use an enum.

Copy link
Contributor Author

@haritkapadia haritkapadia Aug 1, 2025

Choose a reason for hiding this comment

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

Assuming a refactor is done in a follow up:

Can we keep the isJito field in https://github.com/BitGo/public-types/pull/213 as well for now?

I agree having individual flags is messy, but I think having flags (isMarinade) and enums (stakingType === "JITO") in the same codebase is worse because it's inconsistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

};
}

Expand Down Expand Up @@ -184,6 +186,7 @@ export interface AtaClose {
export type ValidInstructionTypes =
| SystemInstructionType
| StakeInstructionType
| StakePoolInstructionType
| 'Memo'
| 'InitializeAssociatedTokenAccount'
| 'CloseAssociatedTokenAccount'
Expand Down
86 changes: 66 additions & 20 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import {
import { NotSupported, TransactionType } from '@bitgo/sdk-core';
import { coins, SolCoin } from '@bitgo/statics';
import assert from 'assert';
import { InstructionBuilderTypes, ValidInstructionTypesEnum, walletInitInstructionIndexes } from './constants';
import {
InstructionBuilderTypes,
JITO_STAKE_POOL_ADDRESS,
ValidInstructionTypesEnum,
walletInitInstructionIndexes,
} from './constants';
import {
AtaClose,
AtaInit,
Expand All @@ -47,6 +52,8 @@ import {
SetPriorityFee,
} from './iface';
import { getInstructionType } from './utils';
import { DepositSolParams } from '@solana/spl-stake-pool';
import { decodeDepositSol } from './jitoStakePoolOperations';

/**
* Construct instructions params from Solana instructions
Expand Down Expand Up @@ -303,6 +310,14 @@ function parseSendInstructions(
return instructionData;
}

function stakingInstructionsIsMarinade(si: StakingInstructions): boolean {
return !!(si.delegate === undefined && si.depositSol === undefined);
}

function stakingInstructionsIsJito(si: StakingInstructions): boolean {
return !!(si.delegate === undefined && si.depositSol?.stakePool.toString() === JITO_STAKE_POOL_ADDRESS);
}

/**
* Parses Solana instructions to create staking tx and delegate tx instructions params
* Only supports Nonce, StakingActivate and Memo Solana instructions
Expand All @@ -312,8 +327,8 @@ function parseSendInstructions(
*/
function parseStakingActivateInstructions(
instructions: TransactionInstruction[]
): Array<Nonce | StakingActivate | Memo> {
const instructionData: Array<Nonce | StakingActivate | Memo> = [];
): Array<Nonce | StakingActivate | Memo | AtaInit> {
const instructionData: Array<Nonce | StakingActivate | Memo | AtaInit> = [];
const stakingInstructions = {} as StakingInstructions;
for (const instruction of instructions) {
const type = getInstructionType(instruction);
Expand Down Expand Up @@ -346,21 +361,48 @@ function parseStakingActivateInstructions(
case ValidInstructionTypesEnum.StakingDelegate:
stakingInstructions.delegate = StakeInstruction.decodeDelegate(instruction);
break;

case ValidInstructionTypesEnum.DepositSol:
stakingInstructions.depositSol = decodeDepositSol(instruction);
break;

case ValidInstructionTypesEnum.InitializeAssociatedTokenAccount:
instructionData.push({
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
params: {
mintAddress: instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString(),
ataAddress: instruction.keys[ataInitInstructionKeysIndexes.ATAAddress].pubkey.toString(),
ownerAddress: instruction.keys[ataInitInstructionKeysIndexes.OwnerAddress].pubkey.toString(),
payerAddress: instruction.keys[ataInitInstructionKeysIndexes.PayerAddress].pubkey.toString(),
tokenName: findTokenName(instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString()),
},
});
break;
}
}

validateStakingInstructions(stakingInstructions);

const stakingActivate: StakingActivate = {
type: InstructionBuilderTypes.StakingActivate,
params: {
fromAddress: stakingInstructions.create?.fromPubkey.toString() || '',
stakingAddress: stakingInstructions.initialize?.stakePubkey.toString() || '',
amount: stakingInstructions.create?.lamports.toString() || '',
fromAddress:
stakingInstructions.create?.fromPubkey.toString() ||
stakingInstructions.depositSol?.fundingAccount.toString() ||
'',
stakingAddress:
stakingInstructions.initialize?.stakePubkey.toString() ||
stakingInstructions.depositSol?.stakePool.toString() ||
'',
amount:
stakingInstructions.create?.lamports.toString() || stakingInstructions.depositSol?.lamports.toString() || '',
validator:
stakingInstructions.delegate?.votePubkey.toString() ||
stakingInstructions.initialize?.authorized.staker.toString() ||
stakingInstructions.depositSol?.stakePool.toString() ||
'',
isMarinade: stakingInstructions.delegate === undefined,
isMarinade: stakingInstructionsIsMarinade(stakingInstructions),
isJito: stakingInstructionsIsJito(stakingInstructions),
},
};
instructionData.push(stakingActivate);
Expand Down Expand Up @@ -413,22 +455,23 @@ interface StakingInstructions {
initialize?: InitializeStakeParams;
delegate?: DelegateStakeParams;
authorize?: AuthorizeStakeParams[];
depositSol?: DepositSolParams;
}

function validateStakingInstructions(stakingInstructions: StakingInstructions) {
if (!stakingInstructions.create) {
throw new NotSupported('Invalid staking activate transaction, missing create stake account instruction');
}

if (!stakingInstructions.initialize && stakingInstructions.delegate) {
return;
} else if (!stakingInstructions.delegate && stakingInstructions.initialize) {
return;
} else if (!stakingInstructions.delegate && !stakingInstructions.initialize) {
// If both are missing something is wrong
throw new NotSupported(
'Invalid staking activate transaction, missing initialize stake account/delegate instruction'
);
if (stakingInstructionsIsJito(stakingInstructions)) {
if (!stakingInstructions.depositSol) {
throw new NotSupported('Invalid staking activate transaction, missing deposit sol instruction');
}
} else {
if (!stakingInstructions.create) {
throw new NotSupported('Invalid staking activate transaction, missing create stake account instruction');
}
if (!stakingInstructions.delegate && !stakingInstructions.initialize) {
throw new NotSupported(
'Invalid staking activate transaction, missing initialize stake account/delegate instruction'
);
}
}
}

Expand Down Expand Up @@ -776,6 +819,9 @@ function parseAtaInitInstructions(
};
instructionData.push(ataInit);
break;
case ValidInstructionTypesEnum.DepositSol:
// AtaInit is a part of spl-stake-pool's depositSol process
break;
default:
throw new NotSupported(
'Invalid transaction, instruction type not supported: ' + getInstructionType(instruction)
Expand Down
Loading