Integration Guide
This guide shows how to integrate the UsdsPsmWrapper into a smart contract or off-chain routing system. Addresses, source links, audits, and the full ABI are maintained in the Contract Reference.
For background on the underlying mechanism, see How It Works.
Quick Reference
Use the deployed wrapper ABI, not the core DssLitePsm ABI, when integrating USDS ↔ USDC directly. The adapter example below includes the minimal interface it needs.
| Direction | Wrapper call | Input token | Output token | Approval target | Liquidity check |
|---|---|---|---|---|---|
| USDC → USDS | sellGem(recipient, gemAmt) | USDC, 6 decimals | USDS, 18 decimals | Wrapper, for gemAmt USDC | Canonical DAI balance at wrapper.psm() |
| USDS → USDC | buyGem(recipient, gemAmt) | USDS, 18 decimals | USDC, 6 decimals | Wrapper, for returned usdsIn | USDC balance at wrapper.pocket() |
gemAmt is always the USDC amount in 6-decimal units. The return value is always the 18-decimal stablecoin amount: usdsOut for sells and usdsIn for buys.
USDC → USDS
caller/adapter ── USDC ──► UsdsPsmWrapper ──► core DssLitePsm ──► DaiJoin/UsdsJoin ──► USDS recipient
USDS → USDC
caller/adapter ── USDS ──► UsdsPsmWrapper ──► DaiJoin/UsdsJoin ──► core DssLitePsm ──► USDC recipientAdapter Pattern
This example uses OpenZeppelin Contracts 5.x. It assumes the adapter does not custody idle balances. It pulls the exact token amount needed for each call, approves the wrapper for the exact spend, and clears the allowance after the swap.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
interface IUsdsPsmWrapper {
function sellGem(address usr, uint256 gemAmt) external returns (uint256 usdsOutWad);
function buyGem(address usr, uint256 gemAmt) external returns (uint256 usdsInWad);
function psm() external view returns (address);
function pocket() external view returns (address);
function tin() external view returns (uint256);
function tout() external view returns (uint256);
function to18ConversionFactor() external view returns (uint256);
}
contract UsdsPsmWrapperAdapter is ReentrancyGuard {
using SafeERC20 for IERC20;
uint256 internal constant WAD = 1e18;
uint256 internal constant HALTED = type(uint256).max;
IUsdsPsmWrapper public immutable wrapper;
IERC20 public immutable usdc;
IERC20 public immutable usds;
IERC20 public immutable dai; // Canonical DAI token, not wrapper.dai().
error ZeroAddress();
error SellHalted();
error BuyHalted();
error FeeTooHigh();
error InsufficientSellLiquidity(uint256 requested, uint256 available);
error InsufficientBuyLiquidity(uint256 requested, uint256 available);
error InsufficientOutput(uint256 received, uint256 minimum);
error ExcessiveInput(uint256 required, uint256 maximum);
constructor(IUsdsPsmWrapper wrapper_, IERC20 usdc_, IERC20 usds_, IERC20 dai_) {
if (
address(wrapper_) == address(0) ||
address(usdc_) == address(0) ||
address(usds_) == address(0) ||
address(dai_) == address(0)
) {
revert ZeroAddress();
}
wrapper = wrapper_;
usdc = usdc_;
usds = usds_;
dai = dai_;
}
function previewSellGem(uint256 gemAmt) public view returns (uint256 usdsOutWad) {
uint256 tin = wrapper.tin();
if (tin == HALTED) revert SellHalted();
if (tin >= WAD) revert FeeTooHigh();
uint256 gemAmt18 = Math.mulDiv(gemAmt, wrapper.to18ConversionFactor(), 1);
return Math.mulDiv(gemAmt18, WAD - tin, WAD);
}
function previewBuyGem(uint256 gemAmt) public view returns (uint256 usdsInWad) {
uint256 tout = wrapper.tout();
if (tout == HALTED) revert BuyHalted();
if (tout > WAD) revert FeeTooHigh();
uint256 gemAmt18 = Math.mulDiv(gemAmt, wrapper.to18ConversionFactor(), 1);
return gemAmt18 + Math.mulDiv(gemAmt18, tout, WAD);
}
function maxSellGem() public view returns (uint256 gemAmt) {
uint256 tin = wrapper.tin();
if (tin == HALTED || tin >= WAD) return 0;
uint256 denominator = wrapper.to18ConversionFactor() * (WAD - tin);
return Math.mulDiv(dai.balanceOf(wrapper.psm()), WAD, denominator);
}
function maxBuyGem() public view returns (uint256 gemAmt) {
if (wrapper.tout() == HALTED) return 0;
return usdc.balanceOf(wrapper.pocket());
}
function sellUsdcForUsds(
uint256 gemAmt,
uint256 minUsdsOut,
address recipient
) external nonReentrant returns (uint256 usdsOut) {
if (recipient == address(0)) revert ZeroAddress();
uint256 expectedOut = previewSellGem(gemAmt);
if (expectedOut < minUsdsOut) revert InsufficientOutput(expectedOut, minUsdsOut);
uint256 sellCapacity = maxSellGem();
if (gemAmt > sellCapacity) revert InsufficientSellLiquidity(gemAmt, sellCapacity);
usdc.safeTransferFrom(msg.sender, address(this), gemAmt);
usdc.forceApprove(address(wrapper), gemAmt);
usdsOut = wrapper.sellGem(recipient, gemAmt);
usdc.forceApprove(address(wrapper), 0);
if (usdsOut < minUsdsOut) revert InsufficientOutput(usdsOut, minUsdsOut);
}
function buyUsdcWithUsds(
uint256 gemAmt,
uint256 maxUsdsIn,
address recipient
) external nonReentrant returns (uint256 usdsIn) {
if (recipient == address(0)) revert ZeroAddress();
uint256 requiredIn = previewBuyGem(gemAmt);
if (requiredIn > maxUsdsIn) revert ExcessiveInput(requiredIn, maxUsdsIn);
uint256 buyCapacity = maxBuyGem();
if (gemAmt > buyCapacity) revert InsufficientBuyLiquidity(gemAmt, buyCapacity);
uint256 balanceBefore = usds.balanceOf(address(this));
usds.safeTransferFrom(msg.sender, address(this), requiredIn);
usds.forceApprove(address(wrapper), requiredIn);
usdsIn = wrapper.buyGem(recipient, gemAmt);
usds.forceApprove(address(wrapper), 0);
if (usdsIn > maxUsdsIn) revert ExcessiveInput(usdsIn, maxUsdsIn);
uint256 balanceAfter = usds.balanceOf(address(this));
if (balanceAfter > balanceBefore) {
usds.safeTransfer(msg.sender, balanceAfter - balanceBefore);
}
}
}Production Checklist
Before executing a route, check direction-specific state in the same transaction path:
- USDC → USDS: reject if
tin() == type(uint256).max; reject iftin() >= 1e18; ensuregemAmt <= maxSellGem(). - USDS → USDC: reject if
tout() == type(uint256).max; ensuregemAmt <= maxBuyGem(). - User protection: always take
minUsdsOutfor sells andmaxUsdsInfor buys, even though the PSM itself has no slippage parameter. - Token approvals: approve the wrapper only for the exact token amount the adapter is about to spend, and clear the allowance after the call.
- DAI balance reads: use the canonical DAI token to read
dai.balanceOf(wrapper.psm()). Do not usewrapper.dai()for this; the deployed wrapper's compatibility getter returns USDS. - Address configuration: configure the wrapper, USDC, USDS, and canonical DAI addresses explicitly. Do not infer DAI from the wrapper.
The PSM price is deterministic, but route execution can still fail because fees, halts, DAI balance, pocket balance, and token transfer permissions are live chain state.
Large Trades
USDS → USDC capacity is the pocket's live USDC balance. USDC → USDS capacity is the core PSM's live DAI balance after applying tin.
For a USDC → USDS route larger than the current DAI balance, use one of these strategies:
| Strategy | When to use |
|---|---|
| Refuse the quote | Default for simple integrations and frontends. |
| Split the route | Useful when the user can tolerate multiple transactions or partial routing through other venues. |
Bundle fill() atomically | Advanced integrations only. The core fill() function is permissionless, but the batch still needs to handle debt-ceiling limits, same-block competition, and revert risk. |
Do not quote against the governance debt ceiling as if it were immediately available liquidity. Quote against the actual DAI balance unless your transaction path explicitly includes and validates a fill().
Off-Chain Routers
For quoting engines and aggregators, model LitePSM as a stateful fixed-price venue rather than an AMM:
- Re-read
tin,tout,to18ConversionFactor,dai.balanceOf(wrapper.psm()), andusdc.balanceOf(wrapper.pocket())per block. - Do not quote USDC → USDS above the core PSM's live DAI balance converted through the current
tin. - Do not quote USDS → USDC above the pocket's live USDC balance.
- Treat
tinandtoutindependently; one direction can be halted while the other remains available. - Keep a user min/max guard in the final transaction calldata or wrapping contract.
Live protocol stats are available from the LitePSM dashboard. Treat dashboard data as monitoring context; execution checks should still read on-chain state.
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
| USDC → USDS reverts | Sell direction halted | wrapper.tin() == type(uint256).max |
| USDC → USDS reverts | Not enough DAI in the core PSM | dai.balanceOf(wrapper.psm()) |
| USDS → USDC reverts | Buy direction halted | wrapper.tout() == type(uint256).max |
| USDS → USDC reverts | Not enough USDC in the pocket | usdc.balanceOf(wrapper.pocket()) |
| Quote changes before execution | Governance fee update or competing transaction | Re-read tin, tout, and balances in the execution path |
| DAI balance looks wrong | Reading wrapper.dai() instead of canonical DAI | Use the DAI token address directly |
| Token transfer fails | Missing allowance, insufficient balance, or USDC transfer restriction | Check token balances, allowances, and USDC transfer status |
UI Example: USDC via USDS
SparkLend may present a user-facing market where users interact with USDC via USDS. This is useful product context for understanding how USDS-backed USDC liquidity can appear in Spark interfaces.
SparkLend market: USDC via USDS
User-facing market: USDC via USDS
│
▼
USDS-denominated source or debt
│
▼
Sky UsdsPsmWrapper
│
├── Core DssLitePsm + USDC pocket
│
▼
USDC delivered to recipientThe relevant contract primitive for a generic USDS → USDC leg is wrapper.buyGem(recipient, gemAmt).