# Staking

Requires `pools:read` for the reads and `transactions:write` for the
transaction flow - granted by name, or through the
[`all:read` / `all:write` wildcards](/authentication#wildcard-scopes).

## Finding a pool

```bash
curl https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/simple-strategy/table \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

Returns the curated selection split into `low` / `mid` / `high` by risk score,
ordered by rank within each bucket. Each pool carries its `slug` - the handle
every other call takes - along with `apy`, `avg30dApy`, `tvlUsd`, `feePercent`,
`riskScore`, and the tokens involved:

```json
{
  "data": {
    "table": {
      "low": [
        {
          "slug": "aave-usdc-base",
          "name": "Aave USDC",
          "project": "Aave",
          "chain": "BASE",
          "poolType": "A_TOKEN",
          "contractAddress": "0x…",
          "riskScore": 12,
          "riskCategory": "low",
          "apy": 5.41,
          "avg30dApy": 5.12,
          "tvlUsd": 84213944.19,
          "feePercent": 0,
          "stakingEnabled": true,
          "website": "https://aave.com",
          "baseToken": { "symbol": "USDC", "chain": "BASE", "contractAddress": "0x…", "decimals": 6 },
          "quoteToken": null,
          "wrapToken": { "symbol": "aBasUSDC", "chain": "BASE", "contractAddress": "0x…", "decimals": 6 },
          "utilization": {
            "level": "normal",
            "rate": 82.4,
            "availableLiquidity": 14820331.5,
            "baseTokenSymbol": "USDC",
            "observedAt": "2026-08-11T09:00:01.000Z"
          },
          "updatedAt": "2026-08-11T09:00:00.000Z"
        }
      ],
      "mid": [],
      "high": []
    }
  }
}
```

`stakingEnabled: false` means the pool is listed for data only: building a stake
or an unstake against it is a `400` - check it before building a transaction. `updatedAt` is when the
stats were last recomputed, which is what to key your own caching on.

### Exit liquidity (`utilization`)

A lending pool pays depositors back out of what is not lent out. At 100%
utilization every unit supplied is borrowed, so **an unstake reverts** until
borrowers repay or new supply arrives - while the APY is at its highest.
`utilization` tells you how close a pool is:

| Field | Meaning |
| --- | --- |
| `rate` | Percent of depositor assets that cannot be withdrawn right now, 2 dp |
| `availableLiquidity` | What can be withdrawn right now, in base-token units |
| `level` | `normal`, `high` (from 90%) or `critical` (from 98%). A pool leaves a level only 3 points below its threshold, so it does not flap |
| `observedAt` | When it was read. Readings are hourly |

For a vault the rate is *exit* utilization: the share of its assets it cannot
pay out now, whatever the vault's own reporting calls utilization.
`utilization` is `null` for pools we do not monitor (DEX pools, and vaults we
have not mapped yet) and for pools not read yet. Consider holding new stakes
into a `critical` pool, and warn users before an unstake from one. A change of
`level` is also pushed to you as the
[`staking_pool_utilization_changed`](/callbacks#pool-events) callback.

### Which pools you see

Every pool we list is available to you by default, new ones included - there is
nothing to opt into. Your team can switch individual pools off in the partner
panel, and a pool switched off leaves this list and stops accepting
transactions: a chart, stake or unstake addressed at its slug answers `403`.

That is your own configuration, not a permission we set, so the fix is a toggle
in the panel rather than a support ticket. A change takes effect within a minute
across every instance.

History for one pool:

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/chart/tvl-apy/aave-usdc-base?dateFrom=2026-07-01" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

Ascending by date, defaulting to the last 7 days when `dateFrom` is omitted.
Points with a zero APY are left out.

## The transaction flow

Staking is **two steps**, because the API never holds a user's key:

1. `POST …/construct/stake/transaction/batch` returns an unsigned EIP-7702 batch
   user operation plus an `intentId`.
2. The user's wallet signs `bundle.userOpHash` and, when `bundle.authorization`
   is not `null`, the EIP-7702 authorization tuple.
3. `POST …/transaction/batch/send` returns the signatures with the `intentId`,
   and the API submits the operation.

The user operation itself never travels back to you. It stays server-side under
the `intentId`, which is what stops a signed call from being replayed against a
different chain, amount or target than the one that was quoted.

### 1. Build

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/construct/stake/transaction/batch \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "userId": "8f2b1c4e-…",
    "slug": "aave-usdc-base",
    "amount": 250.5
  }'
```

```json title="Response"
{
  "data": {
    "intentId": "b7c1…",
    "amount": 250.5,
    "bundle": {
      "chainId": 8453,
      "entryPoint": "0x…",
      "delegate": "0x…",
      "calls": [{ "target": "0x…", "value": "0", "data": "0x…" }],
      "userOp": {
        "sender": "0x…",
        "nonce": "0x…",
        "callData": "0x…",
        "callGasLimit": "0x…",
        "verificationGasLimit": "0x…",
        "preVerificationGas": "0x…",
        "maxFeePerGas": "0x…",
        "maxPriorityFeePerGas": "0x…",
        "signature": "0x"
      },
      "userOpHash": "0x…",
      "authorization": { "chainId": 8453, "address": "0x…", "nonce": 0 }
    }
  }
}
```

`userId` names the user the transaction acts for - a partner token identifies
your organisation, not a person, so the subject has to be explicit. It must be
one of your users; anything else is a `404`.

`amount` in the response is the amount the batch was built for.

Gas is always sponsored: the user's address never needs the chain's native
token. The platform fee includes a gas-coverage component that pays for it,
priced from the chain's current gas price.

#### Building for the largest amount (`maxAmount`)

A "max" button should not send the wallet balance: the platform fee comes on
top of a USDC stake, and an unstake can be capped by what the pool can pay out.
Send `"maxAmount": true` instead of `amount`, and the batch is built for the
largest amount the wallet can afford right now, fee included. The response's
`amount` says what that was.

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/construct/stake/transaction/batch \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{ "userId": "8f2b1c4e-…", "slug": "aave-usdc-base", "maxAmount": true }'
```

- Stake: the base-token balance less the platform fee, when the fee is paid
  in the same token. A tiny remainder stays in the wallet, so a gas price that
  rises before the batch lands cannot make it unaffordable.
- Unstake: every share the wallet holds that the pool can pay out now (ERC-4626
  `maxRedeem`, or the Aave reserve's unborrowed liquidity). The fee comes out
  of the withdrawal. A DEX pool answers `400`
  ([`MAX_AMOUNT_NOT_SUPPORTED`](/error-codes)).
- `amount` is ignored when `maxAmount` is `true`.

### 2. Sign

The wallet produces one or two signatures:

- `signature` - EIP-191 over the 32 bytes of `bundle.userOpHash`, prefixed with
  `0xff00`.
- `authorization` - the EIP-7702 tuple signed as `{ r, s, yParity }`, using
  `bundle.authorization` (`chainId`, `address`, `nonce`). **Only when
  `bundle.authorization` is not `null`.**

`bundle.authorization` is `null` once the wallet already delegates to
`bundle.delegate` - normally every batch after the user's first on that chain.
Then sign only `userOpHash` and leave `authorization` out of the submit body.
Sending one anyway, or leaving it out when the bundle has one, is a `400`
([`AUTHORIZATION_INVALID`](/error-codes)).

[Signing with Privy](/privy) and [Signing with Wallet Core](/wallet-core) cover
both signatures in code, along with the mistakes that get a submit rejected.

### 3. Submit

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/transaction/batch/send \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "userId": "8f2b1c4e-…",
    "intentId": "b7c1…",
    "signature": "0x…",
    "authorization": { "r": "0x…", "s": "0x…", "yParity": 0 }
  }'
```

```json title="Response"
{ "data": { "userOpHash": "0x…" } }
```

An intent is **single-use and short-lived**. A replayed or late submit is a
`400` - rebuild and re-sign rather than retrying the same `intentId`.

## Estimating the fee (`fee-estimate`)

`GET …/fee-estimate/:slug?action=stake|unstake&amount=…` answers the platform
fee and gas fee on an amount, and what is left of it once they are taken. It
reads no wallet and builds nothing, so you can call it as the user types. It
needs `pools:read`, and refuses a pool you switched off with `403`, like a
build.

```bash
curl "https://stg-s2s.restake.gg/api/v1/s2s/staking-pools/fee-estimate/aave-usdc-base?action=stake&amount=100" \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

```json title="Response"
{
  "data": {
    "amount": 100,
    "amountToken": "USDC",
    "feePercent": 1,
    "systemFeeUsd": 1,
    "gasFeeUsd": 0.1,
    "totalFeeUsd": 1.1,
    "feeTokenSymbol": "USDC",
    "finalAmount": 98.9,
    "finalAmountToken": "USDC"
  }
}
```

- `action` is `stake` (`amount` in base-token units) or `unstake` (share
  units), matching the construct call it is for.
- `feePercent` is the tier the amount falls in - from the fee tiers you set
  for your users in the partner panel (**Fees**), or the platform's default
  tiers if you have not set any. A change there applies to every estimate and
  batch built after it.
- `gasFeeUsd` is the gas coverage at the chain's current gas price; it moves
  with the network.
- `finalAmount` is what is left once the fee is taken. On a stake it is
  `amount` less the fee, so for a wallet holding `amount` it is about what
  `maxAmount` will stake. On an unstake it is what the shares redeem for
  (`finalAmountToken` is the base token) less the fee. When the fee is paid in
  another token, nothing is taken from it.
- The figures are **approximate**. The construct call charges the exact fee,
  and refuses an amount the wallet cannot cover.

## Unstaking, and the units trap

Unstaking is the same three steps against
`…/construct/unstake/transaction/batch`, with one difference that is easy to get
wrong:

| Direction | `amount` is denominated in |
| --- | --- |
| Stake | the **base token** - `baseToken.decimals` |
| Unstake | the **wrap token** (shares) - `wrapToken.decimals` |

A withdrawal redeems the share token the deposit minted, so it is denominated in
that token, not in the asset the user originally deposited. The two can have
different decimals, and their exchange rate drifts as the position accrues -
which is exactly why the pool view carries `decimals` for both.

`amount` is a human-readable number (`250.5`), not base units; the API scales it
by the decimals of the correct token.

An unstake batch also revokes any base-token allowance the wallet still grants
the pool contract, so `bundle.calls` may carry an `approve(pool, 0)` right after
the withdrawal - see [Allowances are revoked on unstake](/security#allowances-are-revoked-on-unstake).

<Callout type="caution" title="Unstaking a full position">
  Read the user's share balance and unstake that, rather than converting the
  staked amount back through an exchange rate you computed yourself. The rate
  moves between your read and the transaction landing.
</Callout>

## Failure modes

| Status | Cause |
| --- | --- |
| `400` | Unknown pool slug, the pool has `stakingEnabled: false`, the user has no linked wallet, neither `amount` nor `maxAmount` was sent, the wallet cannot afford amount plus fee ([`INSUFFICIENT_BALANCE`](/errors#insufficient-balance)), an unstake above what the pool can pay out now, or the intent is unknown, already used or expired |
| `403` | Your token lacks `transactions:write` (or `pools:read` on `fee-estimate`), or your team has switched this pool off in the panel |
| `404` | No such user, or the user belongs to another partner |
