> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gauntlet.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# CONTEXT.md

> Copy this into your LLM context window for accurate, grounded answers about the Gauntlet SDK.

Copy the block below into your system prompt or context window. It gives an LLM everything it needs to answer questions about the SDK, generate correct integration code, and avoid common mistakes.

````markdown theme={null}
# Gauntlet SDK — CONTEXT.md

## Package

- Name: `@gauntlet-xyz/sdk`
- Install: `npm install @gauntlet-xyz/sdk` (peer dep: `viem >=2.0.0`)
- Import paths:
  - `@gauntlet-xyz/sdk` — root: GauntletClient, AttributionMode, all functions, types, errors
  - `@gauntlet-xyz/sdk/evm` — EVM subpath: tx-building functions + types (preferred for tree-shaking)
  - `@gauntlet-xyz/sdk/api` — Gaia REST API client (`GauntletApi`) + data helpers, usable standalone
  - `@gauntlet-xyz/sdk/privy` — Privy wallet integration helpers

---

## GauntletClient

Main entry point. Pass one instance to every SDK function.

```typescript
import { GauntletClient, AttributionMode } from '@gauntlet-xyz/sdk'

const client = new GauntletClient({
  evmClients: { [chainId]: publicClient }, // viem PublicClient per chain
  wallet: walletClient,                    // viem WalletClient — SDK reads account address only, never signs
  apiKey: '<YOUR_API_KEY>',                // optional — sent as x-api-key on client.api requests
  apiUrl: 'https://api.gauntlet.xyz',      // optional — override Gaia API origin (relative path OK in browser)
  builderCode: 'your-code',               // optional — MUST be issued by Gauntlet, not self-serve
  attributionMode: AttributionMode.PUBLIC, // optional — default PUBLIC
})

client.api // typed Gaia REST API client — see "Gaia API" section
```

| Param | Type | Required | Notes |
|-------|------|----------|-------|
| `evmClients` | `Record<number\|string, PublicClient>` | For tx methods | chainId → viem PublicClient |
| `wallet` | `WalletClient` | For tx methods | Only reads `wallet.account.address` |
| `apiKey` | `string` | No | Gaia API key, sent as `x-api-key`. Anonymous access is rate-limited. |
| `apiUrl` | `string` | No | Gaia API origin override. Defaults to `https://api.gauntlet.xyz`. In a browser it may be a relative path (e.g. a Next.js rewrite like `/gauntlet-api`). |
| `builderCode` | `string` | No | Must be issued by Gauntlet. Without it, transactions are unattributed. |
| `attributionMode` | `AttributionMode` | No | `PUBLIC` (default). `ENCODED`/`PRIVATE` throw `UnimplementedFeatureError`. |

Other members: `client.manifest` (Promise of the bundled vault manifest), `client.setManifest(manifest)` (override it), `client.getPublicClient(chainId?)` (defaults to Base; throws `RpcNotConfiguredError`).

---

## EVM Functions

### getDepositTx(client, params) → Promise<PreparedTx[]>

Builds ordered EVM transactions to deposit into a vault. Returns 1–2 steps: optional ERC-20 `approve` (only if allowance insufficient) + `deposit` or `requestDeposit`.

```typescript
import { getDepositTx, VaultId } from '@gauntlet-xyz/sdk/evm'

const steps = await getDepositTx(client, {
  vaultId: VaultId.AeraUsdAlpha, // or any VaultId value or raw string
  amount: 1_000_000n,            // token base units
  chainId: 8453,                 // optional; defaults to Base (8453) for multi-chain vaults
  assetSymbol: 'USDC',          // optional; required for multi-asset vaults
  depositMode: 'async',         // optional; 'async' | 'sync'. Availability read from live vault config.
  receiver: '0x...',            // optional; defaults to wallet.account. Aera V1: MUST equal signer.
  slippageBps: 100,             // optional; integer 0–10000. Default 100 (1%).
  solverTip: 0n,                // optional; tip for async Aera provisioner requests. Default 0.
  maxPriceAge: 864_000n,        // optional; max price age (seconds) for async Aera requests. Default 10 days.
})
```

Throws: `VaultNotFoundError`, `AccountRequiredError`, `UnsupportedDepositModeError`,
`UnsupportedAssetError`, `InvalidSlippageBPSError`, `InvalidSolverTipError`, `RpcNotConfiguredError`

---

