Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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.

DirectionWrapper callInput tokenOutput tokenApproval targetLiquidity check
USDC → USDSsellGem(recipient, gemAmt)USDC, 6 decimalsUSDS, 18 decimalsWrapper, for gemAmt USDCCanonical DAI balance at wrapper.psm()
USDS → USDCbuyGem(recipient, gemAmt)USDS, 18 decimalsUSDC, 6 decimalsWrapper, for returned usdsInUSDC 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 recipient

Adapter 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 if tin() >= 1e18; ensure gemAmt <= maxSellGem().
  • USDS → USDC: reject if tout() == type(uint256).max; ensure gemAmt <= maxBuyGem().
  • User protection: always take minUsdsOut for sells and maxUsdsIn for 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 use wrapper.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:

StrategyWhen to use
Refuse the quoteDefault for simple integrations and frontends.
Split the routeUseful when the user can tolerate multiple transactions or partial routing through other venues.
Bundle fill() atomicallyAdvanced 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()), and usdc.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 tin and tout independently; 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

SymptomLikely causeCheck
USDC → USDS revertsSell direction haltedwrapper.tin() == type(uint256).max
USDC → USDS revertsNot enough DAI in the core PSMdai.balanceOf(wrapper.psm())
USDS → USDC revertsBuy direction haltedwrapper.tout() == type(uint256).max
USDS → USDC revertsNot enough USDC in the pocketusdc.balanceOf(wrapper.pocket())
Quote changes before executionGovernance fee update or competing transactionRe-read tin, tout, and balances in the execution path
DAI balance looks wrongReading wrapper.dai() instead of canonical DAIUse the DAI token address directly
Token transfer failsMissing allowance, insufficient balance, or USDC transfer restrictionCheck 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 recipient

The relevant contract primitive for a generic USDS → USDC leg is wrapper.buyGem(recipient, gemAmt).