Cover photo

Stable Build #5: Add Earn to a USDT Payment App with Dynamic

You have a USDT payment app. Customers already hold money in it and send payments. The next step is giving them a way to put idle balances to work without leaving the app.

In this build, we’ll add an Earn balance using StableEarn through Dynamic. Customers choose how much to move into it and can request withdrawals back to their payment balance. They use the same wallet throughout. Yield comes from lending through StableEarn, our onchain yield product available through Dynamic’s Earn integration.

The part we'll build is the connection between Earn and payments. When a customer wants to send more than their wallet holds, the app calculates the shortfall and withdraws that amount from Earn. It waits for that withdrawal to succeed before asking them to approve the payment.

What the Customer Gets

A stablecoin neobank uses stablecoins for account balances and payments. Stable Build #2 covers creating and funding those accounts. Here we start with a funded account and add the Earn feature.

Suppose a customer receives 1,000 USDT on Monday. They keep 200 in their wallet for payments and fees and move 800 into Earn. The account screen shows where the money is:

Balance

What it means

Available to pay

USDT0 in the wallet, less the amount reserved for transaction fees

In Earn

The current USDT0 value of the user's vault shares, subject to withdrawal liquidity


On Friday, they want to send 300. The app asks them to withdraw enough from Earn to cover the shortfall and fees, then approve the payment. These are separate transactions. Withdrawals depend on available vault liquidity, and if sufficient liquidity is unavailable, the withdrawal can fail, and the payment should not proceed.

What Stable and Dynamic Supply

Dynamic already lists StableEarn among its supported vaults. A developer using Dynamic can access it through the same Earn API used to read positions and submit deposits or withdrawals. Dynamic also exposes the customer's wallet to the Stable SDK for signing payments.

On Stable, the asset is USDT0, the omnichain representation of USDT used across the network. The wallet holds it, StableEarn accepts it, and the payment recipient receives it. Transaction fees, or gas, are also paid in USDT0. This flow needs no additional gas token and no asset conversion between Earn and payment.

Component

Responsibility in this build

Dynamic

The user's wallet and the API for accessing StableEarn, including token approvals when needed

StableEarn

The lending vault, using Morpho infrastructure with Gauntlet as curator

Stable SDK

Sending USDT0 from that same wallet on Stable

Your app

The account screen, fee estimates, and the withdrawal-then-payment flow


StableEarn issues vault shares in exchange for a deposit. They track the user's portion of the vault's assets as borrowers pay interest. Rates vary, and lending losses can reduce their value. The vault and its lending strategy are supplied by the integration; the code below connects that position to the customer's payments.

Connect the Same Wallet to Earn and Payments

Start with a signed-in Dynamic EVM walletAccount and Stable mainnet, chain ID 988, enabled in the project. Select StableEarn from listYieldVaults and store its vaultId in the app's configuration.

The Stable Earn guide provides no testnet vault deployment. These examples use mainnet.

To send payments from the same wallet, pass Dynamic's viem WalletClient to the Stable SDK:

import { createWalletClientForWalletAccount } from "@dynamic-labs-sdk/evm/viem";
import { createStable, Network } from "@stablechain/sdk";

const walletClient = await createWalletClientForWalletAccount({
  walletAccount,
});
const stable = createStable({
  network: Network.Mainnet,
  walletClient,
});

Check the account and chain again before signing; a user can switch wallets after this setup. Dynamic's helper reference lists the compatible viem versions.

Show the Cost Before the Deposit

The 200 USDT0 left in the wallet covers payments and fees. The other 800 needs time to earn back the cost of moving it.

Put the expected return beside the cost of entering and leaving Earn. Include approval gas if needed, plus fees not already included in the quoted net rate.

Try the calculation with a smaller deposit. At a hypothetical 5% annual percentage yield (APY), 100 USDT0 earns about 1.3 cents a day. A deposit and withdrawal costing 0.10 USDT0 together would take about 7.5 days to pay for themselves:

const principal = 100;
const apy = 0.05;
const roundTripCost = 0.10;
const breakEvenDays =
  365 * Math.log1p(roundTripCost / principal) / Math.log1p(apy);