### getWithdrawTx(client, params) → Promise<PreparedTx[]>

Builds ordered EVM transactions to withdraw from a vault. Exactly one of `shares`, `amount`, or `entireAmount` is required.

```typescript
import { getWithdrawTx, VaultId } from '@gauntlet-xyz/sdk/evm'

const steps = await getWithdrawTx(client, {
  vaultId: VaultId.AeraUsdAlpha,
  entireAmount: true,   // OR: shares: 500_000000000000000000n  OR: amount: 1_000_000n
  chainId: 8453,        // optional; defaults to Base
  assetSymbol: 'USDC', // optional; required for multi-asset vaults
  depositMode: 'async', // optional; 'async' | 'sync'. Aera: withdrawals blocked for 1 hour after a sync deposit.
  receiver: '0x...',   // optional; receives the withdrawn assets (shares always burn from the signer). Defaults to wallet.account
  slippageBps: 100,    // optional; integer 0–10000. Default 100 (1%).
  solverTip: 0n,       // optional; default 0
  maxPriceAge: 864_000n, // optional; default 10 days
})
```

Throws: `VaultNotFoundError`, `AccountRequiredError`, `UnsupportedDepositModeError`,
`UnsupportedAssetError`, `InvalidSlippageBPSError`, `InvalidSolverTipError`, `InvalidWithdrawParamsError`, `RpcNotConfiguredError`

---

### getDepositReceiverApprovalTx(client, params) → Promise<PreparedTx>

Aera V2 only. Builds the receiver-side approval transaction required before a depositor
can make **sync** deposits to a separate receiver. The client's wallet account is the
receiver and must sign this transaction.

```typescript
import { getDepositReceiverApprovalTx } from '@gauntlet-xyz/sdk/evm'

const step = await getDepositReceiverApprovalTx(client, {
  vaultId: 'gtusda',
  depositor: '0xDepositor', // the address that will deposit on the receiver's behalf
  approved: true,           // optional; default true. false revokes.
  chainId: 8453,            // optional
})
```

Throws: `VaultNotFoundError`, `AccountRequiredError`, `UnsupportedFeatureError` (on V1 vaults), `RpcNotConfiguredError`

---

### getVaults(client, filter?) → Promise<VaultInfo[]>

Returns vaults from bundled manifest. No network request.

```typescript
import { getVaults } from '@gauntlet-xyz/sdk/evm'

const vaults = await getVaults(client, { chainId: 8453, protocol: 'aera' })
// filter is optional — omit to return all vaults
```

---

### getUserCurrentBalance(client, params) → Promise<UserCurrentBalance[]>

Returns balance breakdown for a user across all deployments of a vault. Only works for Aera multi-depositor vaults. Makes on-chain RPC calls (scans ~3 days of provisioner events for pending requests).

```typescript
import { getUserCurrentBalance } from '@gauntlet-xyz/sdk'

const balances = await getUserCurrentBalance(client, {
  vaultId: 'gtusda',
  address: '0xUser',
  chainId: 8453,  // optional; omit to return one entry per chain the vault is deployed on
})
```

Throws: `VaultNotFoundError`, `UnsupportedProtocolError`, `ChainMismatchError`, `RpcNotConfiguredError`

---

## Gaia API — client.api

Typed client for the Gaia REST API (`api.gauntlet.xyz`): indexed vaults, user positions
with PnL, activity logs, TVL, and token prices. Available as `client.api` on a configured
`GauntletClient`, or standalone:

```typescript
import { GauntletApi } from '@gauntlet-xyz/sdk/api'
const api = new GauntletApi({ apiKey: process.env.GAUNTLET_API_KEY })
```

### Conventions

- **Vault ids are CAIP-10-style**: `"{chainId}:{address}"` with a lowercase address
  (e.g. `"8453:0xabc…"`) — NOT manifest vault ids like `'gtusda'`. Convert with
  `apiVaultIdFromVaultId(client, 'gtusda')` / `vaultIdFromApiVaultId(client, apiId)`.
- **Amounts are human-unit decimal strings** (e.g. `"1250.5"`), not base-unit bigints.
  Convert with `decimalToBigInt(value, decimals)` / `sharesToBigInt(value)` (shares are
  always 18 decimals — `SHARE_DECIMALS`). Conversion is exact: excess precision throws
  `DecimalPrecisionError` instead of rounding.
