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.
Install dependencies
Section titled “Install dependencies”npm install @mayflower-sys/evm-avm-sdk @mayflower-sys/evm-avm-interfaces-gen @mayflower-sys/avm-calc viempnpm add @mayflower-sys/evm-avm-sdk @mayflower-sys/evm-avm-interfaces-gen @mayflower-sys/avm-calc viemyarn add @mayflower-sys/evm-avm-sdk @mayflower-sys/evm-avm-interfaces-gen @mayflower-sys/avm-calc viembun add @mayflower-sys/evm-avm-sdk @mayflower-sys/evm-avm-interfaces-gen @mayflower-sys/avm-calc viemConnecting to the network
Section titled “Connecting to the network”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
anvilon yourPATH
Install the demo package
From your project root:
npm install --save-dev @mayflower-sys/evm-avm-client-demo tsxpnpm add -D @mayflower-sys/evm-avm-client-demo tsxyarn add -D @mayflower-sys/evm-avm-client-demo tsxbun add -D @mayflower-sys/evm-avm-client-demo tsxStart anvil with the golden state
The demo ships a long-lived node script (no published CLI bin yet):
npx tsx node_modules/@mayflower-sys/evm-avm-client-demo/scripts/start-anvil.tspnpm exec tsx node_modules/@mayflower-sys/evm-avm-client-demo/scripts/start-anvil.tsyarn exec tsx node_modules/@mayflower-sys/evm-avm-client-demo/scripts/start-anvil.tsbun x tsx node_modules/@mayflower-sys/evm-avm-client-demo/scripts/start-anvil.tsWhen 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:
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:
jq . node_modules/@mayflower-sys/evm-avm-client-demo/fixtures/golden-manifest.jsonMap 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.
Hosted, testnet, and mainnet network entries will appear here when they are published. For now, use Local above to develop against anvil.
Configuration that your application holds
Section titled “Configuration that your application holds”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.
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:
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):
ADDR=0xYourConnectedWalletAddressRPC=http://localhost:8545WEI=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" --etherReplace 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:
RESERVE=0xYourReserveTokenTO=0xYourConnectedWalletAddressRPC=http://localhost:8545# 1,000,000 RESERVE @ 6 decimalsAMOUNT=1000000000000# Anvil default account #0 — mock ERC-20 mint is unrestricted on the local fixtureKEY=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 || truepython3 -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.
Step 1 — Read market state for display
Section titled “Step 1 — Read market state for display”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.
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), }}import { useCallback, useEffect, useState } from "react"import type { Address, PublicClient } from "viem"
import { readMarketState, type MarketState } from "../lib/read-market-state"
export const useMarketState = ( publicClient: PublicClient, marketAddress: Address,) => { const [state, setState] = useState<MarketState | null>(null) const [error, setError] = useState<string | null>(null) const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => { const next = await readMarketState(publicClient, marketAddress) setState(next) return next }, [publicClient, marketAddress])
useEffect(() => { let cancelled = false
async function load() { try { const next = await readMarketState(publicClient, marketAddress) if (!cancelled) setState(next) } catch (cause) { if (!cancelled) { setError(cause instanceof Error ? cause.message : String(cause)) } } finally { if (!cancelled) setLoading(false) } }
load()
return () => { cancelled = true } }, [publicClient, marketAddress])
return { state, error, loading, refresh }}import type { ReactNode } from "react"
export function DataCard({ title, children, className = "",}: { title: string children: ReactNode className?: string}) { const headingId = `${title.replace(/\s+/g, "-").toLowerCase()}-heading`
return ( <section className={`rounded-sm border border-border bg-card p-5 shadow-sm ${className}`} aria-labelledby={headingId} > <h2 id={headingId} className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground" > {title} </h2> <div>{children}</div> </section> )}export type DetailRow = { label: string value: string emphasis?: boolean}
export function DetailRows({ rows }: { rows: DetailRow[] }) { return ( <dl> {rows.map((row) => ( <div key={row.label} className="flex items-center justify-between gap-4 border-b border-border py-2.5 first:pt-0 last:border-b-0 last:pb-0" > <dt className="text-sm text-muted-foreground">{row.label}</dt> <dd className={`text-right font-mono text-sm ${ row.emphasis ? "font-semibold text-primary" : "font-medium text-foreground" }`} > {row.value} </dd> </div> ))} </dl> )}export function AvailabilityBadge({ enabled }: { enabled: boolean }) { return ( <span className={`inline-flex rounded-sm px-2 py-0.5 text-xs font-semibold ${ enabled ? "bg-primary/10 text-primary" : "bg-secondary text-muted-foreground" }`} > {enabled ? "Yes" : "No"} </span> )}import type { MarketState } from "../lib/read-market-state"import { AvailabilityBadge } from "./AvailabilityBadge"import { DataCard } from "./DataCard"import { DetailRows } from "./DetailRows"
export function MarketOverview({ state }: { state: MarketState }) { const priceRows = [ { label: "Spot", value: state.spotPrice }, { label: "Floor", value: state.floorPrice, emphasis: true }, { label: "Segment", value: state.segment }, ]
const balanceRows = [ { label: "Reserve", value: String(state.reserveBalance) }, { label: "AVM supply", value: String(state.avmSupply) }, { label: "Net issued", value: String(state.netIssuedSupply) }, { label: "Decimals", value: String(state.decimals) }, ]
const curveRows = [ { label: "Slope", value: state.slope, emphasis: true }, { label: "Ramp start", value: state.rampStart }, { label: "Ramp width", value: state.rampWidth }, { label: "Ramp scalar", value: state.rampScalar }, ]
const availabilityItems = [ { label: "Can buy", enabled: state.canBuy }, { label: "Can sell", enabled: state.canSell }, { label: "Can borrow", enabled: state.canBorrow }, ]
return ( <div className="grid gap-4 sm:grid-cols-2"> <DataCard title="Prices"> <DetailRows rows={priceRows} /> </DataCard>
<DataCard title="Availability"> <dl> {availabilityItems.map(({ label, enabled }) => ( <div key={label} className="flex items-center justify-between gap-4 border-b border-border py-2.5 first:pt-0 last:border-b-0 last:pb-0" > <dt className="text-sm text-muted-foreground">{label}</dt> <dd> <AvailabilityBadge enabled={enabled} /> </dd> </div> ))} </dl> </DataCard>
<DataCard title="Balances"> <DetailRows rows={balanceRows} /> </DataCard>
<DataCard title="Linear curve"> <DetailRows rows={curveRows} /> </DataCard> </div> )}import { useMemo } from "react"import type { Address, Hex } from "viem"
import { MarketOverview } from "./components/MarketOverview"import { useMarketState } from "./hooks/useMarketState"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error, loading } = useMarketState(publicClient, MARKET_ADDRESS)
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (error || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {error ? <pre className="panel error">{error}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <h1 className="mb-6 font-heading text-2xl font-semibold tracking-tight text-foreground sm:text-3xl"> Mayflower Market </h1> <MarketOverview state={state} /> </div> </main> )}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.
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 }}import { useCallback, useEffect, useState } from "react"import type { Address, EIP1193Provider } from "viem"
interface Eip6963ProviderDetail { info: { rdns: string } provider: EIP1193Provider}
const isMetaMask = ({ info }: Eip6963ProviderDetail) => info.rdns === "io.metamask"
export const useWallet = (chainId: number) => { const [address, setAddress] = useState<Address | null>(null) const [provider, setProvider] = useState<EIP1193Provider | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { const onProviderAnnounced = (event: Event) => { const { detail } = event as CustomEvent<Eip6963ProviderDetail> if (isMetaMask(detail)) setProvider(detail.provider) }
window.addEventListener("eip6963:announceProvider", onProviderAnnounced) window.dispatchEvent(new Event("eip6963:requestProvider"))
return () => window.removeEventListener( "eip6963:announceProvider", onProviderAnnounced, ) }, [])
const connect = useCallback(async () => { if (!provider) { setError("MetaMask was not found") return }
setError(null) try { await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: `0x${chainId.toString(16)}` }], }) const accounts = (await provider.request({ method: "eth_requestAccounts", })) as Address[] setAddress(accounts[0] ?? null) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } }, [chainId, provider])
return { address, provider, error, connect }}import { useCallback, useEffect, useState } from "react"import type { Address, PublicClient } from "viem"
import type { MarketState } from "../lib/read-market-state"import { readWalletBalances, type WalletBalances,} from "../lib/read-wallet-balances"
export const useWalletBalances = ( publicClient: PublicClient, state: MarketState | null, holder: Address | null,) => { const [balances, setBalances] = useState<WalletBalances | null>(null) const [error, setError] = useState<string | null>(null)
const refresh = useCallback( async (address: Address) => { if (!state) return null const next = await readWalletBalances(publicClient, state, address) setBalances(next) return next }, [publicClient, state], )
useEffect(() => { if (!holder || !state) { setBalances(null) return }
let cancelled = false
async function load(address: Address, state: MarketState) { try { const next = await readWalletBalances(publicClient, state, address) if (!cancelled) setBalances(next) } catch (cause) { if (!cancelled) { setError(cause instanceof Error ? cause.message : String(cause)) } } }
load(holder, state)
return () => { cancelled = true } }, [holder, publicClient, state])
return { balances, error, refresh }}export function PageHeader({ connected, onConnect,}: { connected: boolean onConnect: () => void}) { return ( <header className="border-b border-border bg-background px-5 py-4 sm:px-8" aria-label="Page header" > <nav className="mx-auto flex max-w-[720px] items-center justify-between gap-4" aria-label="Primary navigation" > <h1 className="font-heading text-2xl font-semibold tracking-tight text-foreground sm:text-3xl"> Mayflower Market </h1> <button type="button" onClick={onConnect} aria-pressed={connected} className="shrink-0 rounded-sm bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30" > {connected ? "Wallet connected" : "Connect wallet"} </button> </nav> </header> )}import { formatUnits } from "viem"
import type { WalletBalances as WalletBalancesData } from "../lib/read-wallet-balances"import { DetailRows } from "./DetailRows"
export function WalletBalances({ balances, decimals,}: { balances: WalletBalancesData decimals: number}) { return ( <DetailRows rows={[ { label: "Reserve", value: formatUnits(balances.reserveBalance, decimals), }, { label: "AVM", value: formatUnits(balances.avmBalance, decimals) }, ]} /> )}import { useMemo } from "react"import type { Address, Hex } from "viem"
import { DataCard } from "./components/DataCard"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error: marketError, loading, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, error: walletError, connect } = useWallet(operator.chainId) const { balances, error: balancesError } = useWalletBalances( publicClient, state, address, )
const error = marketError ?? walletError ?? balancesError
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (error || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {error ? <pre className="panel error">{error}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : balancesError ? null : <p className="text-sm text-muted-foreground"> Loading balances… </p> } {balancesError ? <pre className="panel error">{balancesError}</pre> : null} </DataCard> </div> : null} </div> </main> )}Step 3 — Read the user’s position
Section titled “Step 3 — Read the user’s position”Cash advance capacity depends on pledged AVM collateral and the current floor. Read positions with getPosition.
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, }}import { useCallback, useEffect, useState } from "react"import type { Address, PublicClient } from "viem"
import { readUserPosition, type UserPosition } from "../lib/read-position"
export const useUserPosition = ( publicClient: PublicClient, marketAddress: Address, holder: Address | null,) => { const [position, setPosition] = useState<UserPosition | null>(null) const [error, setError] = useState<string | null>(null)
const refresh = useCallback( async (address: Address) => { const next = await readUserPosition(publicClient, marketAddress, address) setPosition(next) return next }, [publicClient, marketAddress], )
useEffect(() => { if (!holder) { setPosition(null) return }
let cancelled = false
async function load(address: Address) { try { const next = await readUserPosition( publicClient, marketAddress, address, ) if (!cancelled) setPosition(next) } catch (cause) { if (!cancelled) { setError(cause instanceof Error ? cause.message : String(cause)) } } }
load(holder)
return () => { cancelled = true } }, [holder, publicClient, marketAddress])
return { position, error, refresh }}import type { UserPosition as UserPositionData } from "../lib/read-position"import { DataCard } from "./DataCard"import { DetailRows } from "./DetailRows"
export function UserPosition({ position }: { position: UserPositionData }) { return ( <DataCard title="Your position"> <DetailRows rows={[ { label: "Collateral AVM", value: String(position.collateralAvm) }, { label: "Debt reserve", value: String(position.debtReserve) }, ]} /> </DataCard> )}import { useMemo } from "react"import type { Address, Hex } from "viem"
import { DataCard } from "./components/DataCard"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { UserPosition } from "./components/UserPosition"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useUserPosition } from "./hooks/useUserPosition"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error: marketError, loading, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, error: walletError, connect } = useWallet(operator.chainId) const { position, error: positionError } = useUserPosition( publicClient, MARKET_ADDRESS, address, ) const { balances, error: balancesError } = useWalletBalances( publicClient, state, address, )
const error = marketError ?? walletError ?? positionError ?? balancesError
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (error || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {error ? <pre className="panel error">{error}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : balancesError ? null : <p className="text-sm text-muted-foreground"> Loading balances… </p> } {balancesError ? <pre className="panel error">{balancesError}</pre> : null} </DataCard> </div> : null}
{address && position ? <div className="mt-4"> <UserPosition position={position} /> </div> : null} </div> </main> )}Capacity is approximately collateral × floor in reserve terms. See Cash advance for the full mechanics.
Step 4 — Quote before you submit
Section titled “Step 4 — Quote before you submit”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.
Quote reserve in → AVM out
Section titled “Quote reserve in → AVM out”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.
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, }, })}import { IAvmDirectoryViews } from "@mayflower-sys/evm-avm-interfaces-gen"import { Decimal } from "@mayflower-sys/avm-calc"import type { Address, Hex, PublicClient } from "viem"
import { sendPackedCall } from "./transport"
/** On-chain MicroBps denominator (`100_000_000` = 100%). */export const CHAIN_MICRO_BPS_ONE = 100_000_000n
/** Raw MicroBps from `getMarketGroup` → decimal fraction for quote math. */export const chainFeeRateDecimal = (microBps: bigint): Decimal => new Decimal(microBps.toString()).div( new Decimal(CHAIN_MICRO_BPS_ONE.toString()), )
/** Read buy/sell group fee rates as decimal fractions. */export const fetchGroupFeeRates = async ( publicClient: PublicClient, directoryAddress: Address, groupId: Hex,) => { const group = await sendPackedCall(publicClient, { to: directoryAddress, data: IAvmDirectoryViews.encodeGetMarketGroup(groupId), decode: IAvmDirectoryViews.decodeGetMarketGroup, })
return { buy: chainFeeRateDecimal(group.fees.buy), sell: chainFeeRateDecimal(group.fees.sell), }}import { Fixed18 } from "@mayflower-sys/evm-avm-sdk"import { Decimal } from "@mayflower-sys/avm-calc"import { parseUnits } from "viem"import type { Address, PublicClient } from "viem"import type { OperatorConfig } from "./config"import { buildLinearMarketCalculator } from "./market-calculator"import { fetchGroupFeeRates } from "./group-fee-rate"import { readMarketState } from "./read-market-state"
const toDecimal = (value: string | number) => new Decimal(value)
/** Quote reserve in → AVM out for an exact reserve input (client-side). */export const quoteIssueWithExactReserveIn = async ( publicClient: PublicClient, operator: OperatorConfig, marketAddress: Address, reserveAmountHuman: string,) => { const marketState = await readMarketState(publicClient, marketAddress) if (!marketState.canBuy) throw new Error("issuance disabled on this market")
const { buy: buyRate } = await fetchGroupFeeRates( publicClient, operator.directoryAddress, marketState.market.groupId, )
const calc = buildLinearMarketCalculator( marketState.market, marketState.netIssuedSupply, ) const grossReserveTokensIn = parseUnits( reserveAmountHuman, marketState.decimals, ) const grossHuman = Fixed18.toString( Fixed18.fromToken(grossReserveTokensIn, marketState.decimals), )
// Fee-on-top: net reserves reaching the curve ≈ gross × (1 − buyRate). // On-chain fee rounding favors the pool; treat this as a UI preview. const netCurveReserve = toDecimal(grossHuman).mul( new Decimal(1).minus(buyRate), ) const sharesOutHuman = calc.sharesOutForExactReservesIn(netCurveReserve) const avmTokensOut = Fixed18.toTokenDown( Fixed18.fromString(sharesOutHuman.toString()), marketState.decimals, )
return { grossReserveTokensIn, avmTokensOut, netCurveReserve: netCurveReserve.toString(), }}import { useEffect, useState } from "react"import type { Address, PublicClient } from "viem"
import type { OperatorConfig } from "../lib/config"import { quoteIssueWithExactReserveIn } from "../lib/quote-issue"import { DetailRows } from "./DetailRows"
export function IssueSharesForm({ operator, marketAddress, publicClient, canBuy,}: { operator: OperatorConfig marketAddress: Address publicClient: PublicClient canBuy: boolean}) { const [reserveAmount, setReserveAmount] = useState("1") const [quote, setQuote] = useState<{ avmTokensOut: bigint netCurveReserve: string } | null>(null) const [quoting, setQuoting] = useState(false) const [error, setError] = useState<string | null>(null)
useEffect(() => { if (!canBuy || reserveAmount.trim() === "") { setQuote(null) return }
let cancelled = false const timer = window.setTimeout(() => { setQuoting(true) setError(null)
void quoteIssueWithExactReserveIn( publicClient, operator, marketAddress, reserveAmount, ) .then((next) => { if (!cancelled) setQuote(next) }) .catch((cause: unknown) => { if (!cancelled) { setQuote(null) setError(cause instanceof Error ? cause.message : String(cause)) } }) .finally(() => { if (!cancelled) setQuoting(false) }) }, 300)
return () => { cancelled = true window.clearTimeout(timer) } }, [canBuy, marketAddress, operator, publicClient, reserveAmount])
return ( <section className="mt-4 rounded-sm border border-border bg-card p-5 shadow-md" aria-labelledby="issue-quote-heading" > <h2 id="issue-quote-heading" className="mb-5 font-heading text-xl font-semibold text-foreground" > Issue quote </h2> <label htmlFor="issue-quote-amount" className="mb-2 block text-sm font-medium text-muted-foreground" > Reserve in </label> <input id="issue-quote-amount" type="number" min="0" step="any" value={reserveAmount} onChange={(event) => setReserveAmount(event.target.value)} className="mb-4 h-10 w-full rounded-sm border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20" /> {error ? <pre className="panel error">{error}</pre> : null} {quoting && !quote ? <p className="text-sm text-muted-foreground">Updating quote…</p> : null} {quote ? <div className="mt-3"> <DetailRows rows={[ { label: "AVM out", value: String(quote.avmTokensOut) }, { label: "Net to curve", value: quote.netCurveReserve }, ]} /> </div> : null} </section> )}import { useMemo } from "react"import type { Address, Hex } from "viem"
import { DataCard } from "./components/DataCard"import { IssueSharesForm } from "./components/IssueSharesForm"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { UserPosition } from "./components/UserPosition"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useUserPosition } from "./hooks/useUserPosition"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error: marketError, loading, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, error: walletError, connect } = useWallet(operator.chainId) const { position, error: positionError } = useUserPosition( publicClient, MARKET_ADDRESS, address, ) const { balances, error: balancesError } = useWalletBalances( publicClient, state, address, )
const error = marketError ?? walletError ?? positionError ?? balancesError
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (error || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {error ? <pre className="panel error">{error}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : balancesError ? null : <p className="text-sm text-muted-foreground"> Loading balances… </p> } {balancesError ? <pre className="panel error">{balancesError}</pre> : null} </DataCard> </div> : null}
{address && position ? <div className="mt-4"> <UserPosition position={position} /> </div> : null}
<IssueSharesForm operator={operator} marketAddress={MARKET_ADDRESS} publicClient={publicClient} canBuy={state.canBuy} /> </div> </main> )}Derive slippage bounds from the quote — e.g. accept 0.5% less output than quoted:
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_000nIssue 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.
Step 5 — Approve tokens before pulls
Section titled “Step 5 — Approve tokens before pulls”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:
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 })}Step 6 — Issue shares with the SDK
Section titled “Step 6 — Issue shares with the SDK”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.
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 }}import { createWalletClient, custom, type Address, type Chain, type EIP1193Provider,} from "viem"
import type { BoundWallet } from "./transport"
/** Bind a connected browser account to a viem wallet client. */export const browserWallet = ( provider: EIP1193Provider, holder: Address, rpcUrl: string, chainId: number,): BoundWallet => { const chain: Chain = { id: chainId, name: "mayflower-evm", nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } }, } return createWalletClient({ account: holder, chain, transport: custom(provider), })}// src/components/IssueSharesForm.tsx — Step 6 adds issuance on top of the Step 4 quote form.import { useEffect, useState } from "react"import type { Address, EIP1193Provider, PublicClient } from "viem"
import type { OperatorConfig } from "../lib/config"import type { MarketState } from "../lib/read-market-state"import { browserWallet } from "../lib/browser-wallet"import { issueSharesWithExactReserveIn } from "../lib/issue-shares"import { quoteIssueWithExactReserveIn } from "../lib/quote-issue"import { minOutputAfterSlippage } from "../lib/slippage"import { DetailRows } from "./DetailRows"
export function IssueSharesForm({ operator, marketAddress, state, publicClient, holder, walletProvider, canBuy, onSuccess,}: { operator: OperatorConfig marketAddress: Address state: MarketState publicClient: PublicClient holder: Address | null walletProvider: EIP1193Provider | null canBuy: boolean onSuccess: () => Promise<void>}) { const [reserveAmount, setReserveAmount] = useState("1") const [quote, setQuote] = useState<{ avmTokensOut: bigint netCurveReserve: string } | null>(null) const [quoting, setQuoting] = useState(false) const [issuing, setIssuing] = useState(false) const [error, setError] = useState<string | null>(null)
useEffect(() => { if (!canBuy || reserveAmount.trim() === "") { setQuote(null) return }
let cancelled = false const timer = window.setTimeout(() => { setQuoting(true) setError(null)
void quoteIssueWithExactReserveIn( publicClient, operator, marketAddress, reserveAmount, ) .then((next) => { if (!cancelled) setQuote(next) }) .catch((cause: unknown) => { if (!cancelled) { setQuote(null) setError(cause instanceof Error ? cause.message : String(cause)) } }) .finally(() => { if (!cancelled) setQuoting(false) }) }, 300)
return () => { cancelled = true window.clearTimeout(timer) } }, [canBuy, marketAddress, operator, publicClient, reserveAmount])
async function submitIssue() { if (!holder || !walletProvider || !canBuy) return setIssuing(true) setError(null) try { const freshQuote = await quoteIssueWithExactReserveIn( publicClient, operator, marketAddress, reserveAmount, ) setQuote(freshQuote) const minSharesOut = minOutputAfterSlippage(freshQuote.avmTokensOut) await issueSharesWithExactReserveIn( marketAddress, state, browserWallet( walletProvider, holder, operator.rpcUrl, operator.chainId, ), publicClient, { reserveAmountHuman: reserveAmount, minSharesOut, }, ) await onSuccess() } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setIssuing(false) } }
return ( <section className="rounded-sm border border-border bg-card p-5 shadow-md" aria-labelledby="issue-heading" > <h2 id="issue-heading" className="mb-5 font-heading text-xl font-semibold text-foreground" > Issue </h2> <label htmlFor="issue-amount" className="mb-2 block text-sm font-medium text-muted-foreground" > Reserve in </label> <input id="issue-amount" type="number" min="0" step="any" value={reserveAmount} onChange={(event) => setReserveAmount(event.target.value)} className="mb-4 h-10 w-full rounded-sm border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20" /> <button type="button" disabled={ issuing || quoting || !holder || !walletProvider || !canBuy || !quote } onClick={() => void submitIssue()} className="w-full rounded-sm bg-primary px-3 py-2.5 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60" > Issue shares </button> {error ? <pre className="panel error">{error}</pre> : null} {quoting && !quote ? <p className="mt-3 text-sm text-muted-foreground">Updating quote…</p> : null} {quote ? <div className="mt-3"> <DetailRows rows={[ { label: "AVM out", value: String(quote.avmTokensOut) }, { label: "Net to curve", value: quote.netCurveReserve }, ]} /> </div> : null} </section> )}import { useCallback, useMemo } from "react"import type { Address, Hex } from "viem"
import { DataCard } from "./components/DataCard"import { IssueSharesForm } from "./components/IssueSharesForm"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { UserPosition } from "./components/UserPosition"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useUserPosition } from "./hooks/useUserPosition"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error: marketError, loading, refresh: refreshMarket, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, connect, provider: walletProvider, } = useWallet(operator.chainId) const { position, refresh: refreshPosition } = useUserPosition( publicClient, MARKET_ADDRESS, address, ) const { balances, refresh: refreshWalletBalances } = useWalletBalances( publicClient, state, address, )
const refreshAll = useCallback(async () => { await refreshMarket() if (address) { await refreshPosition(address) await refreshWalletBalances(address) } }, [address, refreshMarket, refreshPosition, refreshWalletBalances])
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (marketError || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {marketError ? <pre className="panel error">{marketError}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : <p className="text-sm text-muted-foreground"> Loading balances… </p> } </DataCard> </div> : null}
{address && position ? <div className="mt-4"> <UserPosition position={position} /> </div> : null}
<div className="mt-4"> <IssueSharesForm operator={operator} marketAddress={MARKET_ADDRESS} state={state} publicClient={publicClient} walletProvider={walletProvider} holder={address} canBuy={state.canBuy} onSuccess={refreshAll} /> </div> </div> </main> )}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 packer | Goal | On-chain method |
|---|---|---|
Ix.IssueSharesWithExactReserveIn | Spend exactly X reserve | buyWithExactReserveTokensIn |
Ix.IssueSharesWithExactSharesOut | Receive exactly X AVM tokens | buyWithExactAvmTokensOut |
Ix.RedeemSharesWithExactSharesIn | Burn exactly X AVM tokens | sellWithExactAvmTokensIn |
Ix.RedeemSharesWithExactReserveOut | Receive exactly X reserve | sellWithExactReserveTokensOut |
Step 7 — Redeem shares with the SDK
Section titled “Step 7 — Redeem shares with the SDK”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.
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 }}import { Fixed18 } from "@mayflower-sys/evm-avm-sdk"import { Decimal } from "@mayflower-sys/avm-calc"import { parseUnits } from "viem"import type { Address, PublicClient } from "viem"import type { OperatorConfig } from "./config"import { buildLinearMarketCalculator } from "./market-calculator"import { fetchGroupFeeRates } from "./group-fee-rate"import { readMarketState } from "./read-market-state"
const toDecimal = (value: string | number) => new Decimal(value)
/** Quote AVM in → reserve out for an exact share input (client-side). */export const quoteRedeemWithExactSharesIn = async ( publicClient: PublicClient, operator: OperatorConfig, marketAddress: Address, sharesAmountHuman: string,) => { const marketState = await readMarketState(publicClient, marketAddress) if (!marketState.canSell) throw new Error("redemption disabled on this market")
const { sell: sellRate } = await fetchGroupFeeRates( publicClient, operator.directoryAddress, marketState.market.groupId, )
const calc = buildLinearMarketCalculator( marketState.market, marketState.netIssuedSupply, ) const exactSharesIn = parseUnits(sharesAmountHuman, marketState.decimals) const sharesInHuman = Fixed18.toString( Fixed18.fromToken(exactSharesIn, marketState.decimals), ) const grossCurveReserve = calc.reservesOutForExactSharesIn( toDecimal(sharesInHuman), ) if (grossCurveReserve === null) { throw new Error("shares in exceed redeemable supply on the curve") }
// Fee on sell: net reserve to the user ≈ gross curve out × (1 − sellRate). const netReserveOut = grossCurveReserve.mul(new Decimal(1).minus(sellRate)) const reserveTokensOut = Fixed18.toTokenDown( Fixed18.fromString(netReserveOut.toString()), marketState.decimals, )
return { exactSharesIn, reserveTokensOut, netReserveOut: netReserveOut.toString(), }}import { useEffect, useState } from "react"import type { Address, EIP1193Provider, PublicClient } from "viem"
import type { OperatorConfig } from "../lib/config"import type { MarketState } from "../lib/read-market-state"import { browserWallet } from "../lib/browser-wallet"import { quoteRedeemWithExactSharesIn } from "../lib/quote-redeem"import { redeemSharesWithExactSharesIn } from "../lib/redeem-shares"import { minOutputAfterSlippage } from "../lib/slippage"import { DetailRows } from "./DetailRows"
export function RedeemSharesForm({ operator, marketAddress, state, publicClient, walletProvider, holder, canSell, onSuccess,}: { operator: OperatorConfig marketAddress: Address state: MarketState publicClient: PublicClient walletProvider: EIP1193Provider | null holder: Address | null canSell: boolean onSuccess: () => Promise<void>}) { const [sharesAmount, setSharesAmount] = useState("1") const [quote, setQuote] = useState<{ reserveTokensOut: bigint netReserveOut: string } | null>(null) const [quoting, setQuoting] = useState(false) const [redeeming, setRedeeming] = useState(false) const [error, setError] = useState<string | null>(null)
useEffect(() => { if (!canSell || sharesAmount.trim() === "") { setQuote(null) return }
let cancelled = false const timer = window.setTimeout(() => { setQuoting(true) setError(null)
void quoteRedeemWithExactSharesIn( publicClient, operator, marketAddress, sharesAmount, ) .then((next) => { if (!cancelled) setQuote(next) }) .catch((cause: unknown) => { if (!cancelled) { setQuote(null) setError(cause instanceof Error ? cause.message : String(cause)) } }) .finally(() => { if (!cancelled) setQuoting(false) }) }, 300)
return () => { cancelled = true window.clearTimeout(timer) } }, [canSell, marketAddress, operator, publicClient, sharesAmount])
async function submitRedeem() { if (!holder || !walletProvider || !canSell) return setRedeeming(true) setError(null) try { const freshQuote = await quoteRedeemWithExactSharesIn( publicClient, operator, marketAddress, sharesAmount, ) setQuote(freshQuote) const minReserveOut = minOutputAfterSlippage(freshQuote.reserveTokensOut) await redeemSharesWithExactSharesIn( marketAddress, state, browserWallet( walletProvider, holder, operator.rpcUrl, operator.chainId, ), publicClient, { sharesHuman: sharesAmount, minReserveOut, }, ) await onSuccess() } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setRedeeming(false) } }
return ( <section className="rounded-sm border border-border bg-card p-5 shadow-md" aria-labelledby="redeem-heading" > <h2 id="redeem-heading" className="mb-5 font-heading text-xl font-semibold text-foreground" > Redeem </h2> <label htmlFor="redeem-amount" className="mb-2 block text-sm font-medium text-muted-foreground" > AVM in </label> <input id="redeem-amount" type="number" min="0" step="any" value={sharesAmount} onChange={(event) => setSharesAmount(event.target.value)} className="mb-4 h-10 w-full rounded-sm border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20" /> <button type="button" disabled={ redeeming || quoting || !holder || !walletProvider || !canSell || !quote } onClick={() => void submitRedeem()} className="w-full rounded-sm bg-primary px-3 py-2.5 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60" > Redeem shares </button> {error ? <pre className="panel error">{error}</pre> : null} {quoting && !quote ? <p className="mt-3 text-sm text-muted-foreground">Updating quote…</p> : null} {quote ? <div className="mt-3"> <DetailRows rows={[ { label: "Reserve out", value: String(quote.reserveTokensOut) }, { label: "Net after fee", value: quote.netReserveOut }, ]} /> </div> : null} </section> )}import type { Address, EIP1193Provider, PublicClient } from "viem"
import type { OperatorConfig } from "../lib/config"import type { MarketState } from "../lib/read-market-state"import { IssueSharesForm } from "./IssueSharesForm"import { RedeemSharesForm } from "./RedeemSharesForm"
/** Issue and redemption side by side — each form owns its own local state. */export function Swap({ operator, marketAddress, state, publicClient, walletProvider, holder, canBuy, canSell, onSuccess,}: { operator: OperatorConfig marketAddress: Address state: MarketState publicClient: PublicClient walletProvider: EIP1193Provider | null holder: Address | null canBuy: boolean canSell: boolean onSuccess: () => Promise<void>}) { return ( <div className="mt-4 grid gap-4 sm:grid-cols-2"> <IssueSharesForm operator={operator} marketAddress={marketAddress} state={state} walletProvider={walletProvider} publicClient={publicClient} holder={holder} canBuy={canBuy} onSuccess={onSuccess} /> <RedeemSharesForm operator={operator} marketAddress={marketAddress} state={state} publicClient={publicClient} walletProvider={walletProvider} holder={holder} canSell={canSell} onSuccess={onSuccess} /> </div> )}import { useCallback, useMemo } from "react"import type { Address, Hex } from "viem"
import { DataCard } from "./components/DataCard"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { Swap } from "./components/Swap"import { UserPosition } from "./components/UserPosition"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useUserPosition } from "./hooks/useUserPosition"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, loading, refresh: refreshMarket, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, connect, provider: walletProvider, } = useWallet(operator.chainId) const { position, refresh: refreshPosition } = useUserPosition( publicClient, MARKET_ADDRESS, address, ) const { balances, refresh: refreshWalletBalances } = useWalletBalances( publicClient, state, address, )
const refreshAll = useCallback(async () => { await refreshMarket() if (address) { await refreshPosition(address) await refreshWalletBalances(address) } }, [address, refreshMarket, refreshPosition, refreshWalletBalances])
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (marketError || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {marketError ? <pre className="panel error">{marketError}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : <p className="text-sm text-muted-foreground"> Loading balances… </p> } </DataCard> </div> : null}
{address && position ? <div className="mt-4"> <UserPosition position={position} /> </div> : null}
<Swap operator={operator} marketAddress={MARKET_ADDRESS} state={state} publicClient={publicClient} walletProvider={walletProvider} holder={address} canBuy={state.canBuy} canSell={state.canSell} onSuccess={refreshAll} /> </div> </main> )}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.
Pledge AVM collateral
Section titled “Pledge AVM collateral”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).
Draw reserve against the floor
Section titled “Draw reserve against the floor”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}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"
/** Draw a cash advance (on-chain: borrow). */export const drawCashAdvance = async ( marketAddress: Address, marketState: MarketState, wallet: BoundWallet, publicClient: PublicClient, reserveAmountHuman: string,) => { const grossDebtIncrease = parseUnits(reserveAmountHuman, marketState.decimals)
return sendPackedTx(wallet, publicClient, { to: marketAddress, data: IMarket.encodeBorrow(grossDebtIncrease), })}import { useState } from "react"import type { Address, EIP1193Provider, PublicClient } from "viem"
import type { OperatorConfig } from "../lib/config"import type { MarketState } from "../lib/read-market-state"import { browserWallet } from "../lib/browser-wallet"import { drawCashAdvance } from "../lib/cash-advance"import { depositCollateral } from "../lib/deposit-collateral"
export function CashAdvanceForm({ operator, marketAddress, marketState, publicClient, walletProvider, holder, canBorrow, onSuccess,}: { operator: OperatorConfig marketAddress: Address marketState: MarketState publicClient: PublicClient walletProvider: EIP1193Provider | null holder: Address | null canBorrow: boolean onSuccess: () => Promise<void>}) { const [collateralAmount, setCollateralAmount] = useState("1") const [advanceAmount, setAdvanceAmount] = useState("0.5") const [busy, setBusy] = useState(false) const [error, setError] = useState<string | null>(null)
async function submitDepositCollateral() { if (!holder || !walletProvider) return setBusy(true) setError(null) try { await depositCollateral( marketAddress, marketState, browserWallet( walletProvider, holder, operator.rpcUrl, operator.chainId, ), publicClient, collateralAmount, ) await onSuccess() } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setBusy(false) } }
async function submitCashAdvance() { if (!holder || !walletProvider || !canBorrow) return setBusy(true) setError(null) try { await drawCashAdvance( marketAddress, marketState, browserWallet( walletProvider, holder, operator.rpcUrl, operator.chainId, ), publicClient, advanceAmount, ) await onSuccess() } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setBusy(false) } }
return ( <section className="mt-4 rounded-sm border border-border bg-card p-5 shadow-md" aria-labelledby="cash-advance-heading" > <h2 id="cash-advance-heading" className="mb-5 font-heading text-xl font-semibold text-foreground" > Cash advance </h2> <label htmlFor="collateral-amount" className="mb-2 block text-sm font-medium text-muted-foreground" > Collateral AVM </label> <input id="collateral-amount" type="number" min="0" step="any" value={collateralAmount} onChange={(event) => setCollateralAmount(event.target.value)} className="mb-4 h-10 w-full rounded-sm border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20" /> <button type="button" disabled={busy || !holder || !walletProvider} onClick={() => void submitDepositCollateral()} className="mb-6 w-full rounded-sm bg-secondary px-3 py-2.5 text-sm font-semibold text-secondary-foreground shadow-sm transition hover:bg-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60" > Deposit collateral </button> <label htmlFor="advance-amount" className="mb-2 block text-sm font-medium text-muted-foreground" > Draw reserve </label> <input id="advance-amount" type="number" min="0" step="any" value={advanceAmount} onChange={(event) => setAdvanceAmount(event.target.value)} className="mb-4 h-10 w-full rounded-sm border border-input bg-background px-3 font-mono text-sm text-foreground outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20" /> <button type="button" disabled={busy || !holder || !walletProvider || !canBorrow} onClick={() => void submitCashAdvance()} className="w-full rounded-sm bg-primary px-3 py-2.5 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60" > Draw cash advance </button> {error ? <pre className="panel error">{error}</pre> : null} </section> )}import { useCallback, useMemo } from "react"import type { Address, Hex } from "viem"
import { CashAdvanceForm } from "./components/CashAdvanceForm"import { DataCard } from "./components/DataCard"import { MarketOverview } from "./components/MarketOverview"import { PageHeader } from "./components/PageHeader"import { Swap } from "./components/Swap"import { UserPosition } from "./components/UserPosition"import { WalletBalances } from "./components/WalletBalances"import { useMarketState } from "./hooks/useMarketState"import { useUserPosition } from "./hooks/useUserPosition"import { useWallet } from "./hooks/useWallet"import { useWalletBalances } from "./hooks/useWalletBalances"import type { OperatorConfig } from "./lib/config"import { makeClients } from "./lib/transport"
const operator: OperatorConfig = { rpcUrl: "http://localhost:8545", chainId: 31337, directoryAddress: "0xYourDirectoryProxy" as Address, tenantId: "0xYourTenantId" as Hex,}
const MARKET_ADDRESS = "0xYourMarketAddress" as Address
function truncateAddress(address: string) { return `${address.slice(0, 6)}…${address.slice(-4)}`}
export default function App() { const { publicClient } = useMemo( () => makeClients(operator.rpcUrl, operator.chainId), [operator.rpcUrl, operator.chainId], ) const { state, error: marketError, loading, refresh: refreshMarket, } = useMarketState(publicClient, MARKET_ADDRESS) const { address, connect, provider: walletProvider, } = useWallet(operator.chainId) const { position, refresh: refreshPosition } = useUserPosition( publicClient, MARKET_ADDRESS, address, ) const { balances, refresh: refreshWalletBalances } = useWalletBalances( publicClient, state, address, )
const refreshAll = useCallback(async () => { await refreshMarket() if (address) { await refreshPosition(address) await refreshWalletBalances(address) } }, [address, refreshMarket, refreshPosition, refreshWalletBalances])
if (loading) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={false} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Loading market state…</p> </div> </main> ) }
if (marketError || !state) { return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} /> <div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <p className="text-muted-foreground">Could not load market state.</p> {marketError ? <pre className="panel error">{marketError}</pre> : null} </div> </main> ) }
return ( <main className="min-h-screen w-full bg-background text-foreground"> <PageHeader connected={!!address} onConnect={() => void connect()} />
<div className="mx-auto max-w-[720px] px-5 py-10 sm:px-8 sm:py-14"> <MarketOverview state={state} />
{address ? <div className="mt-4"> <DataCard title="Your wallet"> <p className="mb-3 font-mono text-sm text-muted-foreground"> {truncateAddress(address)} </p> {balances ? <WalletBalances balances={balances} decimals={state.decimals} /> : <p className="text-sm text-muted-foreground"> Loading balances… </p> } </DataCard> </div> : null}
{address && position ? <div className="mt-4"> <UserPosition position={position} /> <p className="mt-2 text-sm text-muted-foreground"> Floor:{" "} <span className="font-mono font-medium text-foreground"> {state.floorPrice} </span> </p> </div> : null}
<Swap operator={operator} marketAddress={MARKET_ADDRESS} state={state} publicClient={publicClient} walletProvider={walletProvider} holder={address} canBuy={state.canBuy} canSell={state.canSell} onSuccess={refreshAll} />
<CashAdvanceForm operator={operator} marketAddress={MARKET_ADDRESS} marketState={state} publicClient={publicClient} walletProvider={walletProvider} holder={address} canBorrow={state.canBorrow} onSuccess={refreshAll} /> </div> </main> )}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”Install Tailwind v4
Section titled “Install Tailwind v4”Pick the install line for your bundler. All three need tailwindcss and tailwindcss-animate; the PostCSS integration package differs.
npm install --save-dev tailwindcss @tailwindcss/vite tailwindcss-animatepnpm add -D tailwindcss @tailwindcss/vite tailwindcss-animateyarn add -D tailwindcss @tailwindcss/vite tailwindcss-animatebun add -D tailwindcss @tailwindcss/vite tailwindcss-animatenpm install --save-dev tailwindcss @tailwindcss/postcss postcss tailwindcss-animatepnpm add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateyarn add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animatebun add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateUse this path for Webpack, Parcel, or any setup that already runs PostCSS (no Vite or Next.js plugin).
npm install --save-dev tailwindcss @tailwindcss/postcss postcss tailwindcss-animatepnpm add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateyarn add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animatebun add -D tailwindcss @tailwindcss/postcss postcss tailwindcss-animateWire up your bundler
Section titled “Wire up your bundler”Add the Tailwind Vite plugin:
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).
Add a PostCSS config at the project root:
const config = { plugins: { "@tailwindcss/postcss": {}, },}
export default configImport the Mayflower theme in your root layout (App Router):
import type { ReactNode } from "react"
import "./globals.css"
export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> )}Copy the styles.css tab below into app/globals.css (or @import it from there).
Add PostCSS config and point your bundler at it (Webpack, Rspack, etc. usually pick this up automatically):
const config = { plugins: { "@tailwindcss/postcss": {}, },}
export default configImport ./styles.css from your client entry (src/main.tsx, src/index.tsx, or equivalent).
Add the Mayflower theme stylesheet
Section titled “Add the Mayflower theme stylesheet”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);}import { StrictMode } from "react"import { createRoot } from "react-dom/client"
import App from "./App"import "./styles.css"
createRoot(document.getElementById("root")!).render( <StrictMode> <App /> </StrictMode>,)// app/layout.tsx — globals.css holds the styles.css contents aboveimport type { ReactNode } from "react"
import "./globals.css"
export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> )}// src/main.tsx (or src/index.tsx — wherever your app mounts)import { StrictMode } from "react"import { createRoot } from "react-dom/client"
import App from "./App"import "./styles.css"
createRoot(document.getElementById("root")!).render( <StrictMode> <App /> </StrictMode>,)Ensure your bundler runs PostCSS on imported CSS (Webpack: postcss-loader in the CSS rule).
Restart your dev server after adding the stylesheet. Cards should render on a light gray background with pink primary buttons and Fraunces headings.
Error handling checklist
Section titled “Error handling checklist”When there are transaction reverts, surface revert reasons in your UI and offer a suitable UX path for the given failure.
| Failure | Likely cause | UX path |
|---|---|---|
| Insufficient allowance | Missing approve before a pull-based action | Prompt approve, then retry the action |
| Insufficient balance | Wallet holds zero reserve or AVM — fund reserve (prerequisite) and issue before redeeming | Show balances; link to fund / issue first |
| Slippage revert | Quote went stale; widen bound or re-quote | Refresh quote inline; offer retry with updated min out |
| Borrow revert | Collateral × floor cannot cover the draw | Show capacity from position + floor; cap input to max drawable |
| Flag disabled | Market admin turned off the operation | Disable the action; explain which flag is off |
What you should have now
Section titled “What you should have now”After this guide you can:
- Read live market state and the connected user’s cash-advance position
- Quote issuance and redemption client-side with avm-calc before opening a wallet prompt
- Issue and redeem shares through the SDK packers
- Draw cash advances by pledging AVM collateral and calling the on-chain borrow path
- 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.