Skip to content

Scaffold Placeholder. The surface described here is not built.

Build on a listed Mayflower market

You consume a market that is already created — build your product layer (vault, portfolio tool, yield strategy, or consumer app) on top of an existing listed market whose reserve, floor, and positions are readable on-chain. This guide is market-agnostic: you only need a market address (and optionally a directory for discovery).

Creating markets is covered in Create your own Mayflower market.

Terminal window
npm install @mayflower-sys/evm-avm-sdk @mayflower-sys/evm-avm-interfaces-gen @mayflower-sys/avm-calc viem

Pick a network to get the RPC URL, chain id, and deployment addresses for the rest of this guide.

Overview

Run a preloaded anvil node from the demo package — a fully wired AVM with one tenant, ready for local iteration.

Prerequisites

  • Foundry installed and anvil on your PATH

Install the demo package

From your project root:

Terminal window
npm install --save-dev @mayflower-sys/evm-avm-client-demo tsx

Start anvil with the golden state

The demo ships a long-lived node script (no published CLI bin yet):

Terminal window
npx tsx node_modules/@mayflower-sys/evm-avm-client-demo/scripts/start-anvil.ts

When it starts you should see output like:

▶ anvil listening on http://localhost:8545
golden state loaded from …/fixtures/golden-state.json
(Ctrl-C to stop)

Chain id is 31337 (anvil default). Leave this terminal running while you work. Open a new terminal for following commands.

Read deployment addresses from the manifest

Contract addresses and the demo tenant live in a JSON file next to the golden state. Print it:

Terminal window
cat node_modules/@mayflower-sys/evm-avm-client-demo/fixtures/golden-manifest.json

Example output (yours may differ if the package version changed — always trust your cat):

{
"network": {
"rpcUrl": "http://localhost:8545",
"chainName": "anvil",
"protocolAdmin": "0x6b13585A90137dea5Ee0a674547a189Fb31D2F44"
},
"contracts": {
"marketImpl": "0xfd83eff0bd37fe07b5b8ead57969fa5f73f586d1",
"directoryImpl": "0x8f3eaaae1acbf953ca715c63c02ec2f79eed4a27",
"directoryProxy": "0xcbfefd94555b7676deeed155028ed39e5af1bc51",
"marketFactory": "0xb0ddb061bf278ed0dbd968e7965bfb361b272a1a",
"eventEmitterImpl": "0xde7fda308632a9bfd816c1b1cd4a0699ebcdb083",
"eventEmitterProxy": "0x69df8e7159ecef6afab2f6c4727ec87ed0253c3b",
"linearCurveEngineImpl": "0x02dde91ae43d9870f7678b190d1e66c79745feb1",
"linearCurveEngineProxy": "0x0bc036eb0359a2b29b5a7c2bbc13bf58c0a0ccbd",
"marketAdminActions": "0xbae2e9cf2f2dff09274de0376ad6f10250152a0b",
"marketPositionActions": "0xefa4d8005bc3c3cb2182c9ed63edabee2597e29f",
"marketTraderActions": "0xd1e35ed9be906e24ab2626ac25dfeac1a0034472"
},
"tenant": {
"id": "0xe8a091d995f061e21bb3127f20fc2c494e997e4de7719a44539353b2528b6ac1",
"label": "golden-fixture-tenant",
"admin": "0x681f9E19057e187B20c4592285e7b05705886A41",
"adminPrivateKey": "0xe56d3c51cdc36cf9ad391fdc67508986e4c8b1a2cda0ab07b31211fbb0e5b651",
"platformFeeMicroBps": "0"
}
}

tenant.label is the human-readable name of your tenant. In the golden fixture that label is golden-fixture-tenant.

Optional pretty-print if you have jq:

Terminal window
jq . node_modules/@mayflower-sys/evm-avm-client-demo/fixtures/golden-manifest.json

Map JSON fields to your config

Config field Where to read it
rpcUrl Fixed http://localhost:8545 (also under network.rpcUrl)
chainId Fixed 31337
directoryAddress contracts.directoryProxy
marketFactoryAddress contracts.marketFactory (create guide)
tenantId tenant.id (create guide)
Tenant label tenant.label — e.g. golden-fixture-tenant
Tenant admin private key (local signing only) tenant.adminPrivateKey

