---
name: push-pusd
description: Integrate with PUSD and PUSD+ — the cross-chain par-backed stablecoin and yield-bearing companion on Push Chain Donut Testnet. Use when minting PUSD by depositing USDC/USDT from any supported chain (Ethereum, Base, Arbitrum, Solana, BNB), redeeming PUSD for reserve tokens, minting or redeeming PUSD+, quoting fees or NAV, reading protocol state, or calling the contracts on-chain from another Solidity contract. Covers React (@pushchain/ui-kit), Node.js (@pushchain/core), and on-chain Solidity paths, plus external-chain wallets via multicall and native Push EOAs. Triggers on 'mint PUSD', 'redeem PUSD for USDC', 'PUSD+ yield', 'integrate PUSD', 'PUSD contract addresses'.
license: MIT
metadata:
  version: '3.3.0'
  network: testnet
  chain_id: '42101'
  rpc: https://evm.donut.rpc.push.org/
  explorer: https://donut.push.network
  pusd: '0x774c799646bB60103e38Fd65b18D81bbDD1Aa760'
  pusd_manager: '0x775A23E81fCd1f9C2997663b45401bEe80e4242A'
  pusd_plus_vault: '0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16'
  insurance_fund: '0x995348eABc39ecdF591b8A93B3568Dc3C1c0BBAD'
  frontend_package: '@pushchain/ui-kit'
  backend_package: '@pushchain/core'
  onchain: solidity ^0.8.20
  skill_url: https://pusd.push.org/agents/skill/push-pusd/SKILL.md
  llms_txt: https://pusd.push.org/llms.txt
  docs_url: https://pusd.push.org/docs
  push_llms_txt: https://push.org/llms.txt
---

# Skill: PUSD + PUSD+ Integration

**Intent**: Mint and redeem PUSD (par-backed) and PUSD+ (yield-bearing) on Push Chain — from any frontend, backend, or on-chain context.

PUSD is a 6-decimal ERC-20 stablecoin pegged 1:1 to reserve stablecoins (USDC / USDT bridged from Ethereum, Base, Arbitrum, Solana, BNB). PUSD+ is a 6-decimal yield-bearing companion whose NAV grows monotonically as the vault collects LP fees from Uniswap V3 stable/stable pools on Push Chain. Both live on Push Chain Donut Testnet (chain ID 42101). All accounting is at 6 decimals.

Integration choices:

- **Off-chain SDK** — `@pushchain/ui-kit` (React) or `@pushchain/core` (Node). Goes through Push Chain's universal transaction layer. Handles cross-chain wallet identity, payload encoding, and optional bridging. Recommended for new integrations.
- **On-chain Solidity** — Another contract on Donut imports the PUSD / PUSDManager / PUSDPlusVault interfaces and calls them directly. Used when your protocol holds PUSD/PUSD+ or mints / burns on behalf of users.

> **Push Chain agent layer.** PUSD rides on Push Chain's universal transaction primitives. For the broader SDK surface (signer wrapping, route detection, progress hooks, cascades, error recovery, constants), see [push.org/llms.txt](https://push.org/llms.txt) — it indexes the `push-frontend`, `push-backend`, and `push-contracts` skills plus workflows and examples. Pull it when something here points outside PUSD's domain (e.g. unknown progress-hook IDs, generic universal-transaction routing, or wallet provider setup beyond the snippet below).

---

## Architecture

Three contracts plus a passive sidecar on Donut. All UUPS proxies.

```
PUSD.sol              ─ ERC-20, 6 decimals
  mint(to, amount)            ← MINTER_ROLE only  → held by PUSDManager
  burn(from, amount)          ← BURNER_ROLE only  → held by PUSDManager

PUSDManager.sol       ─ reserve orchestrator (v2)
  // par-backed entrypoints (v1, unchanged)
  deposit(token, amount, recipient)                            → mints PUSD
  redeem(pusdAmount, preferredAsset, allowBasket, recipient)   → burns PUSD
  // yield-product entrypoints (v2)
  depositToPlus(tokenIn, amount, recipient)                    → mints PUSD+
  redeemFromPlus(plusAmount, preferredAsset, allowBasket, recipient)  → burns PUSD+

PUSDPlusVault.sol     ─ NAV-bearing custom ERC-20 (PUSD+, 6 decimals)
  // user-facing reads
  nav() / totalAssets() / previewMintPlus() / previewBurnPlus()
  // user-facing claim path (queued redeems)
  fulfillQueueClaim(queueId)

InsuranceFund.sol     ─ passive sidecar; receives the LP-fee haircut
```

> PUSD+ is a **custom 6-decimal ERC-20 with NAV-per-share**, not ERC-4626. Use `previewMintPlus` / `previewBurnPlus` to quote.

> **Pausability (PUSD+ only).** The vault's `mintPlus` / `burnPlus` / `fulfillQueueClaim` / `rebalance` are `whenNotPaused` — `GUARDIAN_ROLE` can pause, only `DEFAULT_ADMIN_ROLE` can unpause. So **PUSD+ mint/redeem can revert while the vault is paused** (OZ `EnforcedPause()` — see [Errors](#errors)). `PUSDManager` (PUSD `deposit` / `redeem`) is **not** pausable. Handle a paused-vault revert on PUSD+ flows; PUSD flows have no pause.

### Live addresses — Donut Testnet (chain 42101)

| Contract       | Proxy                                        |
| -------------- | -------------------------------------------- |
| PUSD           | `0x774c799646bB60103e38Fd65b18D81bbDD1Aa760` |
| PUSDManager    | `0x775A23E81fCd1f9C2997663b45401bEe80e4242A` |
| PUSDPlusVault  | `0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16` |
| InsuranceFund  | `0x995348eABc39ecdF591b8A93B3568Dc3C1c0BBAD` |

RPC: `https://evm.donut.rpc.push.org/` — Explorer: `https://donut.push.network`

Always interact with the **proxy** addresses. Implementations change on upgrade; the proxies do not.