- **Pagination is cursor-based**: pass `{ next: previousResponse.meta.next_cursor }`.
  Page size is capped at 1000 rows.
- Response types are generated from the service's OpenAPI spec, so they cannot drift
  from the server models.

### Endpoints

```typescript
api.health()                                  // GET /health — liveness (version + uptime)
api.chainSyncStatus()                         // GET /health/chains — per-chain indexer freshness
api.vaults(page?)                             // GET /v1/vaults — all indexed vaults with live metrics (TVL, APY, unit price)
api.vault(apiVaultId)                         // GET /v1/vaults/{vault_id}
api.vaultDefinition(apiVaultId)               // GET /v1/vaults/{vault_id}/definition — raw indexed definition
api.vaultTimeseries(apiVaultId, window?)      // GET /v1/vaults/{vault_id}/timeseries — TVL / unit-price / APY history
api.positions(wallet, page?)                  // GET /v1/users/{wallet}/positions — all positions with PnL
api.position(wallet, apiVaultId)              // GET /v1/users/{wallet}/positions/{vault_id} — one position with PnL breakdown
api.positionTimeseries(wallet, apiVaultId, window?) // value / cost-basis / PnL / ROI history
api.activity(wallet, options?)                // GET /v1/users/{wallet}/activity — one page of the immutable event log
api.activityRows(wallet, options?)            // async generator — auto-follows next_cursor across pages
api.tvl({ mode? })                            // GET /v1/tvl — aggregate Gauntlet TVL ('chain' | 'strategy')
api.latestPrice({ address, chainId, at? })    // GET /v1/prices — latest (or point-in-time) USD token price
api.priceTimeseries({ address, chainId, start, end, granularity?, limit? }) // USD price history
```

Options types: `PageOptions { next?, limit?, order? }`,
`TimeWindowOptions extends PageOptions { start?, end?, granularity? }`
(`granularity`: `'hour' | 'day' | 'week' | 'month'`; dates are ISO 8601 / RFC 3339),
`ActivityOptions extends PageOptions { vaultId? }` (CAIP-10; omit for wallet-wide activity).

Failed requests throw `GauntletApiError` with `.status`, `.path`, and optional `.code`.

### Activity flows (high-level helper)

The raw activity log is an immutable row stream; Aera async lifecycles span multiple rows
(`deposit_pending` → `deposit`/`deposit_refunded`) paired by `request_hash`.
`getActivityFlows` fetches and stitches them into one flow per user action — the
replacement for scanning vault event logs over RPC.

```typescript
import { getActivityFlows, waitForRequestSettlement } from '@gauntlet-xyz/sdk/api'

const flows = await getActivityFlows(client.api, wallet, { vaultId?, maxRows? }) // newest-first
const open = flows.filter((f) => f.status === 'pending')

// After submitting a requestDeposit/requestRedeem tx, poll until it settles or refunds:
const settled = await waitForRequestSettlement(client.api, wallet, requestHash, {
  vaultId?, pollIntervalMs?: 5_000, timeoutMs?: 600_000, // throws SettlementTimeoutError on expiry
})
```

```typescript
type ActivityFlow = {
  kind: 'deposit' | 'withdraw' | 'transfer_in' | 'transfer_out'
  status: 'settled' | 'pending' | 'refunded'
  vaultId: string            // CAIP-10
  requestHash: string | null // Aera async correlation hash; null for sync flows
  requestedAt: Date | null
  settledAt: Date | null     // null while pending
  assets: { decimal: string; raw: bigint | null; token: TokenRef | null }
  shares: bigint             // 18-decimal base units
  txHashes: string[]
}
```

`stitchActivityFlows(rows)` is the pure stitcher if you already have rows.

### Position history (high-level helper)

Replays one vault's activity into a chronological position timeline — running share
balance, escrowed pending amounts, cumulative net asset flows.

```typescript
import { getPositionHistory } from '@gauntlet-xyz/sdk/api'

const history = await getPositionHistory(client.api, wallet, apiVaultId)
// { vaultId, token, points: PositionHistoryPoint[] } — oldest-first
// Each point: { timestamp, txHash, type, sharesDelta, assetsDelta, sharesBalance,
//               pendingDepositAssets, pendingRedeemShares, netAssetsIn }
// A wallet that never touched the vault gets an empty timeline, not an error.
```

`buildPositionHistory(rows)` is the pure builder.