Use rpcUrl, chainId, directoryAddress, and tenantId for OperatorConfig below. Paste a market address in App.tsx — reserve and AVM token addresses come from on-chain state after Step 1.

Use the RPC URL, chain id, directory, and tenant id from Local setup above for OperatorConfig. Paste a single market address in App.tsx (see Step 1) — reserve, AVM, and option token addresses and decimals are read from on-chain state() via readMarketState, not duplicated in config.

If you already ran through Create your own Mayflower market, you will already have src/lib/config.ts. Use the marketAddress logged by scripts/create-market.ts.

src/lib/config.ts
import type { Address, Hex } from "viem"
export interface OperatorConfig {
/** Provided by the Mayflower team and will be specific to your chain. */
directoryAddress: Address
/** bytes32 tenant id assigned at provisioning. */
tenantId: Hex
rpcUrl: string
chainId: number
}
/** Load operator settings from env (local scripts, CI, deploy targets). */
export const operatorConfigFromEnv = (): OperatorConfig => {
const directoryAddress = process.env.DIRECTORY_ADDRESS
const tenantId = process.env.TENANT_ID
const rpcUrl = process.env.RPC_URL ?? "http://localhost:8545"
const chainId = Number(process.env.CHAIN_ID ?? "31337")
if (!directoryAddress || !tenantId) {
throw new Error("missing DIRECTORY_ADDRESS or TENANT_ID")
}
return {
directoryAddress: directoryAddress as Address,
tenantId: tenantId as Hex,
rpcUrl,
chainId,
}
}

Transport helpers: wallet connection and RPC clients

Section titled “Transport helpers: wallet connection and RPC clients”

Reuse a thin viem layer across your program. Helpful functions for sending payloads built by the SDKs to the RPC. Every snippet below builds on these:

src/lib/transport.ts
import {
createPublicClient,
createWalletClient,
http,
type Address,
type Hex,
type PublicClient,
type TransactionReceipt,
type WalletClient,
type Chain,
} from "viem"
import type { Account } from "viem/accounts"
import type { PackedCall, PackedTx } from "@mayflower-sys/evm-avm-sdk"
export type BoundWallet = WalletClient<
ReturnType<typeof http>,
Chain,
Account
>
export const makeClients = (rpcUrl: string, chainId: number) => {
const chain = {
id: chainId,
name: "mayflower-evm",
nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: [rpcUrl] } },
}
const transport = http(rpcUrl)
return {
publicClient: createPublicClient({ chain, transport }),
walletFromAccount: (account: Account): BoundWallet =>
createWalletClient({ account, chain, transport }),
}
}
/** Run a packed eth_call and decode the typed result. */
export const sendPackedCall = async <A>(
publicClient: PublicClient,
packed: PackedCall<A>,
): Promise<A> => {
const { data } = await publicClient.call({ to: packed.to, data: packed.data })
if (data === undefined) throw new Error("call returned no data")
return packed.decode(data)
}
/** Sign, send, and wait for a packed write. */
export const sendPackedTx = async (
wallet: BoundWallet,
publicClient: PublicClient,
packed: PackedTx,
): Promise<TransactionReceipt> => {
const hash = await wallet.sendTransaction({
to: packed.to,
data: packed.data,
})
const receipt = await publicClient.waitForTransactionReceipt({ hash })
if (receipt.status !== "success") {
throw new Error(`transaction reverted (${hash})`)
}
return receipt
}
/** Send a contract-creation tx and return the deployed address. */
export const deployContract = async (
wallet: BoundWallet,
publicClient: PublicClient,
data: Hex,
): Promise<Address> => {
const hash = await wallet.sendTransaction({ data })
const receipt = await publicClient.waitForTransactionReceipt({ hash })
if (receipt.status !== "success") {
throw new Error(`deployment reverted (${hash})`)
}
if (!receipt.contractAddress) {
throw new Error("deployment produced no contract address")
}
return receipt.contractAddress
}

Prerequisite — Connected wallet ETH and reserve

Section titled “Prerequisite — Connected wallet ETH and reserve”

Issuance, redemption, collateral deposits, and cash advances all spend native ETH for gas. You will also need reserve tokens in order to buy shares of the AVM tokens. Before your UI submits writes, the connected wallet needs a balance.

