MAVAN Staking AI Skill
Download the machine-readable MAVAN staking skill and its backend and browser-wallet examples for AI coding agents.
Use this skill with your AI coding agent to implement native Ethereum staking with the MAVAN API: authentication, validator creation, wallet funding, status, rewards, and full exits.
Download the complete package as Markdown. The source below contains exactly three files: SKILL.md and two reusable examples. It contains no credentials. The backend example requires Node.js 22+; the browser wallet example requires viem.
Install in your project
Run the commands below from your application project. They download the public Markdown, extract only the three named files, and copy the folder into the skill directory. Choose the copy command for your editor.
curl --fail --silent --show-error \
https://docs.mavan.bitminetech.io/docs/integrate-mavan-staking.md \
--output mavan-staking-skill.md
python3 - <<'PY'
import re
from pathlib import Path
source = Path("mavan-staking-skill.md").read_text()
allowed = {"SKILL.md", "scripts/mavan-api.mjs", "scripts/ethereum-wallet.mjs"}
pattern = r"^## (SKILL\.md|scripts/[a-z-]+\.mjs)\n+(" + chr(96) + r"{3,})[^\n]*\n(.*?)\n\2[ \t]*$"
files = {name: content for name, fence, content in re.findall(pattern, source, re.M | re.S)}
if set(files) != allowed:
raise SystemExit("Incomplete skill download; no files were installed")
for name, content in files.items():
target = Path("integrate-mavan-staking") / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content + "\n")
print("Installed integrate-mavan-staking/")
PY
# Cursor: run from your application project
mkdir -p .cursor/skills
cp -R integrate-mavan-staking .cursor/skills/
# Claude Code: run from your application project
mkdir -p .claude/skills
cp -R integrate-mavan-staking .claude/skills/Invoke /integrate-mavan-staking or ask your agent to use this skill to add native ETH staking on Hoodi. Building an integration does not initiate a stake or authorize wallet transactions.
The examples passed mocked tests. A funded Hoodi lifecycle has not been verified.
SKILL.md
---
name: integrate-mavan-staking
description: Build a native Ethereum staking integration with the MAVAN API, including authentication, Pectra validator creation, wallet funding, status, rewards, and full exits. Use for adding MAVAN staking to an application; other networks and liquid staking use separate flows.
---
# Integrate MAVAN staking
Implement the requested application integration using the contract below. Default examples to Hoodi testnet. Preserve the user's chosen framework, custody model, and authorized environment. Building an integration does not authorize making live stake requests, signing transactions, or exiting validators on the user's behalf.
## API contract
Reviewed against the public testnet spec on 2026-10-02 (`1.0.177-develop-testnet`) and gateway controllers. Read the deployed environment's `/docs-json` when checking changed behavior; its DTOs can mark runtime-optional fields as required, so the examples supply them explicitly.
| Environment | API origin | Ethereum chain |
| ----------- | ----------------------------------------- | --------------- |
| Testnet | `https://testnetapi.mavan.bitminetech.io` | Hoodi, `560048` |
| Production | `https://api.mavan.bitminetech.io` | Mainnet, `1` |
Authenticate backend requests with the `api-key` header. Keep API keys in server-side secrets; expose only account-authorized staking data and unsigned transactions to the frontend. The API usually returns `{ "data": ... }`; the bundled client unwraps `data` exactly once.
| Task | Request | Result under `data` |
| ---------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| Authenticate | `GET /account` | Account, including `kycStatus` and `mnemonicStatus` |
| Network and contracts | `GET /public/networkConfig` (public) | `ethereum.chainId`, `network`, and contract addresses |
| Create Pectra stake | `POST /ethereum/stakeV3` | `{ stake: { stakeId, validators, ... }, transaction }` |
| Read this stake | `GET /ethereum/stake/{stakeId}` | Stake with `validators[]`, directly |
| Reconcile by application reference | `GET /ethereum/stakes?reference=...` | Stake array; reference is a filter, not a uniqueness guarantee |
| Rewards | `GET /ethereum/validators/dailyRewards` | Array of daily reward records |
| Craft full exit | `POST /ethereum/txcrafting/validators/withdraw` | Unsigned transaction with analysis |
Use `staker` permission for creation and exit crafting. The account must have `kycStatus: KYC_APPROVED` and `mnemonicStatus: ACTIVE`. The latter is an account readiness flag: never request, generate, or transmit the customer's private wallet keys or mnemonic for these examples. Obtain the environment-specific API key from [MAVAN Command Center settings](https://app.mavan.bitminetech.io/settings).
## Implement the lifecycle
1. Read account readiness and network config. Match the API's Ethereum chain ID to the funding wallet and RPC.
2. Persist the application operation, a unique `reference`, the exact creation body, and a UUID v4 `x-request-id` **before** sending a creation request. Use this body, substituting the customer's addresses:
```json
{
"withdrawalAddress": "0x1111111111111111111111111111111111111111",
"suggestedFeeRecipient": "0x1111111111111111111111111111111111111111",
"fromAddress": "0x1111111111111111111111111111111111111111",
"stakeAmountGwei": "32000000000",
"maxEthPerValidatorGwei": "2048000000000",
"reference": "app-stake-operation-123"
}
```
Addresses above are illustrative; do not submit them. Use decimal strings for amounts: 32 ETH = `32000000000` gwei = `32000000000000000000` wei. `maxEthPerValidatorGwei` sets the allocation cap; validators can contain differing amounts. Omit `region` unless the user has chosen an available deployment region.
3. Persist `data.stake.stakeId`, validator pubkeys, the body, request ID, and deposit transaction. A successful create call reserves validators; it does not fund or activate them. `transaction` can be null even when a stake exists. Recover the existing stake and craft a batch deposit with `/ethereum/txcrafting/validators/batchdeposit`; do not create another stake to recover a missing transaction.
4. Have the user's wallet or custodian validate, sign, and broadcast the returned transaction. Use [the wallet helpers](scripts/ethereum-wallet.mjs) to compare chain, destination, amount, and encoded deposits with the expected inputs. Verify withdrawal credentials and fee recipient; surface `analysis.observations` and simulations. Keep `value`, gas, and fee values as `BigInt` when preparing a transaction. Persist its hash immediately and check the receipt before another send. Never send the `analysis` object as transaction calldata.
5. Poll the stake with a bounded wait and increasing intervals. Read each validator's `status`; a stake's status alone is insufficient. Typical progress is `WAITING_DEPOSIT` → `PENDING_ACTIVATION` → `ACTIVE`, with other key-generation and deployment states possible. `ACTIVE_EXITING`, `EXITING_SLASHED`, `EXITED`, `EXITED_SLASHED`, and `ABANDONED` require distinct UI states. Timeouts should preserve the stake ID and transaction hash for resumption. Activation depends on the network queue.
6. Once validators have numeric indexes, request rewards with **singular** `validatorIndex` containing comma-separated indexes, `dateFrom` / `dateTo` in Unix **seconds**, `version=v2`, and `timezone=Etc/UTC`. Do not omit the indexes when displaying a single stake: omission can return account-wide rewards. For v2, `consensusRewards` and `executionRewards` are wei strings; format ETH with `formatUnits(BigInt(value), 18)` and preserve the raw strings. An empty array can mean no indexed rewards yet. Do not invent rewards or a fixed yield.
7. For each active validator selected for a full exit, craft this body from its actual pubkey and withdrawal wallet:
```json
{
"fromAddress": "0x1111111111111111111111111111111111111111",
"validatorPubkey": "0x<48-byte-validator-public-key>",
"amountGwei": "0",
"gasEstimateMultiplier": 1.2
}
```
Here `amountGwei: "0"` requests a **full exit**. Transaction `value` pays the on-chain withdrawal-request fee. The withdrawal address must sign and broadcast it. Check its receipt, then resume status polling through exit and withdrawal eligibility. An exit transaction receipt or `EXITED` status does not prove that ETH has arrived; reconcile the withdrawal address's on-chain receipt/balance separately. The destination remains the validator's withdrawal address. Repeat only for the explicitly selected validators.
## Retry and error behavior
`x-request-id` is supported by stake creation and operator-managed bulk exits; it is not a generic idempotency header for every endpoint. Reuse it only for the same method, path, and body. Successful responses are replayed; changed payloads or in-progress requests can return `409`. Records have a 24-hour TTL. Do not treat that retention as permanent duplicate protection. Persist application state and reconcile `/ethereum/stakes?reference=...` and transaction hashes after ambiguous failures, before resending. Creation errors are not guaranteed to be cached.
Do not retry `400` / `401` / `403` unchanged. Inspect account readiness, permissions, address validation, and IP restrictions. For read requests, bounded retries for `429` and transient `5xx` are reasonable; respect `Retry-After` when present. The bundled client makes one request per call and reports HTTP status and body via `MavanApiError`.
Operator-managed `/ethereum/bulkWithdrawValidators` is an alternative exit flow, not the default for this Pectra wallet example. It takes `{ "pubkeys": [...] }`, immediately attempts exit broadcasts, returns a **per-validator error array**, and is unavailable when encrypted exit messages are enabled. An HTTP success is not proof that every validator exited. Do not substitute this endpoint silently.
## Reusable examples and completion
Use [the backend client](scripts/mavan-api.mjs) with Node.js 22+ and the wallet helper in a browser application with `viem`. Adapt the examples to the application's server-side authorization and durable storage; they are not a hosted service or a custody integration.
Demonstrate authentication, creation with a persisted request ID, deposit signing, resumable status polling, explicitly scoped rewards, and a selected-validator full exit. Test response parsing and failed or wrong-network signing with mocks. Report separately whether a funded Hoodi end-to-end run was performed. Do not claim on-chain validation from mocked tests.
References: [API environments](https://docs.mavan.bitminetech.io/docs/introduction), [authentication](https://docs.mavan.bitminetech.io/docs/authentication-1), [validator statuses](https://docs.mavan.bitminetech.io/docs/validator-statuses), [payload verification](https://docs.mavan.bitminetech.io/docs/transaction-payload-verification), [testnet OpenAPI](https://testnetapi.mavan.bitminetech.io/docs-json).scripts/mavan-api.mjs
/** Backend-only MAVAN client. Requires Node.js 22 or later. */
export class MavanApiError extends Error {
constructor(status, body) {
super(`MAVAN API returned HTTP ${status}`);
this.name = 'MavanApiError';
this.status = status;
this.body = body;
}
}
export function createMavanClient({
apiKey,
baseUrl = 'https://testnetapi.mavan.bitminetech.io',
fetchImpl = globalThis.fetch,
}) {
if (!apiKey) throw new Error('Supply the API key from a server-side secret');
const base = new URL(baseUrl);
if (
base.protocol !== 'https:' ||
base.pathname !== '/' ||
base.search ||
base.hash ||
base.username ||
base.password
) {
throw new Error('baseUrl must be an HTTPS API origin');
}
async function request(
path,
{ method = 'GET', body, requestId, query, authenticated = true } = {},
) {
const url = new URL(path, base);
const headers = { Accept: 'application/json' };
if (authenticated) headers['api-key'] = apiKey;
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (requestId) headers['x-request-id'] = requestId;
for (const [name, value] of Object.entries(query ?? {})) {
if (value !== undefined) url.searchParams.set(name, String(value));
}
// Deliberately make one attempt. The caller controls reconciliation and retries.
const response = await fetchImpl(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
if (!response.ok)
throw new MavanApiError(response.status, {
message: 'Non-JSON response',
});
throw new Error('Expected a JSON API response');
}
if (!response.ok) throw new MavanApiError(response.status, payload);
if (!payload || !Object.hasOwn(payload, 'data'))
throw new Error('Expected an API data envelope');
return payload.data;
}
function stakePath(stakeId) {
if (!Number.isSafeInteger(stakeId) || stakeId < 1)
throw new Error('stakeId must be a positive integer');
return `/ethereum/stake/${stakeId}`;
}
return {
getAccount: () => request('/account'),
getNetworkConfig: () =>
request('/public/networkConfig', { authenticated: false }),
createStake: (input, requestId) => {
if (!requestId)
throw new Error('Persist a request ID before creating a stake');
return request('/ethereum/stakeV3', {
method: 'POST',
body: input,
requestId,
});
},
getStake: (stakeId) => request(stakePath(stakeId)),
findStakes: (reference) =>
request('/ethereum/stakes', { query: { reference } }),
getRewards: (
validatorIndexes,
{ dateFrom, dateTo, currency = 'USD' } = {},
) => {
if (
!validatorIndexes?.length ||
!validatorIndexes.every(
(index) => Number.isSafeInteger(index) && index >= 0,
)
) {
throw new Error(
'Supply explicit non-negative validator indexes for this stake',
);
}
if (
![dateFrom, dateTo].every(
(value) => Number.isSafeInteger(value) && value >= 0,
) ||
dateFrom >= dateTo
) {
throw new Error('Supply an increasing date range in Unix seconds');
}
return request('/ethereum/validators/dailyRewards', {
query: {
validatorIndex: validatorIndexes.join(','),
dateFrom,
dateTo,
currency,
version: 'v2',
timezone: 'Etc/UTC',
},
});
},
craftFullExit: ({ fromAddress, validatorPubkey }) =>
request('/ethereum/txcrafting/validators/withdraw', {
method: 'POST',
body: {
fromAddress,
validatorPubkey,
amountGwei: '0',
gasEstimateMultiplier: 1.2,
},
}),
};
}scripts/ethereum-wallet.mjs
/** Browser wallet example. Install viem in your application. Never supply API keys here. */
import { encodeFunctionData } from 'viem';
const batchDepositAbi = [
{
type: 'function',
name: 'batchDeposit',
stateMutability: 'payable',
outputs: [],
inputs: [
{
name: '_deposits',
type: 'tuple[]',
components: [
{ name: 'pubKey', type: 'bytes' },
{ name: 'withdrawalCredentials', type: 'bytes' },
{ name: 'signature', type: 'bytes' },
{ name: 'amount', type: 'uint256' },
],
},
],
},
];
const hex = (value) => `0x${String(value).replace(/^0x/i, '').toLowerCase()}`;
const quantity = (value) => `0x${BigInt(value).toString(16)}`;
const address = (value) => {
if (!/^0x[\da-f]{40}$/i.test(value))
throw new Error('Expected a 20-byte Ethereum address');
return value.toLowerCase();
};
/** Derive signing expectations from the requested stake and every returned validator. */
export function depositExpectations(created, input, config) {
if (!created.transaction)
throw new Error(
'Stake exists but deposit transaction is unavailable; recover this stake',
);
const validators = created.stake?.validators;
if (!validators?.length) throw new Error('No validators returned');
if (
address(created.stake.withdrawalAddress) !==
address(input.withdrawalAddress)
)
throw new Error('Withdrawal address mismatch');
const credentials = `0x02${'0'.repeat(22)}${address(input.withdrawalAddress).slice(2)}`;
const deposits = validators.map((validator) => {
if (hex(validator.withdrawal_credentials) !== credentials)
throw new Error('Validator withdrawal credentials mismatch');
if (
address(validator.suggestedFeeRecipient) !==
address(input.suggestedFeeRecipient)
)
throw new Error('Fee recipient mismatch');
if (
!/^0x[\da-f]{96}$/i.test(hex(validator.pubkey)) ||
!/^0x[\da-f]{192}$/i.test(hex(validator.signature))
) {
throw new Error('Invalid validator public key or deposit signature');
}
return {
pubKey: hex(validator.pubkey),
withdrawalCredentials: credentials,
signature: hex(validator.signature),
amount: BigInt(validator.amount) * 1_000_000_000n,
};
});
const valueWei = BigInt(input.stakeAmountGwei) * 1_000_000_000n;
if (
deposits.reduce((total, deposit) => total + deposit.amount, 0n) !== valueWei
)
throw new Error(
'Validator deposit amounts do not match the requested stake',
);
return {
chainId: config.chainId,
fromAddress: input.fromAddress ?? input.withdrawalAddress,
toAddress: config.pectraBatchDepositContractAddress,
valueWei,
data: encodeFunctionData({
abi: batchDepositAbi,
functionName: 'batchDeposit',
args: [deposits],
}),
};
}
/** Full-exit calldata is a 48-byte public key followed by a zero uint64 amount. */
export function exitExpectations(
transaction,
validatorPubkey,
withdrawalAddress,
config,
) {
const pubkey = hex(validatorPubkey);
if (!/^0x[\da-f]{96}$/i.test(pubkey))
throw new Error('Expected a 48-byte validator public key');
return {
chainId: config.chainId,
fromAddress: withdrawalAddress,
toAddress: config.ethValidatorWithdrawalContractAddress,
// The transaction value is the network withdrawal-request fee, not the withdrawn balance.
valueWei: BigInt(transaction.value),
data: `${pubkey}${'0'.repeat(16)}`,
};
}
/** Call from an explicit user action after displaying the transaction and analysis. */
export async function sendMavanTransaction(provider, transaction, expected) {
if (!provider?.request) throw new Error('Connect an EIP-1193 wallet');
if (!transaction || transaction.chainId !== expected.chainId)
throw new Error('Transaction chain mismatch');
if (address(transaction.to) !== address(expected.toAddress))
throw new Error('Transaction destination mismatch');
if (
BigInt(transaction.value) !== BigInt(expected.valueWei) ||
BigInt(transaction.value) < 0n
)
throw new Error('Transaction value mismatch');
if (hex(transaction.data) !== hex(expected.data))
throw new Error('Transaction calldata mismatch');
if (
BigInt(transaction.gas) <= 0n ||
BigInt(transaction.maxPriorityFeePerGas) < 0n ||
BigInt(transaction.maxFeePerGas) < BigInt(transaction.maxPriorityFeePerGas)
) {
throw new Error('Invalid transaction gas or fees');
}
const analysis = transaction.analysis;
if (!Array.isArray(analysis?.observations))
throw new Error('Transaction analysis is unavailable');
if (
analysis.observations.some((item) => item.severity === 'danger') ||
Object.values(analysis.simulations ?? {}).some(
(simulation) => simulation.success === false,
)
) {
throw new Error('Resolve the transaction analysis failure before signing');
}
const chainId = Number(
BigInt(await provider.request({ method: 'eth_chainId' })),
);
if (chainId !== expected.chainId)
throw new Error('Switch the wallet to the API network');
const accounts = await provider.request({ method: 'eth_requestAccounts' });
if (!accounts[0] || address(accounts[0]) !== address(expected.fromAddress))
throw new Error('Select the expected funding or withdrawal wallet');
// Let the wallet choose a current nonce; the API nonce is only an estimate.
return provider.request({
method: 'eth_sendTransaction',
params: [
{
from: accounts[0],
to: transaction.to,
data: transaction.data,
value: quantity(transaction.value),
gas: quantity(transaction.gas),
chainId: quantity(transaction.chainId),
maxFeePerGas: quantity(transaction.maxFeePerGas),
maxPriorityFeePerGas: quantity(transaction.maxPriorityFeePerGas),
},
],
});
}
export async function waitForReceipt(
provider,
hash,
{ timeoutMs = 120_000, pollMs = 3_000 } = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const receipt = await provider.request({
method: 'eth_getTransactionReceipt',
params: [hash],
});
if (receipt) {
if (BigInt(receipt.status) !== 1n)
throw new Error(
'Transaction reverted; inspect the receipt before retrying',
);
return receipt;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(
'Transaction is still pending; keep the hash and reconcile it before sending again',
);
}Updated 7 days ago