---

## Privy — @gauntlet-xyz/sdk/privy

Helpers for Privy embedded/connected wallets. No dependency on any `@privy-io` package.

```typescript
import { createGauntletClientFromPrivy, walletClientFromPrivy } from '@gauntlet-xyz/sdk/privy'
import { base } from 'viem/chains'

// One-call setup: builds public clients per chain + a signing wallet from the Privy provider
const { wallets } = useWallets() // @privy-io/react-auth
const client = await createGauntletClientFromPrivy({
  wallet: wallets[0],
  chains: [base],        // first chain is the wallet's signing chain
  transports: { ... },   // optional per-chain viem Transport; defaults to public RPC http()
  apiKey, apiUrl, attributionMode, builderCode, // optional, same as GauntletClient
})

// Or just wrap the Privy wallet in a viem WalletClient:
const walletClient = await walletClientFromPrivy(wallets[0], base)
```

---

## Transaction Submission

`getDepositTx` and `getWithdrawTx` return `PreparedTx[]`. Steps MUST be executed in order.
An `approve` step, when present, always comes first.

### PreparedTx type

```typescript
type PreparedTx = {
  payload: {
    type: string    // 'approve' | 'deposit' | 'requestDeposit' | 'redeem' | 'requestRedeem' | 'withdraw' | 'setDepositReceiverApproval'
    to: Address     // contract address
    data: Hex       // ABI-encoded calldata + attribution suffix pre-concatenated
    account?: Address
  }
  tx: EvmTxStep   // structured ABI fields + raw attribution bytes
}

type EvmTxStep = {
  type: 'approve' | 'deposit' | 'requestDeposit' | 'redeem' | 'requestRedeem' | 'withdraw' | 'setDepositReceiverApproval'
  address: Address
  abi: Abi
  functionName: string
  args: readonly unknown[]
  account: Address
  attribution?: Hex  // raw ERC-8021 suffix bytes only — NOT the full calldata
}
```

---

### Path 1: step.payload + sendTransaction

**Use for:** backend scripts, embedded wallets (Privy, Dynamic), server-side signing, EVM simulation.
Attribution is baked into `payload.data` — cannot be dropped regardless of wallet or provider.

```typescript
for (const step of steps) {
  // estimateGas simulates exact calldata before broadcast — catches reverts before spending gas
  const gas = await publicClient.estimateGas({
    to: step.payload.to,
    data: step.payload.data,
    account: step.payload.account,
  })
  // gas limit prevents out-of-gas failures on-chain
  const hash = await walletClient.sendTransaction({ ...step.payload, gas })
  // wait for confirmation before next step — deposit reverts if preceding approve is not mined
  const receipt = await publicClient.waitForTransactionReceipt({ hash, confirmations: 2 })
  if (receipt.status !== 'success') throw new Error(`Reverted: ${step.payload.type}`)
}
```

---

### Path 2: step.tx + writeContract

**Use for:** browser wallets via wagmi (MetaMask, Coinbase Wallet, WalletConnect).

CRITICAL: `dataSuffix` MUST be `step.tx.attribution`. Omitting it silently drops attribution —
the transaction succeeds on-chain but volume is NOT tracked.

```typescript
import { useWriteContract } from 'wagmi'

const { writeContractAsync } = useWriteContract()

for (const step of steps) {
  await writeContractAsync({
    address: step.tx.address,
    abi: step.tx.abi,
    functionName: step.tx.functionName,
    args: step.tx.args,
    account: step.tx.account,
    dataSuffix: step.tx.attribution, // REQUIRED — omitting silently drops attribution
  })
}
```

---

## Attribution

- Format: ERC-8021 — `0x8021` + UTF-8 hex of builderCode, e.g. `"acme"` → `0x802161636d65`
- Appended as suffix to calldata. Does not affect contract execution.
- `builderCode` MUST be issued by Gauntlet. Unregistered codes append bytes but are not counted.
- Without `builderCode`, no bytes are appended and transactions are unattributed.
- `AttributionMode.ENCODED` and `AttributionMode.PRIVATE` throw `UnimplementedFeatureError`.

---

## VaultId Enum

Well-known vault ids. The bundled manifest contains more vaults than this enum — any
manifest `vaultId` string is accepted wherever `VaultId` is; use `getVaults(client)` to
enumerate everything. The Gaia API indexes even more vaults than the manifest lists.