On Local anvil, browser wallets start with zero ETH. After eth_requestAccounts, fund the address your wallet shows with anvil’s balance setter (Foundry cast required):

Terminal window
ADDR=0xYourConnectedWalletAddress
RPC=http://localhost:8545
WEI=0x56bc75e2d63100000
echo "Before:"
cast balance "$ADDR" --rpc-url "$RPC"
cast rpc anvil_setBalance "$ADDR" "$WEI" --rpc-url "$RPC"
echo "After:"
cast balance "$ADDR" --rpc-url "$RPC" --ether

Replace ADDR with your connected wallet address. WEI is 100 ETH (0x56bc75e2d63100000).

If you deployed a mock reserve token in Create your own Mayflower market (Step 2), mint reserve to the same connected wallet before you issue shares. Use your reserve token address from that step, from scripts/create-market.ts output, or state.market.reserveTokenAddress after Step 1:

Terminal window
RESERVE=0xYourReserveToken
TO=0xYourConnectedWalletAddress
RPC=http://localhost:8545
# 1,000,000 RESERVE @ 6 decimals
AMOUNT=1000000000000
# Anvil default account #0 — mock ERC-20 mint is unrestricted on the local fixture
KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
echo "Before:"
cast call "$RESERVE" "balanceOf(address)(uint256)" "$TO" --rpc-url "$RPC"
cast send "$RESERVE" "mint(address,uint256)" "$TO" "$AMOUNT" --rpc-url "$RPC" --private-key "$KEY"
echo "After (raw / human):"
cast call "$RESERVE" "balanceOf(address)(uint256)" "$TO" --rpc-url "$RPC"
cast --from-wei "$(cast call "$RESERVE" "balanceOf(address)(uint256)" "$TO" --rpc-url "$RPC")000000000000" ether 2>/dev/null || true
python3 -c "print(int('$(cast call "$RESERVE" "balanceOf(address)(uint256)" "$TO" --rpc-url "$RPC")') / 1e6, 'RESERVE')"

Replace RESERVE with your mock reserve contract address and TO with the same connected wallet as ADDR above. Adjust AMOUNT if your token uses decimals other than 6.

Use snapshot() for price, floor, flags, and aggregate balances. Batch it with state() and netIssuedSupply() in one helper — your UI renders the display fields, and the quote step reuses the decoded market plus net-issued supply to hydrate avm-calc. Token decimals come from the decoded state() market (state.decimals), not from snapshot() — the snapshot struct has prices and balances only.

Each step below includes an App.tsx tab — the same vanilla React page, extended as you add reads, quotes, and writes. Shared hooks live under src/hooks/, presentation components under src/components/ (including shared primitives DataCard, DetailRows, and AvailabilityBadge), and RPC helpers under src/lib/. Tab selection stays in sync across steps (syncKey="build-listed-market-app").

Component snippets use Tailwind utility classes. Wire up the Mayflower theme in Optional — Mayflower styling with Tailwind when you are ready for cards, typography, and form styling — the app works without it.

