| 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 — 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). 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). 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 ≡ contracts/deployed.txt (newest deployment first). Wrapper registry: 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:
-
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:
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 links every token's faucet directly. Exceptions: Solana USDC comes from the Circle faucet; for USDT.sol ask in the Push Discord.
-
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.
-
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 before the first transaction.
-
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:
- Instant — vault has idle PUSD ≥ pusdOwed → ship now.
- Convert — idle PUSD short, vault converts idle non-PUSD basket tokens via fee-exempt manager path. Still no peg risk.
- 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 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 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)
const ZERO = '0x0000000000000000000000000000000000000000' as const;
const PUSD = '0x774c799646bB60103e38Fd65b18D81bbDD1Aa760' as const;
const MANAGER = '0x775A23E81fCd1f9C2997663b45401bEe80e4242A' as const;
const VAULT = '0x9C7A8Bae46d4dd0496bD3016d1D8FB9e83E68F16' as const;
const TOKEN = '0x0f97A213207703923F5f0C613C9827f7C9A0f96B' as const;
Deposit (Mint PUSD)
React — @pushchain/ui-kit
npm install @pushchain/ui-kit@latest
Wrap your app root once with the provider:
import { PushUniversalWalletProvider, PushUniversalAccountButton, PushUI } from '@pushchain/ui-kit';
<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:
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;
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] }) },
];
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:
const tx = await pushChainClient.universal.sendTransaction({
to: ZERO,
value: 0n,
data: multicall,
funds: {
amount,
token: PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDT,
},
});
await tx.wait();
Path B — native Push EOA — two signed transactions:
const mintFromPushEoa = async () => {
const h = PushChain.utils.helpers;
const amount = h.parseUnits('100', 6);
const recipient = pushChainClient.universal.account;
await (await pushChainClient.universal.sendTransaction({
to: TOKEN,
value: 0n,
data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }),
})).wait();
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
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):
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;
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:
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();
await (await pc.universal.sendTransaction({
to: TOKEN,
value: 0n,
data: h.encodeTxData({ abi: APPROVE_ABI, functionName: 'approve', args: [MANAGER, amount] }),
})).wait();
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
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();
};
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.
const redeemAndPayout = async () => {
const h = PushChain.utils.helpers;
const pusdAmount = h.parseUnits('99', 6);
const pushAccount = pushChainClient.universal.account;
const externalWallet = '0xUserOnSepolia' as const;
const baseFeeBps = 5n;
const preferredFeeMaxBps = 200n;
const netOut = pusdAmount - (pusdAmount * (baseFeeBps + preferredFeeMaxBps)) / 10_000n;
const burnHop = await pushChainClient.universal.prepareTransaction({
to: MANAGER,
value: 0n,
data: h.encodeTxData({
abi: REDEEM_ABI,
functionName: 'redeem',
args: [pusdAmount, TOKEN, false, pushAccount],
}),
});
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 },
});
const cascade = await pushChainClient.universal.executeTransactions([burnHop, payoutHop]);
await cascade.wait({
progressHook: (ev) => console.log(`hop ${ev.hopIndex}: ${ev.status}`, ev.txHash ?? ''),
});
};
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
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:
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.
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).
const redeemPlus = async () => {
const h = PushChain.utils.helpers;
const plusAmount = h.parseUnits('99', 6);
const recipient = pushChainClient.universal.account;
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:
import { ethers } from 'ethers';
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);
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;
const [recipient, , , owedNow] = await vault.queue(queueId);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
await (await vault.connect(signer).fulfillQueueClaim(queueId)).wait();
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
// 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
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
// 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).
// 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
// 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)
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([...])):
const EVENTS_ABI = [
'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)',
'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) |
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)',
];
const errIface = new ethers.Interface(VAULT_ERRORS_ABI);
try {
} catch (err: any) {
const parsed = err.data ? errIface.parseError(err.data) : null;
console.log(parsed?.name, parsed?.args);
}
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 transferFroms) |
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) |