Developer quickstart
In about 10 minutes, you run one Python script against a mainnet fork. The script finds a market through the Factory and reads its share value and Temporary Redemption Discount (TRD). Then it deposits 0.01 units of the asset to LT and withdraws the shares. LT is the market's vault contract, which mints yb-LP shares.
The sections from Find a market to Withdraw each explain one numbered part of the complete script, with the YieldBasis-specific traps in that part. To run everything at once, go to the complete script.
Prerequisites
You need these items before you start:
- Python 3 with pip, and web3.py v6 or later. To install web3.py, run
pip install "web3>=6". - An Ethereum mainnet remote procedure call (RPC) endpoint for the fork.
- A local mainnet fork, such as a Hardhat or Anvil node, for every transaction in this guide. A mainnet account spends real assets.
- One funded account on the fork. On a Hardhat or Anvil fork, the node's first account is unlocked and holds ETH. To give it the market asset, wrap ETH for WETH, or write the token's balance mapping with
hardhat_setStorageAtoranvil_setStorageAt.
Set up
Set the RPC_URL environment variable to your fork endpoint. At the top of the complete script, replace the user placeholder with your fork account. The script allows a 300-second timeout, because a fresh fork fetches mainnet state on first use. It hardcodes one contract address, the Factory's, and the market index MARKET_INDEX = 8. Index 8 is the cbBTC market in the deployed market list. The index is deployment state, not a constant, so confirm it against Contract addresses before you rely on it.
The script also defines minimal application binary interfaces (ABIs). A minimal ABI covers the calls in this guide and nothing else. Before you build a production integration, load each full ABI from the source in this table:
| ABI name in code | Full ABI source | Calls in this guide |
|---|---|---|
FACTORY_ABI | scripts/Factory.abi.json in yield-basis/yb-core | market_count, markets |
LT_ABI | scripts/LT.abi.json in yield-basis/yb-core | Reads, deposit, withdraw |
ERC20_ABI | scripts/erc20.abi.json in yield-basis/yb-core | decimals, approve, balanceOf |
AMM_ABI | Compile contracts/AMM.vy in yield-basis/yb-core, or export the ABI from the deployed contract on Etherscan | get_debt, collateral_amount |
TWOCRYPTO_ABI | Export the ABI from the deployed Curve Twocrypto pool on Etherscan | price_oracle, price_scale, POLICY, calc_token_amount |
POLICY_KILLED_ABI | The deployed policy ABI; one read-only function | is_killed |
All token amounts are in raw units, the token's smallest unit. An 8-decimal asset counts 0.01 units as 10**6. yb-LP shares use 18 decimals on every market.
Find a market
Part 1 of the script enumerates the markets onchain. Do not hardcode per-market addresses. Factory.market_count() returns the number of deployed markets. Factory.markets(i) returns a Market struct with seven fields, in this order: (asset_token, cryptopool, amm, lt, price_oracle, virtual_pool, staker).
With this ABI, web3.py returns the seven fields as a list, in that order. The script binds four contracts of market MARKET_INDEX by position: the asset (m[0]), the Curve pool (m[1]), the leveraged AMM or LEVAMM (m[2]), and LT (m[3]). Part 1 prints the market count and the LT of each market. Onchain state does not mark a market as active or deprecated. To find deprecated LTs, compare the enumerated list with the lists on Contract addresses.
Read fundamental value, redemption value, and TRD
Part 2 of the script values one share in two ways. pricePerShare() returns the Fundamental Value of one share, in asset units with 18 decimals. preview_withdraw(10**18) returns the Redemption Value of one share against the live Curve pool, in raw asset units. TRD is the signed gap between the two values: negative during a discount, positive during a premium.
preview_withdraw returns raw units at the asset's decimals, and pricePerShare() uses 18 decimals. On an 8-decimal asset such as cbBTC, redemption / pps - 1 gives about −1, which is wrong. Compute trd = redemption * 10**(18 - asset_decimals) / pps - 1, as the script does.
In a calm market, trd is near 0. A trd of -0.02 means a 2% discount on an immediate withdrawal. For a quote in the same block, use preview_withdraw. pricePerShare() is not an upper bound: during a premium, preview_withdraw returns a higher value.
On markets 3 and later, pricePerShare() uses price_scale of the Curve pool. Markets 0 to 2 use price_oracle of the pool. When a pool runs a pool policy that sets the price_scale target, pricePerShare() follows that target too. For details, read pricePerShare() in LT.
Size the debt and preview the deposit
Part 3 of the script computes debt and previews the deposit. LT.deposit(assets, debt, min_shares, receiver) takes debt as an explicit argument. debt is the crvUSD amount that the LEVAMM borrows for the deposit, in raw crvUSD units at 18 decimals. The correct debt depends on the pool policy, a contract that a Curve pool calls to set its swap fee, its price_scale target, or both.
The script's deposit_debt function reads pool.POLICY() and picks the rule:
- On a pool that runs a live (not killed) oracle-report policy,
YBOraclizedPolicy, and whose LEVAMM book holds collateral, it callspolicy_debt. That function computesdebtfrom the LEVAMM book, because the policy runs the LT deviation check. - In every other case, it uses the price rule,
assets * pool.price_oracle() // 10**asset_decimals. These are pools with noPOLICY()getter (older Twocrypto code) or a zeroPOLICY(), and pools that runYBTwocryptoPolicyor a killed policy. The price rule also applies to a pool with a liveYBOraclizedPolicywhile the LEVAMM book is empty (AMM.collateral_amount()is 0).
Do not use CryptopoolLPOracle.price() for debt. It returns the LP-token price in USD, not the asset price.
A pool of market 7 or later can run a pool policy; read POLICY() on the pool to see which one, or check the table in Contract addresses. For how policy_debt solves for debt, read Compute debt on a pool that runs the oracle-report policy. For a flowchart of the rule, including the case of an empty LEVAMM book, read Choose the debt rule.
lt.preview_deposit(assets, debt) returns the expected shares, with 18 decimals. It reverts with Debt too high when the LEVAMM value after the deposit exceeds AMM.max_debt() / 2. The script sets min_shares 10 basis points (bp) below the preview. Set your own tolerance before you send a production transaction.
On a pool with a live YBOraclizedPolicy and a LEVAMM book that holds collateral, the policy checks debt inside add_liquidity. The proportional debt matches the LEVAMM debt-to-collateral ratio. A debt from the asset price reverts with LT deviation when it differs from the proportional debt by more than ratio_tolerance. preview_deposit still returns a share estimate for a debt that fails the check. Simulate deposit before you send it, as part 4 does.
Deposit on the fork
Part 4 sends the first transactions in the script. Run it against a mainnet fork. Point RPC_URL at mainnet only when you mean to spend real assets from user.
Part 4 of the script approves LT for assets, simulates deposit with .call(), and then sends it with .transact(). A revert at the simulation line returns the revert string of the failed check, if the check has one. Each item gives the cause of a revert string and, where one exists, the fix:
LT deviation:debtdiffers from the proportional debt by more thanratio_tolerance. Interest accrues onget_debt()between the quote block and the inclusion block, andratio_toleranceabsorbs a short delay. Computedebtagain in the most recent block. Then deposit again.Deposit to staker:receiverisLT.staker(), the market's gauge. For an atomic deposit and stake, useStakeZap.Unsafe minorUnsafe max: After the deposit, LEVAMM debt falls below 1/16 or rises above 8.5/16 of collateral value.- Any other string: read the
LT.vytable in Error codes.
.transact() returns the transaction hash, not the share count. So the script reads the shares minted as the change in the receiver balance after the receipt.
Withdraw
Before you withdraw, unstake any shares that you staked in the gauge. withdraw reverts with Withdraw to/from staker when the caller or receiver is LT.staker(). For the unstake procedure, read Staked positions in Deposit and withdraw.
Part 5 of the script quotes your yb-LP balance with preview_withdraw, which includes TRD. It sets min_assets 10 bp below the quote and calls withdraw(shares, min_assets, receiver). The call reverts with Slippage if the received asset amount falls below min_assets. Set your own tolerance before you send a production transaction.
Gas use of withdraw depends on the pool and the policy, so the script sets the gas limit from an estimate plus a margin. If the checkpoint call to the GaugeController fails and less than 200,000 gas remains for the fixed-gas retry, withdraw reverts with GAS. If the LEVAMM is killed, withdraw reverts with We're dead. Use emergency_withdraw. For that exit, read emergency_withdraw() in LT.
Complete script
This script runs parts 1 to 5 in one file. To run it, follow these steps:
- Save the following script as
quickstart.py. - In the script, replace the
userplaceholder with your fork account. - Set the
RPC_URLenvironment variable to your fork endpoint. - Run
python quickstart.py.
import os
from web3 import Web3
RPC_URL = os.environ["RPC_URL"] # fork endpoint for this run
w3 = Web3(Web3.HTTPProvider(RPC_URL, request_kwargs={"timeout": 300}))
user = "0x0000000000000000000000000000000000000000" # your fork account
receiver = user
FACTORY = "0x370a449FeBb9411c95bf897021377fe0B7D100c0"
MARKET_INDEX = 8 # cbBTC market; confirm in Contract addresses
def _fn(name, inputs, outputs, mutability="view"):
return {
"name": name, "type": "function",
"stateMutability": mutability,
"inputs": [{"type": t} for t in inputs],
"outputs": [{"type": t} for t in outputs],
}
FACTORY_ABI = [
_fn("market_count", [], ["uint256"]),
_fn("markets", ["uint256"],
["address"] * 7),
]
LT_ABI = [
_fn("pricePerShare", [], ["uint256"]),
_fn("preview_withdraw", ["uint256"], ["uint256"]),
_fn("preview_deposit", ["uint256", "uint256"], ["uint256"]),
_fn("balanceOf", ["address"], ["uint256"]),
_fn("deposit", ["uint256", "uint256", "uint256", "address"],
["uint256"], "nonpayable"),
_fn("withdraw", ["uint256", "uint256", "address"],
["uint256"], "nonpayable"),
]
ERC20_ABI = [
_fn("decimals", [], ["uint8"]),
_fn("balanceOf", ["address"], ["uint256"]),
_fn("approve", ["address", "uint256"], ["bool"], "nonpayable"),
]
AMM_ABI = [
_fn("get_debt", [], ["uint256"]),
_fn("collateral_amount", [], ["uint256"]),
]
TWOCRYPTO_ABI = [
_fn("price_oracle", [], ["uint256"]),
_fn("price_scale", [], ["uint256"]),
_fn("POLICY", [], ["address"]),
_fn("calc_token_amount", ["uint256[2]", "bool"], ["uint256"]),
]
POLICY_KILLED_ABI = [{
"name": "is_killed", "inputs": [],
"outputs": [{"type": "bool"}],
"stateMutability": "view", "type": "function",
}]
factory = w3.eth.contract(address=FACTORY, abi=FACTORY_ABI)
# 1. Enumerate markets via Factory.markets(i).
count = factory.functions.market_count().call()
print("market_count:", count)
for i in range(count):
print(i, factory.functions.markets(i).call()[3]) # LT per market
m = factory.functions.markets(MARKET_INDEX).call()
asset = w3.eth.contract(address=m[0], abi=ERC20_ABI)
pool = w3.eth.contract(address=m[1], abi=TWOCRYPTO_ABI)
amm = w3.eth.contract(address=m[2], abi=AMM_ABI)
lt = w3.eth.contract(address=m[3], abi=LT_ABI)
asset_decimals = asset.functions.decimals().call()
# 2. Read PPS and TRD. Both use 18-decimal share units.
pps = lt.functions.pricePerShare().call()
redemption = lt.functions.preview_withdraw(10**18).call()
trd = redemption * 10**(18 - asset_decimals) / pps - 1
print("pps:", pps, "redemption:", redemption, "trd:", trd)
# 3. Compute deposit debt, scoped by pool policy, and preview.
def policy_debt(pool, amm, assets, asset_decimals):
book_debt = amm.functions.get_debt().call()
book_coll = amm.functions.collateral_amount().call()
def gap(d):
lp = pool.functions.calc_token_amount([d, assets], True).call()
return d - lp * book_debt // book_coll
d0 = assets * pool.functions.price_scale().call()
d0 = d0 // 10**asset_decimals
d1 = d0 * 101 // 100
for _ in range(6):
g0, g1 = gap(d0), gap(d1)
if g1 == 0 or g1 == g0:
break
d0, d1 = d1, d1 - g1 * (d1 - d0) // (g1 - g0)
return d1
def deposit_debt(pool, amm, assets, asset_decimals):
try:
policy = pool.functions.POLICY().call()
except Exception:
policy = "0x" + "00" * 20 # markets 0 to 6: no POLICY()
if int(policy, 16) != 0:
p = w3.eth.contract(address=policy, abi=POLICY_KILLED_ABI)
try:
killed = p.functions.is_killed().call()
except Exception:
# YBTwocryptoPolicy has no is_killed(). The price rule applies.
killed = True
if not killed and amm.functions.collateral_amount().call() > 0:
return policy_debt(pool, amm, assets, asset_decimals)
# Price rule: no policy, YBTwocryptoPolicy, a killed policy, or an
# empty LEVAMM book.
price = pool.functions.price_oracle().call()
return assets * price // 10**asset_decimals
assets = 10**asset_decimals // 100 # 0.01 asset units
debt = deposit_debt(pool, amm, assets, asset_decimals)
expected = lt.functions.preview_deposit(assets, debt).call()
min_shares = expected * 9_990 // 10_000 # 10 bp tolerance
# 4. Deposit. Fork check: from here on, each .transact() call sends a
# transaction. Confirm RPC_URL points at your fork before you run.
tx = asset.functions.approve(lt.address, assets).transact({"from": user})
w3.eth.wait_for_transaction_receipt(tx)
lt.functions.deposit(
assets, debt, min_shares, receiver
).call({"from": user}) # simulate; catches LT deviation
before = lt.functions.balanceOf(receiver).call()
tx = lt.functions.deposit(
assets, debt, min_shares, receiver
).transact({"from": user}) # .transact() returns the transaction hash
receipt = w3.eth.wait_for_transaction_receipt(tx)
shares = lt.functions.balanceOf(receiver).call() - before
print("status:", receipt.status, "shares received:", shares)
# 5. Withdraw. If you staked shares in the gauge, unstake them first.
shares = lt.functions.balanceOf(user).call() # your yb-LP balance
expected = lt.functions.preview_withdraw(shares).call()
# preview_withdraw includes TRD.
min_assets = expected * 9_990 // 10_000 # 10 bp tolerance
withdraw = lt.functions.withdraw(shares, min_assets, receiver)
gas = withdraw.estimate_gas({"from": user}) * 2 # estimate plus a margin
before = asset.functions.balanceOf(receiver).call()
tx = withdraw.transact({"from": user, "gas": gas})
receipt = w3.eth.wait_for_transaction_receipt(tx)
received = asset.functions.balanceOf(receiver).call() - before
print("status:", receipt.status, "assets received:", received)
Expected output: the script prints these lines, in order:
- A
market_countline. - One LT address per market index, from
0tocount - 1. - A
pps/redemption/trdline. - A deposit line with
status: 1and the shares received, in 18-decimal units. - A withdraw line with
status: 1and the assets received, in raw asset units.
The market lines are deployment state. The other values can change from block to block: price_scale moves, and users trade, deposit, or withdraw. The script's own deposit and withdrawal also change the state, so a second run on the same fork can print different values.
To read the pps line, take these illustrative values on the cbBTC market (8 decimals). pps = 1006000000000000000 reads as 1.006 cbBTC per share, and redemption = 100500000 reads as 1.005 cbBTC. Then trd is about -0.001, a 0.1% discount.
If no market lines print, RPC_URL reaches no mainnet state. If the simulation line reverts, the revert string names the failed check. Fix the named input. Then run the script again.
Traps checklist
Check these points before you adapt the script:
- Get per-market addresses from
Factory.markets(i). Do not hardcode them. - Quote a withdrawal with
preview_withdraw, scaled by10**(18 - asset_decimals)to compare it withpricePerShare().pricePerShare()does not include TRD and is not an upper bound. - Size
debtby pool policy. Use the LEVAMM book on a pool with a liveYBOraclizedPolicyand a LEVAMM book that holds collateral. Use the price rule in every other case. - Simulate
depositbefore you send it.preview_depositdoes not run the LT deviation check. - Set your own
min_sharesandmin_assetstolerances in place of the script's 10 bp. - Do not use
LT.staker()asreceiver, and unstake before you withdraw. - Give
withdrawa gas margin. It reverts withGASwhen the GaugeController checkpoint fails and less than 200,000 gas remains. - Do not call
convertToAssetson LT. That function is on the gauge,LiquidityGauge.
Next steps
Continue with the page that matches your integration:
- Quote and route: quote a swap through VirtualPool or directly on the Curve pool. To send a VirtualPool swap, read Execute a VirtualPool swap.
- Deposit and withdraw: both
debtrules in full, staked positions, and every revert string of both calls. - Event catalog: typed signatures and topic0 hashes to index deposits, withdrawals, and swaps.
- HybridVault: deposit to up to 16 markets with crvUSD backing through one vault per user.
- LT: every LT function, including the liquidity buckets and
emergency_withdraw.