src/lib/read-market-state.ts
import { Entities, Fixed18 } from "@mayflower-sys/evm-avm-sdk"
import { IMarketViews } from "@mayflower-sys/evm-avm-interfaces-gen"
import type { Address, PublicClient } from "viem"
import { sendPackedCall } from "./transport"
export type CurveSegment = "floor" | "ramp" | "main"
export interface MarketState {
spotPrice: string
floorPrice: string
reserveBalance: bigint
avmSupply: bigint
canBuy: boolean
canSell: boolean
canBorrow: boolean
decimals: number
/** Full decoded market — pass to `buildLinearMarketCalculator`. */
market: Entities.Market.Market
netIssuedSupply: bigint
slope: string
rampStart: string
rampWidth: string
rampScalar: string
segment: CurveSegment
}
const segmentAtSupply = (
supply: Fixed18.Fixed18,
rampStart: Fixed18.Fixed18,
rampEnd: Fixed18.Fixed18,
): CurveSegment => {
const x = Fixed18.toRaw(supply)
const x1 = Fixed18.toRaw(rampStart)
const x2 = Fixed18.toRaw(rampEnd)
if (x <= x1) return "floor"
if (x < x2) return "ramp"
return "main"
}
/** Hydrate the numbers your UI needs to render market state. */
export const readMarketState = async (
publicClient: PublicClient,
marketAddress: Address,
): Promise<MarketState> => {
const [snapshotResult, market, netIssuedSupply] = await Promise.all([
publicClient.call({
to: marketAddress,
data: IMarketViews.encodeSnapshot(),
}),
sendPackedCall(publicClient, Entities.Market.fetchMsg(marketAddress)),
sendPackedCall(publicClient, {
to: marketAddress,
data: IMarketViews.encodeNetIssuedSupply(),
decode: IMarketViews.decodeNetIssuedSupply,
}),
])
if (snapshotResult.data === undefined) {
throw new Error("snapshot returned no data")
}
if (market.engine._tag !== "linear") {
throw new Error(`unsupported engine: ${market.engine._tag}`)
}
const snap = IMarketViews.decodeSnapshot(snapshotResult.data)
const { segmentation, characteristic } = market.engine
const supply = Fixed18.fromToken(netIssuedSupply, market.decimals)
// SDK field `segmentation.rampStart` is the on-chain ramp-end coordinate x₂.
const rampEnd = segmentation.rampStart
const rampStart = Fixed18.fromRaw(
Fixed18.toRaw(rampEnd) - Fixed18.toRaw(segmentation.rampWidth),
)
return {
spotPrice: Fixed18.toString(Fixed18.fromRaw(snap.avmPriceInReserveToken)),
floorPrice: Fixed18.toString(
Fixed18.fromRaw(snap.floorPriceInReserveToken),
),
reserveBalance: snap.reserveTokenBalance,
avmSupply: snap.avmTokenSupply,
canBuy: snap.flags.canBuy,
canSell: snap.flags.canSell,
canBorrow: snap.flags.canBorrowReserve,
decimals: market.decimals,
market,
netIssuedSupply,
slope: Fixed18.toString(characteristic.slope),
rampStart: Fixed18.toString(rampStart),
rampWidth: Fixed18.toString(segmentation.rampWidth),
rampScalar: Fixed18.toString(segmentation.rampScalar),
segment: segmentAtSupply(supply, rampStart, rampEnd),
}
}

Gate each action in your UI when the matching flag is false — markets can toggle availability per operation.

Step 2 — Read user AVM and reserve balances

Section titled “Step 2 — Read user AVM and reserve balances”

Connect a browser wallet, then show ERC-20 balanceOf for the market reserve and AVM tokens. Wallet-held balances are separate from pledged collateral in the cash-advance position struct (Step 3). useWallet discovers MetaMask via EIP-6963, switches to your configured chain, and exposes the provider for signing in later steps.

src/lib/read-wallet-balances.ts
import type { Address, PublicClient } from "viem"
import type { MarketState } from "./read-market-state"
const erc20BalanceOfAbi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
] as const
export interface WalletBalances {
reserveBalance: bigint
avmBalance: bigint
}
/** Wallet-held ERC-20 balances for the market reserve and AVM tokens. */
export const readWalletBalances = async (
publicClient: PublicClient,
state: MarketState,
holder: Address,
): Promise<WalletBalances> => {
const { reserveTokenAddress, avmTokenAddress } = state.market
const [reserveBalance, avmBalance] = await Promise.all([
publicClient.readContract({
address: reserveTokenAddress,
abi: erc20BalanceOfAbi,
functionName: "balanceOf",
args: [holder],
}),
publicClient.readContract({
address: avmTokenAddress,
abi: erc20BalanceOfAbi,
functionName: "balanceOf",
args: [holder],
}),
])
return { reserveBalance, avmBalance }
}

Cash advance capacity depends on pledged AVM collateral and the current floor. Read positions with getPosition.

src/lib/read-position.ts
import { IMarketViews } from "@mayflower-sys/evm-avm-interfaces-gen"
import type { Address, PublicClient } from "viem"
export interface UserPosition {
collateralAvm: bigint
debtReserve: bigint
}
export const readUserPosition = async (
publicClient: PublicClient,
marketAddress: Address,
holder: Address,
): Promise<UserPosition> => {
const { data } = await publicClient.call({
to: marketAddress,
data: IMarketViews.encodeGetPosition(holder),
})
if (data === undefined) throw new Error("getPosition returned no data")
const pos = IMarketViews.decodeGetPosition(data)
return {
collateralAvm: pos.collateralBalance,
debtReserve: pos.debtBalance,
}
}