That estimate holds the rate constant and uses an invented fee. Fetch current vault data and gas estimates for the app. A missing fee estimate should appear as unavailable, never zero.

Use netApyExcludingRewards from getYieldDetails for this preview. The APY including incentives can count separately claimed rewards, which aren't available for a USDT0 payment while unclaimed.

For the small deposit held until evening, the screen might show “Estimated earnings: 0.01 USDT0” beside “Estimated Earn transaction costs: 0.10 USDT0.”

Deposits and Earn Positions

For the 800 USDT0 deposit, pass a decimal string. Dynamic sends an approval transaction first if the vault needs an allowance:

import { depositToYieldVault } from "@dynamic-labs-sdk/client";

const deposit = await depositToYieldVault({
  vaultId,
  walletAccount,
  amount: "800",
});

The call returns after broadcast, while the deposit can still revert. Keep it pending until a viem public client on Stable mainnet returns a successful receipt:

async function waitForSuccess(hash: `0x${string}`) {
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== "success") throw new Error("Transaction reverted");
  return receipt;
}

await waitForSuccess(deposit.transactionHash as `0x${string}`);

Dynamic's deposit reference includes the public-client setup. Reuse that client for withdrawal receipts.

After confirmation, fetch what the user's shares are currently worth in USDT0:

import { getYieldDetails, getYieldPosition } from "@dynamic-labs-sdk/client";
import { formatUnits } from "viem";

const [details, position] = await Promise.all([
  getYieldDetails({ vaultId }),
  getYieldPosition({ vaultId, walletAccount }),
]);
const inEarn = formatUnits(BigInt(position.assets), details.assetDecimals);

Use position.assets for the In Earn balance. It includes the principal: an 800 USDT0 deposit hasn't earned 800 USDT0.

Read the wallet balance separately. USDT0's native balance uses 18 decimals and its ERC-20 balance uses 6. Both refer to the same funds; adding them would count the wallet's money twice.

Earn Withdrawals for Payments

On Friday, the user asks to send 300 USDT0. There's roughly 200 in the wallet, less deposit fees. Withdrawing exactly 100 would leave them short:

Amount to withdraw = payment + remaining transaction fees − wallet balance.

If the result is zero or negative, pay from the wallet. Otherwise, round the shortfall up to the vault asset's supported precision. Use integer base units for money arithmetic; the earlier floating-point calculation is only a projection.

Show the withdrawal amount and payment recipient before asking for approval. The wallet must already cover withdrawal gas; the vault funds won't arrive in time to pay it.

Call withdrawFromYieldVault with the calculated amount as a decimal string:

import { withdrawFromYieldVault } from "@dynamic-labs-sdk/client";

const withdrawal = await withdrawFromYieldVault({
  vaultId,
  walletAccount,
  amount: shortfallWithFees, // App-calculated human-readable string.
});
await waitForSuccess(withdrawal.transactionHash as `0x${string}`);


Once the withdrawal confirms, refresh the wallet balance and fee estimate. If the wallet covers the payment, ask the user to sign the transfer:

const payment = await stable.transfer({
  from: walletAccount.address,
  to: recipient,
  amount: 300,
});

The Stable SDK transfer waits for a receipt. Omitting token sends native USDT0.

There are two transactions here, and either can fail. If the vault does not have sufficient withdrawal liquidity, the withdrawal can fail and the payment should remain unsent. A vault without enough withdrawal liquidity leaves the payment waiting. A user who completes the withdrawal but cancels the payment signature now has the money in their wallet.

Store transaction hashes against the app's payment ID so a page reload doesn't lose progress. After a timeout, check the transaction before offering a retry. Look for a txHash in StableTransactionError, or establish the outcome from the wallet's transaction history.

Observed state

What the app shows

Withdrawal submitted, no receipt yet

Withdrawing; payment has not been sent

Withdrawal reverted

Withdrawal failed; payment has not been sent

Withdrawal confirmed, payment cancelled

Funds are in the wallet; payment has not been sent

Payment receipt succeeded

Paid, with the transaction reference


Test the path where the withdrawal succeeds, and the payment fails. On retry, read the wallet balance again before taking more money out of Earn.

If you're building on Stable, find us in Discord and tell us what you're shipping.

Build on Stable