> **Testnet only — no mainnet deployment exists yet.** All admin and upgrade authority (UUPS) currently sits with a single admin EOA and no timelock: upgrades execute on one signature. Multisig + timelocked `UPGRADER_ROLE` is the mainnet target (see [ADR 0002](https://github.com/pushchain/push-chain-pusd/blob/main/docs/design/decisions/0002-access-control-model.md)). Size custody decisions accordingly.

> Reads need no SDK: any ethers/viem provider pointed at `https://evm.donut.rpc.push.org/` works for every view function in this skill.

### Reserve tokens (10 total, 5 chains, all 6 decimals on Donut)

| Symbol   | Origin chain     | Donut address                                |
| -------- | ---------------- | -------------------------------------------- |
| USDT.eth | Ethereum Sepolia | `0x0f97A213207703923F5f0C613C9827f7C9A0f96B` |
| USDC.eth | Ethereum Sepolia | `0x7A58048036206bB898008b5bBDA85697DB1e5d66` |
| USDT.sol | Solana Devnet    | `0x4f1A3D22d170a2F4Bddb37845a962322e24f4e34` |
| USDC.sol | Solana Devnet    | `0x04B8F634ABC7C879763F623e0f0550a4b5c4426F` |
| USDT.base| Base Sepolia     | `0x148823809B853e1db187BC09A9ac909BC42F971a` |
| USDC.base| Base Sepolia     | `0xD7C6cA1e2c0CE260BE0c0AD39C1540de460e3Be1` |
| USDT.arb | Arbitrum Sepolia | `0xFE6E9DF2BbC9ce05D98b83B1365df6DcA9951891` |
| USDC.arb | Arbitrum Sepolia | `0x1091cCBA2FF8d2A131AE4B35e34cf3308C48572C` |
| USDT.bsc | BNB Testnet      | `0x731aF1Da5365259d27528557EE4aFBA4baC90ef2` |
| USDC.bsc | BNB Testnet      | `0x120EBf25Dad7D6a09Ad2316f23f9Be95DBb90639` |

> Source of truth: this list ≡ on-chain enumeration via `PUSDManager.getSupportedTokenAt(i)` ≡ [`app/src/contracts/tokens.ts`](https://github.com/pushchain/push-chain-pusd/blob/main/app/src/contracts/tokens.ts) ≡ [`contracts/deployed.txt`](https://github.com/pushchain/push-chain-pusd/blob/main/contracts/deployed.txt) (newest deployment first). Wrapper registry: [push.org/agents/contract-addresses.json](https://push.org/agents/contract-addresses.json). On-chain `chainNamespace` strings are `Ethereum_Sepolia`, `Solana_Devnet`, `Base_Testnet`, `Arbitrum_Sepolia`, `BNB_Testnet`.

> **BNB pair naming (Deployment 6, 2026-05-18):** the **manager-registered** token names (`getTokenInfo().name`) migrated from `.bnb` to `.bsc` — both `USDT.bsc` and `USDC.bsc` are tracked under this suffix. The tokens' own ERC-20 `symbol()` / `name()` metadata still reads `.bnb`. For cross-chain `funds` bridging the MOVEABLE key uses the **origin-chain** namespace with a bare symbol — `PushChain.CONSTANTS.MOVEABLE.TOKEN.BNB_TESTNET.USDC`. (`.bsc`/`.bnb` sub-accessors exist only under the Donut `PUSH_TESTNET_DONUT` namespace, which is not what bridging uses.) Legacy `USDT.bnb` from Deployment 5 is no longer registered.

> Admin / vault-only entrypoints on PUSDManager — never call from integrator code. They are gated **differently**: `setPlusVault` is `DEFAULT_ADMIN_ROLE` (timelock), `setFeeExempt` is `ADMIN_ROLE`, and `depositForVault` is the only one behind the two-key vault check (`msg.sender == plusVault && feeExempt[plusVault]`).

---

## Getting testnet assets

Every journey below assumes funded wallets. From zero:

1. **Reserve tokens** — every supported reserve token exposes a public `faucet()` (no arguments) on its **origin chain**. The origin-chain contract is a *different address* from the Donut wrapper above — resolve it from the SDK registry, exactly as the dApp does:

   ```ts
   // e.g. USDT on Ethereum Sepolia
   const originAddr = PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDT.address;
   ```

   Open that address on the origin chain's explorer, connect the wallet you want to fund, and call `faucet()`. Zero-code alternative: the dApp's mint page on [pusd.push.org](https://pusd.push.org) links every token's faucet directly. Exceptions: Solana USDC comes from the [Circle faucet](https://faucet.circle.com/); for USDT.sol ask in the [Push Discord](https://discord.com/invite/pushchain).

2. **Origin-chain gas (path A)** — calling `faucet()` and signing the bridged mint still costs origin-chain gas (e.g. Sepolia ETH from any public Sepolia faucet). Path A needs no Donut gas — the relay covers execution on Push Chain.

3. **Donut gas (path B)** — a native Push EOA pays Donut gas in the chain's native token (18 decimals): fund it from the [Push Chain faucet](https://faucet.push.org/) before the first transaction.

4. **Reserve tokens for a path-B EOA** — inbound `funds` bridging only lands tokens in path-A relay-managed accounts. To fund a native EOA: bridge via path A (or the dApp's convert page), then send the Donut wrapper token to the EOA with a plain ERC-20 `transfer` — the wrappers are ordinary ERC-20s on Donut.

> Every reserve-table row maps to a MOVEABLE key as `PushChain.CONSTANTS.MOVEABLE.TOKEN.<ORIGIN_KEY>.<SYMBOL>`, with origin keys `ETHEREUM_SEPOLIA`, `SOLANA_DEVNET`, `BASE_SEPOLIA`, `ARBITRUM_SEPOLIA`, `BNB_TESTNET` and bare symbols `USDT` / `USDC`.

---

## Fee model

| Fee                     | When               | Default       | Max             | Effect                                                   |
| ----------------------- | ------------------ | ------------- | --------------- | -------------------------------------------------------- |
| Deposit haircut         | On mint            | 0 bps (0%)    | 1000 bps (10%)  | Stays in reserve as surplus, used to deprecate risky tokens |
| Base redemption fee     | On every redeem    | 5 bps (0.05%) | 100 bps (1%)    | Accrued per-token, swept to treasury                     |
| Preferred asset premium | Single-token redeem | preferredFeeMin–Max | 200 bps (2%) | Interpolated by token liquidity (formula below)          |
| PUSD+ mint              | depositToPlus      | 0 bps         | —               | No PUSD+-specific fee; the row-1 deposit haircut (currently 0 bps) still applies to the reserve leg. Wrap leg (PUSD in) charges nothing |
| PUSD+ redeem            | redeemFromPlus     | 0 bps         | —               | Protocol-internal compose; reserve payout charges no base or preferred fee |

```
Net PUSD minted  = amount − floor(amount × haircutBps / 10000)
Net token out    = pusdAmount − floor(pusdAmount × (baseFee + preferredFee) / 10000)
```

**Preferred-fee interpolation** (mirrors `PUSDManager._calculatePreferredFee`). There is **no on-chain redeem quote function** — compute the expected output off-chain from `baseFee()`, `preferredFeeMin()`, `preferredFeeMax()`, and per-token liquidity:

```
availableLiquidity(token) = token.balanceOf(PUSDManager) − getAccruedSurplus(token)
liquidityShareBps         = availableLiquidity(token) × 10000 / Σ availableLiquidity(all non-REMOVED tokens)

share ≥ 5000 (≥50%)  → preferredFee = preferredFeeMin
share ≤ 1000 (≤10%)  → preferredFee = preferredFeeMax
else                 → preferredFee = preferredFeeMax − (shareBps − 1000) × (max − min) / 4000
```

The preferred premium applies only when the redeem is served by the preferred route; the basket route charges `baseFee` only. `getAccruedSurplus(token)` returns unswept fees + haircuts held by the manager — subtract it from `balanceOf` to get real redeemable liquidity (also your pre-flight check that a preferred-asset redeem will succeed).

---

## Redemption routing (PUSDManager.redeem)

Three paths, picked automatically based on preferred-asset liquidity and token status:

| Route           | Condition                                                                 | Fee                      |
| --------------- | ------------------------------------------------------------------------- | ------------------------ |
| Preferred asset | preferredAsset redeemable (ENABLED / REDEEM_ONLY) + sufficient liquidity + no funded EMERGENCY_REDEEM token exists | baseFee + preferredFee   |
| Basket          | preferred unavailable, allowBasket = true                                 | baseFee only             |
| Emergency       | a funded EMERGENCY_REDEEM token exists AND preferredAsset is a registered non-REMOVED token → forced proportional drain first | baseFee only (no preferred premium) |

> Pass `allowBasket = true` in production: if the preferred token runs dry the basket route activates instead of reverting with `"PUSDManager: preferred asset unavailable and basket not allowed"`. Note the basket route can still revert with `"PUSDManager: insufficient total liquidity"` if reserves across **all** tokens can't cover the redemption.

---

## PUSD+ redemption — three-tier fulfilment

`redeemFromPlus` first burns PUSD+ from the caller (committing them to current NAV), then sources PUSD via:

1. **Instant** — vault has idle PUSD ≥ pusdOwed → ship now.
2. **Convert** — idle PUSD short, vault converts idle non-PUSD basket tokens via fee-exempt manager path. Still no peg risk.
3. **Queue** — any residual is enqueued (partial fills are possible: some PUSD ships now, the rest queues). PUSD+ is already burned, NAV is fixed at the burn block. Keeper fills on the next rebalance; anyone can call `fulfillQueueClaim(queueId)` once the vault has PUSD on hand.

`redeemFromPlus` **returns nothing to off-chain callers** — read the vault's `BurnedPlus(from, plusIn, pusdOwed, pusdReturned, queueId)` event from the transaction receipt: `queueId != 0` means `pusdOwed − pusdReturned` was queued (`nextQueueId` is 1-indexed; `queueId == 0` means nothing was queued). See [Tracking a queued redeem](#tracking-a-queued-redeem) for the full recipe.

> **Queued residuals pay the original caller.** The queue entry records the account that burned the PUSD+ (`from`), **not** the `recipient` you passed to `redeemFromPlus`. Instant payouts go to `recipient`; queued residuals settle to the caller. This matters especially for intermediary contracts — see the on-chain section.

---

## Off-chain SDK — two write paths

Every PUSD/PUSD+ mutation goes through `pushChainClient.universal.sendTransaction(...)`. The **shape of the payload** depends on which wallet signed in.

**Path A — External-chain wallet** (MetaMask on Sepolia, Phantom on Solana, Coinbase Wallet, etc.) — the user gets a relay-managed account on Donut that supports multicall. Approve + deposit ride in **one signature**, batched as a multicall. Outer `to` is the zero address — the marker the relay reads as "walk each leg against its own `to`".

**Path B — Native Push EOA** (Push Wallet, or any private key signing directly against the Donut RPC) — a regular EVM externally-owned account, no multicall. Mint takes **two separate signatures** (approve, then deposit). **Redeem is one signature on either path** — `PUSDManager` holds `BURNER_ROLE` on PUSD and burns `msg.sender` directly, so no PUSD approval is required.

**Gas.** Path A abstracts gas — the user signs on their origin chain and needs no balance on Donut. Path B pays Donut gas in the chain's native token (18 decimals); fund the EOA from the [Push Chain faucet](https://faucet.push.org/) before the first transaction.

**Bridging.** If the reserve token lives on the user's origin chain (USDT on Sepolia, etc.) instead of already sitting on Donut, attach a `funds` param to the same call. The relay moves the tokens over to your Push Chain account before the legs execute. **Inbound** `funds` bridging on a deposit applies to path A only; path B assumes the token is already on Donut. (**Outbound** transfers — `to: { address, chain }` + `funds` — work on either path; see the cross-chain payout section.)

> **Version note:** the snippets below need `@pushchain/ui-kit` / `@pushchain/core` **≥ 6.x** (`CONSTANTS.MOVEABLE`, `prepareTransaction` / `executeTransactions`). Install with `@latest`.

### Shared constants (used by every snippet below)

```ts
const ZERO    = '0x0000000000000000000000000000000000000000' as const; // multicall sentinel
const PUSD    = '0x774c799646bB60103e38Fd65b18D81bbDD1Aa760' as const;
const MANAGER = '0x775A23E81fCd1f9C2997663b45401bEe80e4242A' as const;
const VAULT   = '0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16' as const;
const TOKEN   = '0x0f97A213207703923F5f0C613C9827f7C9A0f96B' as const; // USDT.eth — any reserve token works
// APPROVE_ABI / DEPOSIT_ABI / REDEEM_ABI / DEPOSIT_TO_PLUS_ABI / REDEEM_FROM_PLUS_ABI / EVENTS_ABI:
// defined in the "ABI fragments" section near the end of this skill.
```

---

## Deposit (Mint PUSD)

### React — `@pushchain/ui-kit`

```bash
npm install @pushchain/ui-kit@latest
```

Wrap your app root once with the provider:

```tsx
import { PushUniversalWalletProvider, PushUniversalAccountButton, PushUI } from '@pushchain/ui-kit';

// main.tsx
<PushUniversalWalletProvider
  config={{
    network: PushUI.CONSTANTS.PUSH_NETWORK.TESTNET,
    login: { email: true, google: true, wallet: { enabled: true } },
  }}
  app={{ title: 'My PUSD App' }}
>
  {/* Render the connect button anywhere inside the provider — signing in
      is what initializes usePushChainClient. */}
  <PushUniversalAccountButton />
  <App />
</PushUniversalWalletProvider>;
```

**Path A — multicall in one signature, funds already on Donut:**

```tsx
import { usePushChainClient, usePushChain } from '@pushchain/ui-kit';

function MintButton() {
  const { pushChainClient, isInitialized, error } = usePushChainClient();
  const { PushChain } = usePushChain();

  if (error) return <div role="alert">{error.message}</div>;
  if (!isInitialized) return <div>Loading…</div>;
  if (!pushChainClient) return null;

  const mint = async () => {
    const h = PushChain.utils.helpers;
    const amount = h.parseUnits('100', 6);
    const recipient = pushChainClient.universal.account;

    // TOKEN / MANAGER / ZERO and the *_ABI fragments: see "Shared constants" + "ABI fragments".
    const multicall = [
      { to: TOKEN, value: 0n, data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }) },
      { to: MANAGER, value: 0n, data: h.encodeTxData({ abi: DEPOSIT_ABI, functionName: 'deposit', args: [TOKEN, amount, recipient] }) },
    ];

    // Outer 'to' is the zero sentinel -> the relay treats this as a multicall.
    const tx = await pushChainClient.universal.sendTransaction({
      to: ZERO,
      value: 0n,
      data: multicall,
    });
    await tx.wait();
  };

  return <button onClick={mint}>Mint 100 PUSD</button>;
}
```

**Path A — with bridging** — user holds USDT on Ethereum Sepolia, the relay bridges into the user's Push Chain account before the multicall runs:

```tsx
const tx = await pushChainClient.universal.sendTransaction({
  to: ZERO,
  value: 0n,
  data: multicall, // approve + deposit, as above
  funds: {
    amount, // 100 USDT (6 dec)
    token: PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDT,
  },
});
await tx.wait();
```

**Path B — native Push EOA — two signed transactions:**

```tsx
const mintFromPushEoa = async () => {
  const h = PushChain.utils.helpers;
  const amount = h.parseUnits('100', 6);
  const recipient = pushChainClient.universal.account;

  // Tx 1 (signature 1 of 2): approve PUSDManager to spend the reserve token.
  await (await pushChainClient.universal.sendTransaction({
    to: TOKEN,
    value: 0n,
    data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }),
  })).wait();

  // Tx 2 (signature 2 of 2): deposit, mint PUSD to recipient.
  await (await pushChainClient.universal.sendTransaction({
    to: MANAGER,
    value: 0n,
    data: h.encodeTxData({ abi: DEPOSIT_ABI, functionName: 'deposit', args: [TOKEN, amount, recipient] }),
  })).wait();
};
```

### Node.js — `@pushchain/core`

```bash
npm install @pushchain/core@latest ethers
```

> Keep signing keys in environment variables — never hardcode or log a private key, and never commit `.env` files.

**Path A — server signing on behalf of an external-chain user (multicall):**

```ts
import { PushChain } from '@pushchain/core';
import { ethers } from 'ethers';

const wallet = new ethers.Wallet(
  process.env.PRIVATE_KEY!,
  new ethers.JsonRpcProvider('https://sepolia.infura.io/v3/<KEY>'),
);
const signer = await PushChain.utils.signer.toUniversal(wallet);
const pc = await PushChain.initialize(signer, {
  network: PushChain.CONSTANTS.PUSH_NETWORK.TESTNET,
});

const h = pc.utils.helpers;
const amount = h.parseUnits('100', 6);
const owner = pc.universal.account;

// TOKEN / MANAGER / ZERO and the *_ABI fragments: see "Shared constants" + "ABI fragments".
await (await pc.universal.sendTransaction({
  to: ZERO,
  value: 0n,
  data: [
    { to: TOKEN, value: 0n, data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }) },
    { to: MANAGER, value: 0n, data: h.encodeTxData({ abi: DEPOSIT_ABI, functionName: 'deposit', args: [TOKEN, amount, owner] }) },
  ],
})).wait();
```

**Path B — native Push EOA, two signed transactions:**

```ts
const wallet = new ethers.Wallet(
  process.env.PRIVATE_KEY!,
  new ethers.JsonRpcProvider('https://evm.donut.rpc.push.org/'),
);
const signer = await PushChain.utils.signer.toUniversal(wallet);
const pc = await PushChain.initialize(signer, {
  network: PushChain.CONSTANTS.PUSH_NETWORK.TESTNET,
});

const h = pc.utils.helpers;
const amount = h.parseUnits('100', 6);
const owner = await wallet.getAddress();

// Tx 1: approve PUSDManager to spend the reserve token.
await (await pc.universal.sendTransaction({
  to: TOKEN,
  value: 0n,
  data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }),
})).wait();

// Tx 2: deposit, mint PUSD to owner.
await (await pc.universal.sendTransaction({
  to: MANAGER,
  value: 0n,
  data: h.encodeTxData({ abi: DEPOSIT_ABI, functionName: 'deposit', args: [TOKEN, amount, owner] }),
})).wait();
```

> Actual amounts minted are not return values — `deposit` returns nothing. Read `Deposited(user, token, tokenAmount, pusdMinted, surplusAmount, recipient)` from the receipt (see EVENTS_ABI) when you need the exact `pusdMinted`.

---

## Redeem (Burn PUSD)

> Same shape on both paths. `PUSDManager` holds `BURNER_ROLE` on PUSD and burns `msg.sender` directly — **no PUSD approval is required**. Path A and path B issue the exact same call; the only difference is who holds the signing key.

### React — single call

```tsx
// MANAGER / TOKEN: see "Shared constants". TOKEN here is the asset you want back.
const redeem = async () => {
  const h = PushChain.utils.helpers;
  const pusdAmount = h.parseUnits('99', 6);
  const recipient = pushChainClient.universal.account;

  const tx = await pushChainClient.universal.sendTransaction({
    to: MANAGER,
    value: 0n,
    data: h.encodeTxData({
      abi: REDEEM_ABI,
      functionName: 'redeem',
      args: [pusdAmount, TOKEN, true, recipient],
    }),
  });
  await tx.wait();
  // Exact token amount received: read Redeemed(user, token, pusdBurned, tokenAmount, recipient) from the receipt.
};
```

### Cross-chain payout — burn on Push Chain, bridge out to an external chain

Two real top-level transactions via `prepareTransaction` + `executeTransactions`. The second hop carries `to: { address, chain }` so the relay knows where to send the tokens.

> **Path A only.** `prepareTransaction` / `executeTransactions` reject native Push EOAs. On path B issue two separate `sendTransaction` calls instead: the redeem first, then (after reading the received amount from the `Redeemed` event) an outbound transfer with `to: { address, chain }` + `funds`.

```tsx
const redeemAndPayout = async () => {
  const h = PushChain.utils.helpers;
  const pusdAmount = h.parseUnits('99', 6);
  const pushAccount = pushChainClient.universal.account;
  const externalWallet = '0xUserOnSepolia' as const;

  // redeem pays out NET of fees — bridging the gross pusdAmount overdraws the
  // account and the outbound hop fails. Both hops are prepared up front, so
  // quote the worst-case fee (base + preferred max; see Fee model) and bridge
  // that; on the preferred route, any unspent difference stays in the Push account.
  const baseFeeBps = 5n;            // live: MANAGER.baseFee()
  const preferredFeeMaxBps = 200n;  // live: MANAGER.preferredFeeMax()
  const netOut = pusdAmount - (pusdAmount * (baseFeeBps + preferredFeeMaxBps)) / 10_000n;

  // Hop 1: burn PUSD on Push Chain. Single call, no approve.
  // allowBasket = FALSE here: a basket payout is a MIX of reserve tokens, which
  // would strand the USDT-only hop 2. Dry preferred route → clean revert instead.
  const burnHop = await pushChainClient.universal.prepareTransaction({
    to: MANAGER,
    value: 0n,
    data: h.encodeTxData({
      abi: REDEEM_ABI,
      functionName: 'redeem',
      args: [pusdAmount, TOKEN, false, pushAccount],
    }),
  });

  // Hop 2: forward the received USDT to the user's wallet on Sepolia.
  const payoutHop = await pushChainClient.universal.prepareTransaction({
    to: { address: externalWallet, chain: PushChain.CONSTANTS.CHAIN.ETHEREUM_SEPOLIA },
    value: 0n,
    data: '0x',
    funds: { amount: netOut, token: PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDT },
  });

  // executeTransactions returns a cascade handle; progress + completion ride on wait().
  const cascade = await pushChainClient.universal.executeTransactions([burnHop, payoutHop]);
  await cascade.wait({
    progressHook: (ev) => console.log(`hop ${ev.hopIndex}: ${ev.status}`, ev.txHash ?? ''),
  }); // throws if a hop fails; cascade.hops[1] carries the external-chain tx hash
};
```

> Because both hops are prepared before hop 1 executes, the payout token must be **deterministic** — hence `allowBasket = false`. Pre-flight the preferred route with the Fee-model liquidity check (`token.balanceOf(MANAGER) − getAccruedSurplus(token)` ≥ redemption amount). Caveat: a funded EMERGENCY_REDEEM token forces a proportional multi-token payout **regardless of `allowBasket`** — if you need certainty, check `getTokenStatus` across the reserve set first. To redeem to a different asset, swap both the Donut `TOKEN` and the MOVEABLE constant per the mapping in "Getting testnet assets" (e.g. `MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDC`).

### Node.js — single call

```ts
// Works for both path A and path B -- same RPC, same call.
const pusdAmount = h.parseUnits('99', 6);
const owner = pc.universal.account;

await (await pc.universal.sendTransaction({
  to: MANAGER,
  value: 0n,
  data: h.encodeTxData({
    abi: REDEEM_ABI,
    functionName: 'redeem',
    args: [pusdAmount, TOKEN, true, owner],
  }),
})).wait();
```

---

## Mint PUSD+ (depositToPlus)

`depositToPlus(tokenIn, amount, recipient)` accepts **either** PUSD or any reserve token. User-facing API unchanged across v2 → v2.1.

> **v2.1 (2026-05-06)** — direct path (`tokenIn` = USDC.eth, USDT.eth, etc.) sends reserves **directly to the vault**, no intermediate PUSD mint. Wrap path (`tokenIn` = PUSD) basket-redeems caller's PUSD into vault inventory. Surplus haircut on the reserve still applies; wrap leg charges no fee. Same caller-visible behavior; the vault now accumulates diverse reserve inventory organically — better LP support.

**Path A — multicall, reserve → PUSD+ in one signature:**

```tsx
const mintPlus = async () => {
  const h = PushChain.utils.helpers;
  const amount = h.parseUnits('100', 6);
  const recipient = pushChainClient.universal.account;

  const multicall = [
    { to: TOKEN, value: 0n, data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }) },
    { to: MANAGER, value: 0n, data: h.encodeTxData({ abi: DEPOSIT_TO_PLUS_ABI, functionName: 'depositToPlus', args: [TOKEN, amount, recipient] }) },
  ];

  await (await pushChainClient.universal.sendTransaction({
    to: ZERO, value: 0n, data: multicall,
  })).wait();
};
```

**Wrap path — already hold PUSD, want PUSD+:**

> Like `redeem`, the wrap path needs **no PUSD approval** — `depositToPlus(PUSD, …)` burns the caller's PUSD via `BURNER_ROLE`, it does not `transferFrom`. So it's a **single call** on either path (no multicall, no approve leg). Only the **direct** path above — `depositToPlus(reserve, …)` — pulls via `transferFrom` and therefore needs the reserve `approve`.

```tsx
const wrapPusd = async () => {
  const h = PushChain.utils.helpers;
  const amount = h.parseUnits('100', 6);
  const recipient = pushChainClient.universal.account;

  await (await pushChainClient.universal.sendTransaction({
    to: MANAGER,
    value: 0n,
    data: h.encodeTxData({ abi: DEPOSIT_TO_PLUS_ABI, functionName: 'depositToPlus', args: [PUSD, amount, recipient] }),
  })).wait();
};
```

> The amount of PUSD+ minted is determined by **pre-deposit NAV**: `plusOut = pusdIn × supply / (totalAssets − pusdIn)`. Quote off-chain with `vault.previewMintPlus(pusdIn)`. For the **direct** path, `pusdIn` = reserve amount − deposit haircut (all Donut reserves are 6-dec, par to PUSD — with the current 0 bps haircut, `pusdIn` simply equals the amount).

---

## Redeem PUSD+ (redeemFromPlus)

`redeemFromPlus(plusAmount, preferredAsset, allowBasket, recipient)` — burns PUSD+ from the caller, computes pusdOwed at current NAV, and either pays out instantly, queues for keeper fulfilment, or both. **PUSD+ approval is not required** — the vault burns `from = msg.sender` directly via the manager (manager holds `MANAGER_ROLE` on the vault).

```tsx
const redeemPlus = async () => {
  const h = PushChain.utils.helpers;
  const plusAmount = h.parseUnits('99', 6);
  const recipient = pushChainClient.universal.account;
  // Pass the PUSD address as preferredAsset to receive PUSD directly (unwrap path).

  await (await pushChainClient.universal.sendTransaction({
    to: MANAGER, value: 0n,
    data: h.encodeTxData({
      abi: REDEEM_FROM_PLUS_ABI,
      functionName: 'redeemFromPlus',
      args: [plusAmount, PUSD, true, recipient],
    }),
  })).wait();
};
```

If you want a reserve token back (e.g. USDC.eth), pass that as `preferredAsset`. The compose path runs the same preferred → basket cascade as `redeem`, but with **zero fees** (this is a protocol-internal compose, not a fresh user redeem). If the vault can't fulfil instantly, the residual is queued — your tx returns successfully but the tokens arrive when the queue entry settles.

### Tracking a queued redeem

`redeemFromPlus` returns nothing; queueing is only observable from the vault's events and queue getter:

```ts
import { ethers } from 'ethers';

// Reads need no Push SDK — any provider on the Donut RPC works.
const provider = new ethers.JsonRpcProvider('https://evm.donut.rpc.push.org/');
const VAULT_ABI = [
  'event BurnedPlus(address indexed from, uint256 plusIn, uint256 pusdOwed, uint256 pusdReturned, uint256 queueId)',
  'event QueueClaimFilled(uint256 indexed queueId, address indexed recipient, uint256 pusdAmount, address asset)',
  'function nextQueueId() view returns (uint256)',
  'function queue(uint256) view returns (address recipient, address preferredAsset, bool allowBasket, uint128 pusdOwed, uint64 queuedAt)',
  'function fulfillQueueClaim(uint256 queueId)',
];
const vault = new ethers.Contract(VAULT, VAULT_ABI, provider);

// 1. After the redeemFromPlus tx confirms, pull BurnedPlus from its receipt.
//    (`tx.hash` returned by sendTransaction is the Donut-side hash on either path.)
const receipt = await provider.getTransactionReceipt(txHash);
const burned = receipt!.logs
  .filter((l) => l.address.toLowerCase() === VAULT.toLowerCase())
  .map((l) => { try { return vault.interface.parseLog(l); } catch { return null; } })
  .find((e) => e?.name === 'BurnedPlus');
const { pusdOwed, pusdReturned, queueId } = burned!.args;
// queueId == 0n  → fully settled now (nextQueueId is 1-indexed, 0 is the "none" sentinel).
// queueId != 0n  → pusdOwed − pusdReturned is queued; NAV was fixed at the burn block.

// 2. Poll the open entry — pusdOwed drops to 0 once filled — or watch QueueClaimFilled.
const [recipient, , , owedNow] = await vault.queue(queueId);

// 3. Settlement: the keeper fills queues on rebalance, and anyone may settle sooner
//    once the vault holds enough PUSD (reverts Vault_QueueUnderfunded until then).
//    Direct from any Donut-funded EOA (gas from the Push faucet):
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
await (await vault.connect(signer).fulfillQueueClaim(queueId)).wait();

// …or through the SDK on either path (no raw key needed):
await (await pushChainClient.universal.sendTransaction({
  to: VAULT, value: 0n,
  data: h.encodeTxData({ abi: FULFILL_QUEUE_ABI, functionName: 'fulfillQueueClaim', args: [queueId] }),
})).wait();
```

> Reminder: the queued residual settles to the **original caller** (`BurnedPlus.from`), not the `recipient` argument. For end-user EOAs those are usually the same account; for intermediary contracts they are not — see the on-chain section.

---

## On-chain Solidity (calling the contracts from another contract)

### Minimal interfaces

```solidity
// SPDX-License-Identifier: MIT
// Save this block as IPUSD.sol — the example contracts below import from it.
pragma solidity ^0.8.20;

interface IPUSD {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amt) external returns (bool);
    function transferFrom(address from, address to, uint256 amt) external returns (bool);
    function approve(address spender, uint256 amt) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
}

interface IPUSDManager {
    enum TokenStatus { REMOVED, ENABLED, REDEEM_ONLY, EMERGENCY_REDEEM }

    struct TokenInfo {
        bool    exists;
        uint8   status;
        uint8   decimals;
        uint16  surplusHaircutBps;
        string  name;
        string  chainNamespace;
    }

    // Mutators -- v1 par-backed.
    function deposit(address token, uint256 amount, address recipient) external;
    function redeem(uint256 pusdAmount, address preferredAsset, bool allowBasket, address recipient) external;

    // Mutators -- v2 yield product.
    function depositToPlus(address tokenIn, uint256 amount, address recipient) external;
    function redeemFromPlus(uint256 plusAmount, address preferredAsset, bool allowBasket, address recipient) external;

    // Reads -- safe to call from any context.
    function baseFee() external view returns (uint256);
    function preferredFeeMin() external view returns (uint256);
    function preferredFeeMax() external view returns (uint256);
    function getSupportedTokensCount() external view returns (uint256);
    function getSupportedTokenAt(uint256 index) external view returns (address);
    function getTokenStatus(address token) external view returns (uint8);
    function getTokenInfo(address token) external view returns (TokenInfo memory);
    function getAccruedSurplus(address token) external view returns (uint256);
    function plusVault() external view returns (address);
}

interface IPUSDPlusVault {
    // ERC-20 surface (PUSD+ is a 6-decimal ERC-20)
    function balanceOf(address) external view returns (uint256);
    function totalSupply() external view returns (uint256);
    function transfer(address, uint256) external returns (bool);
    function approve(address, uint256) external returns (bool);
    function transferFrom(address, address, uint256) external returns (bool);

    // NAV-style reads
    function nav() external view returns (uint256);                    // 1e18 fixed-point
    function totalAssets() external view returns (uint256);            // 6-dec PUSD-equiv
    function previewMintPlus(uint256 pusdIn) external view returns (uint256);
    function previewBurnPlus(uint256 plusIn) external view returns (uint256);

    // Pre-flight reads
    function inBasket(address token) external view returns (bool);     // direct depositToPlus requires true
    function paused() external view returns (bool);                    // PUSD+ flows revert while true

    // Queue
    function fulfillQueueClaim(uint256 queueId) external;
    function nextQueueId() external view returns (uint256);            // 1-indexed; 0 = "none" sentinel
    function totalQueuedPusd() external view returns (uint256);
    function queue(uint256 queueId) external view returns (
        address recipient, address preferredAsset, bool allowBasket, uint128 pusdOwed, uint64 queuedAt
    );
}
```

### Deposit — mint PUSD from another contract

```solidity
import {IPUSDManager} from "./IPUSD.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract PUSDMinter {
    using SafeERC20 for IERC20;

    IPUSDManager public constant MANAGER =
        IPUSDManager(0x775A23E81fCd1f9C2997663b45401bEe80e4242A);

    /// @notice Pull `amount` of `token` from caller, deposit into PUSDManager,
    ///         mint PUSD straight to `recipient`.
    function mintFor(address token, uint256 amount, address recipient) external {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(token).forceApprove(address(MANAGER), amount);
        MANAGER.deposit(token, amount, recipient);
    }
}
```

### Mint PUSD+ from another contract

```solidity
// Same pragma + imports as PUSDMinter above.
contract PUSDPlusMinter {
    using SafeERC20 for IERC20;

    IPUSDManager public constant MANAGER =
        IPUSDManager(0x775A23E81fCd1f9C2997663b45401bEe80e4242A);

    function mintPlusFor(address token, uint256 amount, address recipient) external {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(token).forceApprove(address(MANAGER), amount);
        MANAGER.depositToPlus(token, amount, recipient);
    }
}
```

### Redeem PUSD+ from another contract

> ⚠️ **Queue hazard.** When `redeemFromPlus` queues a residual, the vault records the **caller** — this contract — as the queue recipient, not your end user. And because `fulfillQueueClaim` is permissionless (the protocol keeper normally settles queues right after rebalance), the payout can land on this contract **at any time, with no callback**. A naive intermediary permanently strands queued payouts. **Recommended: have end users call `PUSDManager.redeemFromPlus` directly.** If you must intermediate, either deploy a per-redeem escrow (each queue entry's recipient is then a dedicated single-user address) or — as below — allow one outstanding queued redeem at a time and sweep full balances on settlement. Queueing is detectable on-chain via the `nextQueueId()` delta (pre-increment: if `nextQueueId()` read 5 before a queued call, the new entry's id is 6, and `nextQueueId()` now returns 6).

```solidity
// Same pragma + imports as PUSDMinter, plus IPUSDPlusVault and IPUSD from IPUSD.sol.
contract PUSDPlusRedeemer {
    using SafeERC20 for IERC20;

    IPUSDManager   public constant MANAGER =
        IPUSDManager(0x775A23E81fCd1f9C2997663b45401bEe80e4242A);
    IPUSDPlusVault public constant VAULT =
        IPUSDPlusVault(0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16);
    address public constant PUSD = 0x774c799646bB60103e38Fd65b18D81bbDD1Aa760;

    // One queued redeem outstanding at a time → a full-balance sweep is safely
    // attributable to one user. Multi-user intermediaries need per-redeem escrow.
    uint256 public pendingQueueId;
    address public pendingUser;

    /// @notice Burn caller's PUSD+ and pay out into `preferredAsset`. PUSD+
    ///         approval IS required here (this contract pulls it first); the
    ///         manager then burns this contract's PUSD+ without allowance.
    function redeemFor(uint256 plusAmount, address preferredAsset, address recipient) external {
        require(pendingQueueId == 0, "PUSDPlusRedeemer: settle pending queue first");
        IERC20(address(VAULT)).safeTransferFrom(msg.sender, address(this), plusAmount);
        uint256 idBefore = VAULT.nextQueueId();
        MANAGER.redeemFromPlus(plusAmount, preferredAsset, true, recipient);
        uint256 idAfter = VAULT.nextQueueId();
        if (idAfter != idBefore) {
            // Residual was queued to THIS contract; remember who it belongs to.
            pendingQueueId = idAfter;
            pendingUser = recipient;
        }
    }

    /// @notice Forward a settled (or settleable) queued residual to the owed
    ///         user. Safe whether or not the keeper already ran
    ///         fulfillQueueClaim — never assume this contract settles first.
    function claimQueued() external {
        uint256 queueId = pendingQueueId;
        require(queueId != 0, "PUSDPlusRedeemer: nothing queued");

        (, , , uint128 owed, ) = VAULT.queue(queueId);
        if (owed > 0) {
            // Not yet settled by the keeper — settle now. Reverts
            // Vault_QueueUnderfunded until the vault holds enough PUSD.
            VAULT.fulfillQueueClaim(queueId);
        }
        // Settlement (ours or the keeper's) paid this contract. Sweep FULL
        // balances — attributable because only one queued redeem is pending.
        // Basket fulfilment can pay a mix of reserves (plus PUSD on unwrap).
        address user = pendingUser;
        pendingQueueId = 0;
        pendingUser = address(0);

        uint256 n = MANAGER.getSupportedTokensCount();
        for (uint256 i = 0; i < n; i++) {
            IERC20 t = IERC20(MANAGER.getSupportedTokenAt(i));
            uint256 bal = t.balanceOf(address(this));
            if (bal > 0) t.safeTransfer(user, bal);
        }
        uint256 pusdBal = IERC20(PUSD).balanceOf(address(this));
        if (pusdBal > 0) IERC20(PUSD).safeTransfer(user, pusdBal);
    }
}
```

### Read — quote and inspect protocol state

```solidity
// Same pragma + imports as above.
contract PUSDReader {
    IPUSDManager   public constant MANAGER =
        IPUSDManager(0x775A23E81fCd1f9C2997663b45401bEe80e4242A);
    IPUSDPlusVault public constant VAULT =
        IPUSDPlusVault(0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16);

    /// @notice Quote a deposit. Deposits charge ONLY the per-token surplus
    ///         haircut — baseFee is a redemption fee and never applies to mints.
    ///         All Donut reserves are 6-dec, so amounts map 1:1 to PUSD.
    function quoteMint(address token, uint256 amount)
        external view returns (uint256 expectedPUSD)
    {
        IPUSDManager.TokenInfo memory info = MANAGER.getTokenInfo(token);
        require(
            info.exists && info.status == uint8(IPUSDManager.TokenStatus.ENABLED),
            "PUSDReader: token not depositable"
        );
        expectedPUSD = amount - (amount * info.surplusHaircutBps) / 10_000;
    }

    function quoteMintPlus(uint256 pusdIn) external view returns (uint256) {
        return VAULT.previewMintPlus(pusdIn);
    }

    function plusNav() external view returns (uint256) { return VAULT.nav(); }
}
```

---

## ABI fragments (for SDK `encodeTxData` and ethers.js `Interface`)

```ts
const APPROVE_ABI = [{
  type: 'function', name: 'approve', stateMutability: 'nonpayable',
  inputs: [{ name: 'spender', type: 'address' }, { name: 'amount', type: 'uint256' }],
  outputs: [{ name: '', type: 'bool' }],
}] as const;

const DEPOSIT_ABI = [{
  type: 'function', name: 'deposit', stateMutability: 'nonpayable',
  inputs: [
    { name: 'token',     type: 'address' },
    { name: 'amount',    type: 'uint256' },
    { name: 'recipient', type: 'address' },
  ],
  outputs: [],
}] as const;

const REDEEM_ABI = [{
  type: 'function', name: 'redeem', stateMutability: 'nonpayable',
  inputs: [
    { name: 'pusdAmount',     type: 'uint256' },
    { name: 'preferredAsset', type: 'address' },
    { name: 'allowBasket',    type: 'bool'    },
    { name: 'recipient',      type: 'address' },
  ],
  outputs: [],
}] as const;

const DEPOSIT_TO_PLUS_ABI = [{
  type: 'function', name: 'depositToPlus', stateMutability: 'nonpayable',
  inputs: [
    { name: 'tokenIn',   type: 'address' },
    { name: 'amount',    type: 'uint256' },
    { name: 'recipient', type: 'address' },
  ],
  outputs: [],
}] as const;

const REDEEM_FROM_PLUS_ABI = [{
  type: 'function', name: 'redeemFromPlus', stateMutability: 'nonpayable',
  inputs: [
    { name: 'plusAmount',     type: 'uint256' },
    { name: 'preferredAsset', type: 'address' },
    { name: 'allowBasket',    type: 'bool'    },
    { name: 'recipient',      type: 'address' },
  ],
  outputs: [],
}] as const;

const FULFILL_QUEUE_ABI = [{
  type: 'function', name: 'fulfillQueueClaim', stateMutability: 'nonpayable',
  inputs: [{ name: 'queueId', type: 'uint256' }],
  outputs: [],
}] as const;
```

**Events** — every mutator returns `void`, so actual amounts live in these (human-readable form: parse with ethers `new Interface([...])` or viem `parseAbi([...])`):

```ts
const EVENTS_ABI = [
  // PUSDManager
  'event Deposited(address indexed user, address indexed token, uint256 tokenAmount, uint256 pusdMinted, uint256 surplusAmount, address indexed recipient)',
  'event Redeemed(address indexed user, address indexed token, uint256 pusdBurned, uint256 tokenAmount, address indexed recipient)',
  'event DepositedToPlus(address indexed user, address indexed tokenIn, uint256 amountIn, uint256 plusOut, address indexed recipient)',
  'event RedeemedFromPlus(address indexed user, uint256 plusIn, address indexed preferredAsset, bool basket, address indexed recipient)',
  // PUSDPlusVault
  'event MintedPlus(address indexed recipient, uint256 pusdIn, uint256 plusOut, uint256 navE18)',
  'event BurnedPlus(address indexed from, uint256 plusIn, uint256 pusdOwed, uint256 pusdReturned, uint256 queueId)',
  'event QueueClaimFilled(uint256 indexed queueId, address indexed recipient, uint256 pusdAmount, address asset)',
];
```

---

## Errors

`PUSDManager` reverts with plain require strings — they decode as standard `Error(string)`. `PUSDPlusVault` uses **custom errors** — decoding needs the error ABI below. The user-reachable set:

| Contract | Error | Thrown when |
| -------- | ----- | ----------- |
| PUSDManager | `PUSDManager: token not enabled for deposits` | `deposit` / `depositToPlus` with a token whose status ≠ ENABLED |
| PUSDManager | `PUSDManager: amount must be greater than 0` | zero amount on any entrypoint |
| PUSDManager | `PUSDManager: recipient cannot be zero address` | zero recipient on any entrypoint |
| PUSDManager | `PUSDManager: insufficient PUSD balance` | `redeem` above the caller's PUSD balance |
| PUSDManager | `PUSDManager: preferred asset unavailable and basket not allowed` | preferred route dry + `allowBasket = false` |
| PUSDManager | `PUSDManager: insufficient total liquidity` | basket route: total reserves can't cover the redemption |
| PUSDManager | `PUSDManager: token not in vault basket` | direct `depositToPlus` with a token missing from the vault basket (pre-check `vault.inBasket(token)`) |
| PUSDPlusVault | `EnforcedPause()` | any PUSD+ mint/redeem/claim while the vault is paused (pre-check `vault.paused()`) |
| PUSDPlusVault | `Vault_ZeroAmount()` | zero-amount mint/burn |
| PUSDPlusVault | `Vault_QueueAlreadyFilled(uint256 queueId)` | `fulfillQueueClaim` on a settled or unknown id |
| PUSDPlusVault | `Vault_QueueUnderfunded(uint256 queueId, uint256 have, uint256 need)` | `fulfillQueueClaim` before the vault holds enough PUSD — retry after the next rebalance |
| PUSDPlusVault | `Vault_NavZero()` / `Vault_BootstrapZeroSupply()` | preview calls in degenerate vault states |
| PUSDPlusVault | `Vault_RebalanceCooldown(uint256 nextAllowedAt)` | public `rebalance()` before the cooldown elapses (1h default, admin-tunable, hard-capped at 24h; KEEPER bypasses) |

```ts
const VAULT_ERRORS_ABI = [
  'error EnforcedPause()',
  'error Vault_ZeroAmount()',
  'error Vault_QueueAlreadyFilled(uint256 queueId)',
  'error Vault_QueueUnderfunded(uint256 queueId, uint256 have, uint256 need)',
  'error Vault_NavZero()',
  'error Vault_BootstrapZeroSupply()',
  'error Vault_RebalanceCooldown(uint256 nextAllowedAt)',
];

// Decode a vault revert (ethers v6):
const errIface = new ethers.Interface(VAULT_ERRORS_ABI);
try {
  // ... failed PUSD+ call ...
} catch (err: any) {
  const parsed = err.data ? errIface.parseError(err.data) : null;
  console.log(parsed?.name, parsed?.args); // e.g. 'Vault_QueueUnderfunded' [5n, 100n, 250n]
}
```

Manager errors arrive as plain `Error(string)` — ethers/viem decode those automatically into the message. If a failure surfaced through the path-A relay without revert data attached, replay the same calldata with `provider.call(tx, blockTag)` against the Donut RPC to recover the revert bytes.

---

## Common mistakes

| Mistake                                          | Fix                                                                                  |
| ------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `sendTransaction([leg1, leg2])` — bare array     | Cascade rides in `data`: `sendTransaction({ to: ZERO, value: 0n, data: legs })`      |
| Adding an approve before redeem / redeemFromPlus / `depositToPlus(PUSD,…)` wrap | None need it — `PUSDManager` burns `msg.sender` directly via `BURNER_ROLE`/vault-`MANAGER_ROLE`. The **direct** `depositToPlus(reserve,…)` path DOES need a reserve approve (it `transferFrom`s) |
| `parseUnits(value, 18)`                          | PUSD, PUSD+, and every reserve use 6 decimals — `parseUnits(value, 6)`               |
| `recipient = address(0)`                         | Every entrypoint reverts on zero address                                             |
| Treating PUSD+ as ERC-4626                       | PUSD+ is a custom ERC-20 with `nav() / totalAssets() / previewMint/Burn`, no `convertToShares` |
| Expecting `redeemFromPlus` to return amounts     | It returns nothing — read `BurnedPlus(from, plusIn, pusdOwed, pusdReturned, queueId)` from the receipt; `queueId != 0` means a residual is queued |
| Bridging the gross `pusdAmount` after a redeem   | Redeem pays **net of fees** — bridge the net (see the cross-chain payout example) or the outbound hop overdraws and fails |
| `prepareTransaction` / `executeTransactions` on path B | Path-A only — native Push EOAs issue two separate `sendTransaction` calls |
| Calling `mintPlus` / `burnPlus` / `depositForVault` from app code | Vault-only — only PUSDManager (with MANAGER_ROLE) calls into the vault, and only the vault calls back into `depositForVault` |
| `npm install @pushchain/core` in a UI Kit app    | `@pushchain/core` is a dependency of `@pushchain/ui-kit` — use `usePushChain()` instead of installing it separately |
| Path-B mint as a single multicall                | Native Push EOAs have no multicall; mint is **two sequential** transactions          |

---

## Quick reference

| Operation         | Contract       | Function                                                  |
| ----------------- | -------------- | --------------------------------------------------------- |
| Mint PUSD         | PUSDManager    | `deposit(token, amount, recipient)`                       |
| Redeem PUSD       | PUSDManager    | `redeem(pusdAmount, preferredAsset, allowBasket, recipient)` |
| Mint PUSD+        | PUSDManager    | `depositToPlus(tokenIn, amount, recipient)`               |
| Redeem PUSD+      | PUSDManager    | `redeemFromPlus(plusAmount, preferredAsset, allowBasket, recipient)` |
| Settle queued PUSD+ redeem | PUSDPlusVault | `fulfillQueueClaim(queueId)`                          |
| Inspect a queue entry | PUSDPlusVault | `queue(queueId)`, `nextQueueId()`, `totalQueuedPusd()`  |
| PUSD balance      | PUSD           | `balanceOf(address)`                                      |
| PUSD+ balance     | PUSDPlusVault  | `balanceOf(address)`                                      |
| PUSD+ NAV (1e18)  | PUSDPlusVault  | `nav()`                                                   |
| Quote mint PUSD+  | PUSDPlusVault  | `previewMintPlus(pusdIn)`                                 |
| Quote burn PUSD+  | PUSDPlusVault  | `previewBurnPlus(plusIn)`                                 |
| Pre-flight PUSD+  | PUSDPlusVault  | `inBasket(token)`, `paused()`                             |
| Token info        | PUSDManager    | `getTokenInfo(token)`                                     |
| Fee config        | PUSDManager    | `baseFee()`, `preferredFeeMin()`, `preferredFeeMax()`     |
| Reserve balance   | reserve token  | `balanceOf(PUSDManager)`                                  |
| Accrued surplus   | PUSDManager    | `getAccruedSurplus(token)`                                |
| Actual amounts moved | events      | `Deposited` / `Redeemed` / `BurnedPlus` / `QueueClaimFilled` (see EVENTS_ABI) |