Capacity is approximately collateral × floor in reserve terms. See Cash advance for the full mechanics.

You’ll likely want to give your users buy/sell quotes rendered in your frontend. To do this, we will initialize a client-side calculator with @mayflower-sys/avm-calc, hydrated from on-chain market state, then quote reserve in → AVM out locally. On-chain settlement is authoritative; avm-calc targets roughly 0.1% relative accuracy.

Hydrate a linear calculator from chain state

Section titled “Hydrate a linear calculator from chain state”

Pass market and netIssuedSupply from readMarketState into the helper below. It assumes a linear engine — the common case for listed Mayflower markets.

Apply the group buy fee (fee-on-top on gross reserve in), then ask avm-calc for shares out. Wire the quote to the reserve input with a useEffect so previews update as the user types — debounce RPC work slightly so you are not re-fetching market state on every keystroke. On-chain group fees are MicroBps where 100_000_000 = 100%. Read the raw rates from getMarketGroup for quotes — the SDK’s decoded Fixed18 fee fractions use a different denominator and will skew previews if used directly.

src/lib/market-calculator.ts
import { Entities, Fixed18 } from "@mayflower-sys/evm-avm-sdk"
import {
Decimal,
makeLinearAvmCalculatorDecimalJs,
} from "@mayflower-sys/avm-calc"
const toDecimal = (value: string | number) => new Decimal(value)
/** Main-segment vertical shift that matches on-chain linear engine geometry. */
const mainVerticalShift = (
floor: Decimal,
slope: Decimal,
rampScalar: Decimal,
rampEnd: Decimal,
rampStart: Decimal,
): Decimal => {
const mx2 = slope.mul(rampEnd)
const mx1 = slope.mul(rampStart)
return floor.plus(rampScalar.mul(mx2.minus(mx1))).minus(mx2)
}
/** Build an avm-calc instance from a decoded linear market + net-issued supply. */
export const buildLinearMarketCalculator = (
market: Entities.Market.Market,
netIssuedSupply: bigint,
) => {
if (market.engine._tag !== "linear") {
throw new Error(`unsupported engine: ${market.engine._tag}`)
}
const { segmentation, characteristic } = market.engine
const floor = toDecimal(Fixed18.toString(segmentation.floor))
const slope = toDecimal(Fixed18.toString(characteristic.slope))
const rampScalar = toDecimal(Fixed18.toString(segmentation.rampScalar))
// SDK field `segmentation.rampStart` is the on-chain ramp-end coordinate x₂.
const rampEnd = toDecimal(Fixed18.toString(segmentation.rampStart))
const rampWidth = toDecimal(Fixed18.toString(segmentation.rampWidth))
const rampStart = rampEnd.minus(rampWidth)
const currentSupply = toDecimal(
Fixed18.toString(Fixed18.fromToken(netIssuedSupply, market.decimals)),
)
return makeLinearAvmCalculatorDecimalJs(currentSupply, {
characteristicParameters: { slope },
affineParameters: {
xScale: toDecimal(1),
xTranslation: toDecimal(0),
yScale: toDecimal(1),
yTranslation: mainVerticalShift(
floor,
slope,
rampScalar,
rampEnd,
rampStart,
),
},
segmentationParameters: {
floor,
rampStart,
rampScalar,
},
})
}

Derive slippage bounds from the quote — e.g. accept 0.5% less output than quoted:

src/lib/slippage.ts
export const SLIPPAGE_BPS = 50n // 0.50%
/** Floor a quoted output amount by slippage (basis points). */
export const minOutputAfterSlippage = (
quotedOut: bigint,
slippageBps: bigint = SLIPPAGE_BPS,
): bigint => (quotedOut * (10_000n - slippageBps)) / 10_000n

Issue flows guard AVM out; redeem flows guard reserve out — same helper, different quote field:

const minSharesOut = minOutputAfterSlippage(issueQuote.avmTokensOut)
const minReserveOut = minOutputAfterSlippage(redeemQuote.reserveTokensOut)

Quotes are point-in-time. Another transaction can move the curve before yours lands — always pass a slippage guard on variable-price actions.

Issuance, redemption, and collateral flows that pull ERC-20 tokens need allowance checks first. The helper below is consumed by IssueSharesForm (Step 6), RedeemSharesForm, and CashAdvanceForm — each form calls it internally before its write:

src/lib/approve-token.ts
import { maxUint256 } from "viem"
import type { Address, PublicClient } from "viem"
import type { BoundWallet } from "./transport"
const erc20AllowanceAbi = [
{
name: "allowance",
type: "function",
stateMutability: "view",
inputs: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
],
outputs: [{ type: "uint256" }],
},
] as const
const erc20ApproveAbi = [
{
name: "approve",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ type: "bool" }],
},
] as const
export const approveTokenIfNeeded = async (
wallet: BoundWallet,
publicClient: PublicClient,
token: Address,
spender: Address,
amount: bigint,
) => {
const allowance = await publicClient.readContract({
address: token,
abi: erc20AllowanceAbi,
functionName: "allowance",
args: [wallet.account.address, spender],
})
if (allowance >= amount) return
const hash = await wallet.writeContract({
address: token,
abi: erc20ApproveAbi,
functionName: "approve",
args: [spender, maxUint256],
})
await publicClient.waitForTransactionReceipt({ hash })
}

Signer: connected end user. Issuing shares with reserve requires an ERC-20 allowance to the market first (from Step 5). Pass walletProvider from useWallet through your forms into browserWallet — do not read window.ethereum directly when multiple wallets may be installed.

src/lib/issue-shares.ts
import { Ix, Events } from "@mayflower-sys/evm-avm-sdk"
import { parseUnits } from "viem"
import type { Address, PublicClient } from "viem"
import type { MarketState } from "./read-market-state"
import type { BoundWallet } from "./transport"
import { sendPackedTx } from "./transport"
import { approveTokenIfNeeded } from "./approve-token"
/** Issue AVM shares by spending an exact reserve input. */
export const issueSharesWithExactReserveIn = async (
marketAddress: Address,
state: MarketState,
wallet: BoundWallet,
publicClient: PublicClient,
params: {
reserveAmountHuman: string
minSharesOut: bigint
receiver?: Address
},
) => {
const exactReserveIn = parseUnits(params.reserveAmountHuman, state.decimals)
const receiver = params.receiver ?? wallet.account.address
await approveTokenIfNeeded(
wallet,
publicClient,
state.market.reserveTokenAddress,
marketAddress,
exactReserveIn,
)
const tx = Ix.IssueSharesWithExactReserveIn.pack({
market: marketAddress,
exactReserveIn,
minSharesOut: params.minSharesOut,
receiver,
})
const receipt = await sendPackedTx(wallet, publicClient, tx)
const issued = Events.findEvent(receipt, "AvmTokensBought")
return { receipt, issued }
}

The SDK exposes four issuance and redemption packers — exact-input and exact-output for each direction. Each Ix packer maps to an on-chain method on IMarket:

SDK packerGoalOn-chain method
Ix.IssueSharesWithExactReserveInSpend exactly X reservebuyWithExactReserveTokensIn
Ix.IssueSharesWithExactSharesOutReceive exactly X AVM tokensbuyWithExactAvmTokensOut
Ix.RedeemSharesWithExactSharesInBurn exactly X AVM tokenssellWithExactAvmTokensIn
Ix.RedeemSharesWithExactReserveOutReceive exactly X reservesellWithExactReserveTokensOut

Signer: connected end user. Redemption burns AVM the holder already owns. They must hold shares first — from a prior issuance step or a transfer — then approve the market to pull AVM. Wire redemption quotes the same way as issuance — debounced useEffect on the AVM input. marketAddress is the contract you call; the parent’s state snapshot is the hydrated MarketState from Step 1 — pass it as marketState into redeemSharesWithExactSharesIn.