```typescript
enum VaultId {
  BaseUsdcPrime             = 'baseUsdcPrime',
  EthUsdcPrime              = 'ethUsdcPrime',
  AeraUsdAlpha              = 'gtusda',        // gtUSDa — multi-chain USDC, Aera async
  EthUsdcPrimeV2            = 'ethUsdcPrimeV2',
  AeraUsdAlphaStaging       = 'stgusda',
  AeraUsdAlphaDev           = 'devusda',
  AeraUsdAlphaDevDeux       = 'devusda2',
  AeraLeveredFalconX        = 'gpaafalconx',
  AeraLeveredFalconXStaging = 'pytstg',
  AeraSyrupUsdc             = 'gpsyrupusdc',
  AeraSyrupUsdcStaging      = 'syruppytstg',
  AeraBtcYield              = 'gtbtc',
  AeraBtcYieldStaging       = 'gtbtcstaging',
  AeraLeveredUsccStaging    = 'gtusccstg',
  AeraLeveredUscc           = 'gtuscc',
  AeraLend                  = 'gtlend',
  AeraKastEth               = 'kasteth',
}
```

---

## Types

```typescript
type VaultInfo = {
  vaultId: string
  name: string
  protocol: 'aera' | 'morpho'
  strategy: string
  deployments: VaultDeployment[]
}

type VaultDeployment = {          // currently always EvmVaultDeployment; narrow on `chain`
  chain: 'evm'
  chainId: number
  vaultAddress: Address
  vaultType: 'single-depositor' | 'multi-depositor'
  supplyToken: TokenInfo[]
  expirationDays?: number         // days before an async request deadline expires; default 3
}
// NOTE: provisioner/feeCalculator addresses and sync/async support are NOT in the
// manifest — they are resolved from live chain state (see Key Constraints).

type TokenInfo = {
  symbol: string
  address: Address
  decimals: number
}

type UserCurrentBalance = {
  chain: string           // 'base' | 'ethereum' | 'arbitrum' | 'optimism'
  token: Address
  decimals: number
  pendingDeposit: bigint  // assets locked in provisioner after async deposit — awaiting solver, not yet earning yield; 0n if none
  balance: bigint         // assets actively earning in the vault; 0n if no position
  pendingWithdraw: bigint // assets redeemed but not yet claimable after async withdraw — awaiting solver, no longer earning; 0n if none
}
// All three bigint fields are always present. 0n means no position — not an error.

type VaultFilter = {
  chainId?: number
  protocol?: string
}

enum AttributionMode {
  PUBLIC  = 'public',   // ERC-8021 builder code — default
  ENCODED = 'encoded',  // NOT IMPLEMENTED — throws UnimplementedFeatureError
  PRIVATE = 'private',  // NOT IMPLEMENTED — throws UnimplementedFeatureError
}

const ContractVersion = { V1: 'v1', V2: 'v2' } // Aera provisioner/feeCalculator generations
```

---

## Balance Lifecycle

Pending states represent funds in transit — locked in the provisioner contract and awaiting the vault solver. During `pendingDeposit`, assets are not yet earning yield. During `pendingWithdraw`, vault shares have been redeemed and assets are no longer earning yield but have not yet been transferred. Funds are safe in both pending states; the delay (~2h, up to 12h) is operational, not a risk.

| Event | Effect on balance fields |
|-------|--------------------------|
| Async deposit submitted | Amount enters `pendingDeposit` — locked in provisioner, not yet earning |
| Solver processes deposit (~2h, up to 12h) | `pendingDeposit` → `balance` — now earning yield |
| Sync deposit | Goes directly to `balance` — earning immediately |
| Async withdraw submitted | `balance` → `pendingWithdraw` — shares redeemed, no longer earning, not yet claimable |
| Solver processes withdraw (~2h, up to 12h) | Leaves `pendingWithdraw`; claimable as ERC-20 in receiver wallet |
| Sync withdraw | Removed from `balance` immediately; claimable in receiver wallet. Aera vaults: blocked for 1 hour after the user's most recent sync deposit |

---

## Errors

All extend `GauntletSDKError extends Error`.

