Skip to main content

Integration example

This page shows a complete Lido stVault flow with the Staking API (craft → sign → prepare → broadcast), with the signing step. The example uses the stake action with ETH only. The same four steps apply to claim, unstake, withdraw, burn-wsteth, withdraw-pool, claim-pool-withdrawal and refresh-report: only the endpoint and the request body change.

You sign in your own environment. The Stakely Staking API never has access to your private keys. This page shows two signing options: a local private key and an external custodian (Fireblocks). Lido stVault runs on Ethereum, so standard EVM tools apply.

Test on testnet

Lido stVault is available on Hoodi. Set X-NETWORK to 560048. Read the enabled networks with the networks endpoint. Do you need testnet tokens to try the flow? Contact us at [email protected] and we can provide them.

Setup​

This example targets Node.js 24 (LTS). Install the dependencies:

npm install ethereumjs-util dotenv

Fill in your values in .env:

STAKELY_API_KEY=
STAKELY_API_BASE_URL=https://staking-api.stakely.io
STAKELY_NETWORK=560048
EVM_CHAIN_ID=560048
ETHEREUM_WALLET_ADDRESS=
ETHEREUM_WALLET_PRIVATE_KEY=

A small helper calls the API with the required headers:

require('dotenv').config();

const BASE = `${process.env.STAKELY_API_BASE_URL}/api/v1/ethereum/lido-stvault`;
const headers = {
'X-API-KEY': process.env.STAKELY_API_KEY,
'X-NETWORK': process.env.STAKELY_NETWORK,
'Content-Type': 'application/json',
};

// fetch is available globally in Node.js 24
const api = async (method, path, body) => {
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${path} → ${res.status}: ${await res.text()}`);
return res.json();
};

1. Craft the transaction​

Ask the API to build the unsigned stake transaction. This request stakes ETH only, without mint_wsteth_amount.

const crafted = await api('POST', '/action/stake', {
address: process.env.ETHEREUM_WALLET_ADDRESS,
amount: 0.01,
});
// crafted = { serialized_tx_hex, unsigned_tx_hash_hex, payload }

The API simulates the transaction before it returns it. If the protocol cannot accept the stake in the current on-chain state, the request fails with 422 and a reason. See Errors.

Before you sign, examine crafted.payload. Make sure that to is the strategy contract and that value is the amount that you stake.

2. Sign the transaction​

Sign the unsigned_tx_hash_hex value. Use one of the two options below. Both options return the signature parts { r, s, v } in the format that the prepare step needs:

  • r and s are hex strings without the 0x prefix.
  • v is in EIP-155 form: chain_id * 2 + 35 + recovery.

The prepare step also accepts r and s with the 0x prefix, and v as the recovery id (0 or 1) or as 27 or 28. It converts v to the EIP-155 value.

Option A: Local private key​

const ethUtil = require('ethereumjs-util');

const signWithPrivateKey = ({ unsigned_tx_hash_hex }) => {
const txHashBytes = Buffer.from(unsigned_tx_hash_hex.replace(/^0x/, ''), 'hex');
const privateKeyBuffer = Buffer.from(process.env.ETHEREUM_WALLET_PRIVATE_KEY, 'hex');

// ecsign with the chain id produces the EIP-155 v the API expects
const { r, s, v } = ethUtil.ecsign(
txHashBytes,
privateKeyBuffer,
Number(process.env.EVM_CHAIN_ID),
);

return { r: r.toString('hex'), s: s.toString('hex'), v };
};

const { r, s, v } = signWithPrivateKey(crafted);

Option B: External custodian (Fireblocks)​

Your application never has the private keys. Fireblocks signs in its secure environment. Install and configure the SDK:

npm install fireblocks-sdk
FIREBLOCKS_API_SECRET=
FIREBLOCKS_API_KEY=
FIREBLOCKS_BASE_URL=
FIREBLOCKS_VAULT_ID=
FIREBLOCKS_ASSET_ID=
const { FireblocksSDK, TransactionOperation, PeerType, TransactionStatus } = require('fireblocks-sdk');

const fireblocks = new FireblocksSDK(
process.env.FIREBLOCKS_API_SECRET,
process.env.FIREBLOCKS_API_KEY,
process.env.FIREBLOCKS_BASE_URL,
);

const waitForTxCompletion = async (fbTx) => {
let tx = fbTx;
while (tx.status !== TransactionStatus.COMPLETED) {
if (
tx.status === TransactionStatus.BLOCKED ||
tx.status === TransactionStatus.FAILED ||
tx.status === TransactionStatus.CANCELLED
) {
throw new Error(`Fireblocks signer: the transaction has been ${tx.status}`);
} else if (tx.status === TransactionStatus.REJECTED) {
throw new Error('Fireblocks signer: the transaction was rejected; check your TAP security policy');
}
tx = await fireblocks.getTransactionById(fbTx.id);
}
return fireblocks.getTransactionById(fbTx.id);
};

const signWithFireblocks = async ({ unsigned_tx_hash_hex }) => {
const content = unsigned_tx_hash_hex.replace(/^0x/, '');

const fbTx = await fireblocks.createTransaction({
// The Fireblocks asset of the network you target with X-NETWORK (the Hoodi test asset).
assetId: process.env.FIREBLOCKS_ASSET_ID,
operation: TransactionOperation.RAW,
source: { type: PeerType.VAULT_ACCOUNT, id: process.env.FIREBLOCKS_VAULT_ID },
note: 'Sign transaction from Stakely Staking API',
extraParameters: { rawMessageData: { messages: [{ content }] } },
});

const result = await waitForTxCompletion(fbTx);
const signature = result.signedMessages[0].signature;

// Fireblocks returns r and s without the 0x prefix, and v as the recovery value (0 or 1)
return {
r: signature.r,
s: signature.s,
v: Number(process.env.EVM_CHAIN_ID) * 2 + 35 + signature.v,
};
};

const { r, s, v } = await signWithFireblocks(crafted);

3. Prepare the signed transaction​

Send the serialized_tx_hex value with the signature parts to get the signed transaction.

const prepared = await api('POST', '/action/prepare', {
serialized_tx_hex: crafted.serialized_tx_hex,
r,
s,
v,
});
// prepared = { signed_tx_hex }

4. Broadcast​

Submit the signed transaction. You can use the Stakely broadcast endpoint or your own RPC.

const { tx_hash } = await api('POST', '/action/broadcast', {
signed_tx_hex: prepared.signed_tx_hex,
});

console.log('Broadcasted:', tx_hash);

The broadcast submits the transaction. To confirm that it is final, poll tx_hash on chain. See Errors & responses for how to handle results and failures.

5. Read the position​

After the transaction is confirmed, read the position. The position is on your call forwarder, and the response contains its address.

const position = await api('GET', `/balance/${process.env.ETHEREUM_WALLET_ADDRESS}`);

console.log('Call forwarder:', position.call_forwarder);
console.log('STV:', position.stv, '=', position.stv_assets, 'ETH');

The same response tells you when the next action is possible:

FieldNext action
pending_deposit_is_claimable is trueclaim
shares is greater than 0unstake
exit_requests[].is_claimable is truewithdraw with that request_id
wsteth and minted_steth_shares are greater than 0burn-wsteth
stv is greater than 0withdraw-pool
pool_withdrawal_requests[].is_claimable is trueclaim-pool-withdrawal with that request_id

Next steps​