src/lib/redeem-shares.ts
import { Ix, Events } from "@mayflower-sys/evm-avm-sdk"
import { parseUnits } from "viem"
import type { Address, PublicClient } from "viem"
import type { MarketState } from "./read-market-state"
import type { BoundWallet } from "./transport"
import { sendPackedTx } from "./transport"
import { approveTokenIfNeeded } from "./approve-token"
/** Redeem an exact AVM amount for reserve tokens. */
export const redeemSharesWithExactSharesIn = async (
marketAddress: Address,
marketState: MarketState,
wallet: BoundWallet,
publicClient: PublicClient,
params: {
sharesHuman: string
minReserveOut: bigint
receiver?: Address
},
) => {
const exactSharesIn = parseUnits(params.sharesHuman, marketState.decimals)
const receiver = params.receiver ?? wallet.account.address
// Approve market → pull AVM (same helper as reserve approval above).
await approveTokenIfNeeded(
wallet,
publicClient,
marketState.market.avmTokenAddress,
marketAddress,
exactSharesIn,
)
const tx = Ix.RedeemSharesWithExactSharesIn.pack({
market: marketAddress,
exactSharesIn,
minReserveOut: params.minReserveOut,
receiver,
})
const receipt = await sendPackedTx(wallet, publicClient, tx)
const redeemed = Events.findEvent(receipt, "AvmTokensSold")
return { receipt, redeemed }
}

After every confirmed transaction, invalidate readMarketState, readUserPosition, and readWalletBalances so balances stay fresh.

Step 8 — Cash advance: deposit collateral, then draw

Section titled “Step 8 — Cash advance: deposit collateral, then draw”

Signer: connected end user. The on-chain API uses leverage naming (depositCollateral, borrow, repay). Consider using cash advance language in your UI instead of borrow language — zero interest, non-recourse, capacity tied to the floor.

marketAddress is the on-chain contract you call. The parent’s state snapshot from Step 1 is the hydrated MarketState from readMarketState — pass it as marketState into depositCollateral and drawCashAdvance (decimals, token addresses, flags). Pass walletProvider from useWallet into browserWallet for both writes — same pattern as issuance and redemption.

Requires approving the market to pull AVM tokens, then calling depositCollateral. The helper takes marketAddress for the contract call and marketState for the decoded MarketState (token address, decimals).

src/lib/deposit-collateral.ts
import { IMarket } from "@mayflower-sys/evm-avm-interfaces-gen"
import { parseUnits } from "viem"
import type { Address, PublicClient } from "viem"
import type { MarketState } from "./read-market-state"
import type { BoundWallet } from "./transport"
import { sendPackedTx } from "./transport"
import { approveTokenIfNeeded } from "./approve-token"
/** Pledge AVM as collateral for a cash-advance position. */
export const depositCollateral = async (
marketAddress: Address,
marketState: MarketState,
wallet: BoundWallet,
publicClient: PublicClient,
collateralHuman: string,
) => {
const amount = parseUnits(collateralHuman, marketState.decimals)
const holder = wallet.account.address
await approveTokenIfNeeded(
wallet,
publicClient,
marketState.market.avmTokenAddress,
marketAddress,
amount,
)
const receipt = await sendPackedTx(wallet, publicClient, {
to: marketAddress,
data: IMarket.encodeDepositCollateral(amount, holder),
})
return receipt
}

Repayment uses repay (via IMarket.encodeRepay(amount, onBehalfOf)) with a reserve-token allowance — mirror the issuance approval pattern.

Optional — Mayflower styling with Tailwind

Section titled “Optional — Mayflower styling with Tailwind”

Pick the install line for your bundler. All three need tailwindcss and tailwindcss-animate; the PostCSS integration package differs.

Terminal window
npm install --save-dev tailwindcss @tailwindcss/vite tailwindcss-animate

Add the Tailwind Vite plugin:

vite.config.ts
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [tailwindcss(), react()],
})

Import the stylesheet from your entry file (see Import the stylesheet below — usually src/main.tsx).

Create src/styles.css (or app/globals.css on Next.js) with the Mayflower pink + navy tokens, Fraunces headings, Inter body, and the .panel error helper used by the components:

@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400&family=Fraunces:wght@400&display=swap");
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--font-body: var(--font-body);
--font-heading: var(--font-heading);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
:root {
--radius: 0rem;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--font-body: Inter, ui-sans-serif, system-ui, sans-serif;
--radius-lg: 0.25rem;
--radius-md: 0rem;
--radius-sm: 0rem;
--radius-xl: 0.5rem;
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--font-heading: Fraunces, ui-serif, Georgia, serif;
--card: #fafafa;
--ring: #9a5469;
--input: #e2e4e8;
--muted: #e2e4e8;
--accent: #3b4a63;
--border: #e2e4e8;
--chart-1: #9a5469;
--chart-2: #3b4a63;
--chart-3: #10b981;
--chart-4: #f59e0b;
--chart-5: #06b6d4;
--popover: #fafafa;
--primary: #9a5469;
--sidebar: #0f1a2b;
--secondary: #e2e4e8;
--background: #f2f3f5;
--foreground: #0f1a2b;
--destructive: #ef4444;
--sidebar-ring: #9a5469;
--sidebar-accent: #1c2b42;
--sidebar-border: #1c2b42;
--card-foreground: #0f1a2b;
--sidebar-primary: #9a5469;
--muted-foreground: #64748b;
--accent-foreground: #ffffff;
--popover-foreground: #0f1a2b;
--primary-foreground: #ffffff;
--sidebar-foreground: #fbfbfa;
--secondary-foreground: #0f1a2b;
--destructive-foreground: #ffffff;
--sidebar-accent-foreground: #ffffff;
--sidebar-primary-foreground: #ffffff;
}
.dark {
--card: #132136;
--ring: #9a5469;
--input: #1c2b42;
--muted: #1c2b42;
--accent: #3b4a63;
--border: #1c2b42;
--chart-1: #9a5469;
--chart-2: #3b4a63;
--chart-3: #10b981;
--chart-4: #f59e0b;
--chart-5: #06b6d4;
--popover: #132136;
--primary: #9a5469;
--sidebar: #09101c;
--secondary: #1c2b42;
--background: #0f1a2b;
--foreground: #fbfbfa;
--destructive: #b91c1c;
--sidebar-ring: #9a5469;
--sidebar-accent: #1c2b42;
--sidebar-border: #1c2b42;
--card-foreground: #fbfbfa;
--sidebar-primary: #9a5469;
--muted-foreground: #94a3b8;
--accent-foreground: #fbfbfa;
--popover-foreground: #fbfbfa;
--primary-foreground: #fbfbfa;
--sidebar-foreground: #fbfbfa;
--secondary-foreground: #fbfbfa;
--destructive-foreground: #fbfbfa;
--sidebar-accent-foreground: #fbfbfa;
--sidebar-primary-foreground: #fbfbfa;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
box-sizing: border-box;
overflow: auto;
overscroll-behavior-x: none;
font-family: var(--font-body);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
}
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overscroll-behavior-x: none;
}
}
.panel {
margin-top: 0.75rem;
padding: 0.75rem;
border-radius: var(--radius-sm);
background: var(--card);
border: 1px solid var(--border);
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.75rem;
}
.panel.error {
border-color: var(--destructive);
color: var(--destructive);
}

Restart your dev server after adding the stylesheet. Cards should render on a light gray background with pink primary buttons and Fraunces headings.

When there are transaction reverts, surface revert reasons in your UI and offer a suitable UX path for the given failure.

FailureLikely causeUX path
Insufficient allowanceMissing approve before a pull-based actionPrompt approve, then retry the action
Insufficient balanceWallet holds zero reserve or AVM — fund reserve (prerequisite) and issue before redeemingShow balances; link to fund / issue first
Slippage revertQuote went stale; widen bound or re-quoteRefresh quote inline; offer retry with updated min out
Borrow revertCollateral × floor cannot cover the drawShow capacity from position + floor; cap input to max drawable
Flag disabledMarket admin turned off the operationDisable the action; explain which flag is off

After this guide you can:

  1. Read live market state and the connected user’s cash-advance position
  2. Quote issuance and redemption client-side with avm-calc before opening a wallet prompt
  3. Issue and redeem shares through the SDK packers
  4. Draw cash advances by pledging AVM collateral and calling the on-chain borrow path
  5. Style the UI with Mayflower-themed Tailwind tokens (optional section above)

Dig into the on-chain protocol surface when you need revert reasons, admin flags, or types beyond what the SDK exposes.