| Class | Properties | Thrown when |
|-------|------------|-------------|
| `VaultNotFoundError` | `.vaultId`, `.chainId?` | Vault ID not in manifest or not on requested chain |
| `UnsupportedAssetError` | `.asset`, `.vaultId` | Token not accepted by vault |
| `ChainMismatchError` | `.expected`, `.received` | chainId param doesn't match deployment |
| `UnsupportedDepositModeError` | `.vaultId`, `.requested`, `.available` | Requested sync/async not supported |
| `RpcNotConfiguredError` | `.chainId` | No evmClients entry for required chain |
| `AccountRequiredError` | — | No wallet in client config |
| `UnsupportedProtocolError` | `.protocol` | getUserCurrentBalance called on non-Aera or single-depositor vault |
| `InvalidWithdrawParamsError` | — | Not exactly one of shares/amount/entireAmount provided |
| `InvalidSlippageBPSError` | `.slippage` | slippageBps not an integer in 0–10000 |
| `InvalidSolverTipError` | `.solverTip`, `.availableAmount` | solverTip ≥ available token amount |
| `StalePriceError` | `.blockTimestamp`, `.maxPriceAge`, `.priceTimestamp` | Vault's on-chain price older than its max age |
| `UnimplementedFeatureError` | `.feature` | ENCODED/PRIVATE attribution modes, getSourceId |
| `UnsupportedFeatureError` | `.feature` | Feature not supported (e.g. separate receiver / receiver approval on Aera V1) |
| `UnitConversionError` | `.vaultAddress` | Fee calculator unavailable on-chain |
| `GauntletApiError` | `.status`, `.path`, `.code?` | Gaia API request failed (non-2xx) |
| `InvalidDecimalError` | `.value` | String is not a valid decimal |
| `DecimalPrecisionError` | `.value`, `.decimals` | Conversion would lose precision (never rounds) |
| `InvalidCaipIdError` | `.id` | Not a valid `"{chainId}:{address}"` vault id |
| `SettlementTimeoutError` | `.requestHash`, `.timeoutMs` | waitForRequestSettlement deadline expired |

---

## Key Constraints

- The SDK never signs. `wallet` is read-only for `wallet.account.address`.
- `getDepositTx`/`getWithdrawTx` require `evmClients` + `wallet` (check allowance on-chain).
- `getVaults` reads bundled manifest — no network call required.
- **Aera runtime is resolved from live chain state**, not the manifest: provisioner and
  fee-calculator addresses are read from the vault contract, and V1 vs V2 is detected
  on-chain (`resolveAeraRuntimeContracts` / `resolveContractVersion`, exported for advanced use
  alongside price/unit utilities `convertTokenToUnits`, `convertUnitsToToken`, `getVaultState`, `isVaultPaused`).
- **Deposit mode:** sync/async availability is read from live vault configuration. When
  both are available and `depositMode` is omitted, async is used. Morpho vaults are sync-only.
- **Aera receiver rules:** on V1 vaults, `receiver` MUST equal the signer — a different
  address throws `UnsupportedFeatureError`. V2 vaults support a separate receiver; for
  **sync** deposits to a separate receiver, the receiver must first sign
  `getDepositReceiverApprovalTx` approving the depositor. On withdrawals, `receiver` is
  where the assets are sent; shares always burn from the signer.
- **Unit lock after sync deposit:** on Aera vaults, a sync deposit locks all of the
  user's vault units for the vault's deposit refund timeout, currently 1 hour. Any
  withdraw (sync or async) or unit transfer within that window reverts on-chain with
  `Aera__UnitsLocked`. Async deposits do not trigger the lock.
- `getUserCurrentBalance` scans ~3 days of provisioner events — requires `evmClients` for each chain queried.
- **Multi-chain vaults** (e.g. gtUSDa): `chainId` defaults to Base (8453) when omitted.
- **Single-chain vaults:** provide `chainId` matching the deployment, or omit it. A mismatched chainId throws `ChainMismatchError`.
- Steps from `getDepositTx`/`getWithdrawTx` must be sent in order. Deposit reverts if preceding approve is not mined first.
- Using Path 2 (writeContract): `dataSuffix` must be `step.tx.attribution` or attribution is silently lost.
- **Gaia API:** vault ids are CAIP-10 (`"{chainId}:{address}"`, lowercase) — convert with
  `apiVaultIdFromVaultId`; amounts are human-unit decimal strings — convert with
  `decimalToBigInt`/`sharesToBigInt` (shares are always 18 decimals); pages cap at 1000 rows
  (cursor via `meta.next_cursor`); anonymous access is rate-limited.
````
