diff --git a/common/calculate-swap3.ts b/common/calculate-swap3.ts
new file mode 100644
index 00000000..4e648d75
--- /dev/null
+++ b/common/calculate-swap3.ts
@@ -0,0 +1,260 @@
+type Swap3LiquidityPosition = {
+  // TODO: Record who added this stuff?
+
+  // Not sure if this is needed; maybe YES and NO left
+  // amount: number // M$ quantity
+
+  // For now, only support YES and NO outcome tokens
+  // TODO: replace with Outcome
+  // Hm, is this...
+  // 1. Number of shares left in this particular pool?
+  // 2. Fixed at injection time?
+  pool: { YES: number; NO: number }
+
+  // Uniswap uses 0.01, 0.003, 0.0005. Let's stick with 0.003 for now.
+  // fee: number
+
+  // Min/max is expressed as a odds ratio of cost of YES to cost of NO
+  // E.g. ratio of 1 = 1:1 = 50%; ratio of 3 = 3:1 = 75%
+  // minRatio: number
+  // maxRatio: number
+  minTick: number
+  // minTick = loq_sqrt_1.0001(sqrtRatio)
+  // sqrt(1.0001)^(minTick) = sqrtRatio
+  // minRatio = 1.0001^minTick
+  // e.g. minTick = 20k => 7.3883
+  maxTick: number
+}
+
+type TickState = {
+  tick: number
+
+  // Amount of liquidity added when crossing this tick from left to right
+  // Negative if we should remove liquidity
+  liquidityNet: number
+
+  // Total liquidity referencing this pool
+  liquidityGross: number
+}
+
+// From https://uniswap.org/whitepaper-v3.pdf
+export type Swap3Pool = {
+  // id: string
+  // userId: string
+  // contractId: string
+  // createdTime: number
+
+  // 6.2 Global State
+  liquidity: number // = sqrt(NY)
+  // sqrtRatio: number // = sqrt(N / Y); N = # NO shares in pool
+  // So N = liquidity * sqrtRatio; Y = liquidity / sqrtRatio
+
+  // Current tick number.
+  // Stored as optimization. equal to floor(log_sqrt_1.0001(sqrtRatio))
+  tick: number
+  // TODO add fees?
+
+  // Mapping of tick indices to tick values.
+  tickStates: TickState[]
+}
+
+export function noShares(pool: Swap3Pool) {
+  return pool.liquidity * toRatio(pool.tick) ** 0.5
+}
+
+export function yesShares(pool: Swap3Pool) {
+  return pool.liquidity / toRatio(pool.tick) ** 0.5
+}
+
+export function getSwap3Probability(pool: Swap3Pool) {
+  // Probability is given by N / (N + Y)
+  // const N = pool.liquidity * pool.sqrtRatio
+  // const Y = pool.liquidity / pool.sqrtRatio
+  // return N / (N + Y)
+
+  // To check: this should be equal to toProb(pool.tick)?
+  return toProb(pool.tick)
+}
+
+function calculatePurchase(
+  pool: Swap3Pool,
+  amount: number, // In M$
+  outcome: 'YES' | 'NO'
+) {}
+
+export function calculateLPCost(
+  curTick: number,
+  minTick: number,
+  maxTick: number,
+  deltaL: number
+) {
+  // TODO: this is subtly wrong, because of rounding between curTick and sqrtPrice
+  // Also below in buyYES
+  const upperTick = Math.min(maxTick, Math.max(minTick, curTick))
+  const costN = toRatio(upperTick) ** 0.5 - toRatio(minTick) ** 0.5
+
+  const lowerTick = Math.max(minTick, Math.min(maxTick, curTick))
+  const costY = 1 / toRatio(lowerTick) ** 0.5 - 1 / toRatio(maxTick) ** 0.5
+
+  return {
+    requiredN: deltaL * costN,
+    requiredY: deltaL * costY,
+  }
+}
+
+// Returns a preview of the new pool + number of YES shares purchased.
+// Does NOT modify the pool
+// Hm, logic is pretty complicated. Let's see if we can simplify this.
+export function buyYes(
+  pool: Swap3Pool,
+  amount: number // In M$
+) {
+  const tickStates = sortedTickStates(pool)
+  let tick = pool.tick
+  let stateIndex = 0
+  let amountLeft = amount
+  let yesPurchased = 0
+  // Stop if there's epsilon M$ left, due to rounding issues
+  while (amountLeft > 1e-6) {
+    // Find the current & next states for this tick
+    while (tick >= tickStates[stateIndex + 1].tick) {
+      stateIndex++
+      if (stateIndex > tickStates.length - 2) {
+        // We've reached the end of the tick states...
+        throw new Error('Ran out of tick states')
+      }
+    }
+    const state = tickStates[stateIndex]
+    const nextState = tickStates[stateIndex + 1]
+
+    // nextState.tick purchases through the bucket; fullTick uses the remaining amountLeft
+    const fullCostN = amountLeft / state.liquidityGross
+    // Note: fullTick is NOT floored here; it's for the sqrtPrice to buy up to
+    const fullTick = fromRatioUnfloored((fullCostN + toRatio(tick) ** 0.5) ** 2)
+    const nextTick = Math.min(nextState.tick, fullTick)
+
+    // Copied from above; TODO extract to common function?
+    const noCost = toRatio(nextTick) ** 0.5 - toRatio(tick) ** 0.5
+    const yesCost = 1 / toRatio(tick) ** 0.5 - 1 / toRatio(nextTick) ** 0.5
+
+    amountLeft -= noCost * state.liquidityGross
+    yesPurchased += yesCost * state.liquidityGross
+    tick = Math.floor(nextTick)
+  }
+
+  // Right now we eat the epsilon amounntLeft as a fee. Could return it, shrug.
+  return {
+    newPoolTick: tick,
+    yesPurchased,
+  }
+}
+
+// Currently, this mutates the pool. Should it return a new object instead?
+export function addPosition(
+  pool: Swap3Pool,
+  minTick: number,
+  maxTick: number,
+  deltaL: number
+) {
+  const { requiredN, requiredY } = calculateLPCost(
+    pool.tick,
+    minTick,
+    maxTick,
+    deltaL
+  )
+  // console.log(`Deducting required N: ${requiredN} and required Y: ${requiredY}`)
+
+  // Add liquidity as we pass through the smaller tick
+  const minTickState = pool.tickStates[minTick] || {
+    tick: minTick,
+    liquidityNet: 0,
+    liquidityGross: 0,
+  }
+
+  minTickState.liquidityNet += deltaL
+  pool.tickStates[minTick] = minTickState
+
+  // And remove it as we pass through the larger one
+  const maxTickState = pool.tickStates[maxTick] || {
+    tick: maxTick,
+    liquidityNet: 0,
+    liquidityGross: 0,
+  }
+
+  maxTickState.liquidityNet -= deltaL
+  pool.tickStates[maxTick] = maxTickState
+
+  return pool
+}
+
+export function addBalancer(pool: Swap3Pool, p: number, deltaL: number) {
+  // TODO: math is borked, shouldn't be returning infinity
+  function tickL(tick: number) {
+    const q = 1 - p
+    return deltaL * 2 * p ** q * q ** p * 1.0001 ** ((p - 0.5) * tick)
+  }
+
+  // See how much liquidity is provided at +/- 5pp around p
+  // const minTick = fromProb(p - 0.1)
+  // const maxTick = fromProb(p + 0.05)
+  const minTick = fromProb(0.000001)
+  const maxTick = fromProb(0.999999)
+  let totalN = 0
+  let totalY = 0
+  const stride = 300
+  for (let t = minTick; t <= maxTick; t += stride) {
+    // console.log('liquidity at tick ', t, toProb(t), tickL(t))
+    const { requiredN, requiredY } = calculateLPCost(
+      fromProb(p),
+      t,
+      t + stride,
+      tickL(t)
+    )
+    totalN += requiredN
+    totalY += requiredY
+    // Add liquidity
+    addPosition(pool, t, t + stride, tickL(t))
+  }
+
+  console.log('rough number of ticks', (maxTick - minTick) / stride)
+  console.log(`Total N: ${totalN} and total Y: ${totalY}`)
+  grossLiquidity(pool)
+  return pool
+}
+
+// This also mutates the pool directly
+export function grossLiquidity(pool: Swap3Pool) {
+  let liquidityGross = 0
+  for (const tickState of sortedTickStates(pool)) {
+    liquidityGross += tickState.liquidityNet
+    tickState.liquidityGross = liquidityGross
+  }
+  return pool
+}
+
+export function sortedTickStates(pool: Swap3Pool) {
+  return Object.values(pool.tickStates).sort((a, b) => a.tick - b.tick)
+}
+
+function toRatio(tick: number) {
+  return 1.0001 ** tick
+}
+
+export function toProb(tick: number) {
+  const ratio = toRatio(tick)
+  return ratio / (ratio + 1)
+}
+
+// Returns the tick for a given probability from 0 to 1
+export function fromProb(prob: number) {
+  const ratio = prob / (1 - prob)
+  return fromRatio(ratio)
+}
+
+function fromRatio(ratio: number) {
+  return Math.floor(Math.log(ratio) / Math.log(1.0001))
+}
+
+function fromRatioUnfloored(ratio: number) {
+  return Math.log(ratio) / Math.log(1.0001)
+}
diff --git a/web/components/contract/liquidity-graph.tsx b/web/components/contract/liquidity-graph.tsx
new file mode 100644
index 00000000..38f422ad
--- /dev/null
+++ b/web/components/contract/liquidity-graph.tsx
@@ -0,0 +1,121 @@
+import { CartesianMarkerProps, DatumValue } from '@nivo/core'
+import { Point, ResponsiveLine } from '@nivo/line'
+import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
+import { memo } from 'react'
+import { range } from 'lodash'
+import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
+import { NumericContract } from '../../../common/contract'
+import { useWindowSize } from '../../hooks/use-window-size'
+import { Col } from '../layout/col'
+import { formatLargeNumber, formatPercent } from 'common/util/format'
+
+export type GraphPoint = {
+  // A probability between 0 and 1
+  x: number
+  // Amount of liquidity
+  y: number
+}
+
+export const LiquidityGraph = memo(function NumericGraph(props: {
+  min?: 0
+  max?: 1
+  points: GraphPoint[]
+  height?: number
+  marker?: number // Value between min and max to highlight on x-axis
+  previewMarker?: number
+}) {
+  const { height, min, max, points, marker, previewMarker } = props
+
+  // Really maxLiquidity
+  const maxLiquidity = 500
+  const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
+
+  const yTickValues = [
+    0,
+    0.25 * maxLiquidity,
+    0.5 * maxLiquidity,
+    0.75 * maxLiquidity,
+    maxLiquidity,
+  ]
+
+  const { width } = useWindowSize()
+
+  const numXTickValues = !width || width < 800 ? 2 : 5
+
+  const markers: CartesianMarkerProps<DatumValue>[] = []
+  if (marker) {
+    markers.push({
+      axis: 'x',
+      value: marker,
+      lineStyle: { stroke: '#000', strokeWidth: 2 },
+      legend: `Implied: ${formatPercent(marker)}`,
+    })
+  }
+  if (previewMarker) {
+    markers.push({
+      axis: 'x',
+      value: previewMarker,
+      lineStyle: { stroke: '#8888', strokeWidth: 2 },
+    })
+  }
+
+  return (
+    <div
+      className="w-full overflow-hidden"
+      style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
+    >
+      <ResponsiveLine
+        data={data}
+        yScale={{ min: 0, max: maxLiquidity, type: 'linear' }}
+        yFormat={formatLiquidity}
+        axisLeft={{
+          tickValues: yTickValues,
+          format: formatLiquidity,
+        }}
+        xScale={{
+          type: 'linear',
+          min: min,
+          max: max,
+        }}
+        xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
+        axisBottom={{
+          tickValues: numXTickValues,
+          format: (d) => `${formatLargeNumber(+d, 3)}`,
+        }}
+        colors={{ datum: 'color' }}
+        pointSize={0}
+        enableSlices="x"
+        sliceTooltip={({ slice }) => {
+          const point = slice.points[0]
+          return <Tooltip point={point} />
+        }}
+        enableGridX={!!width && width >= 800}
+        enableArea
+        margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
+        markers={markers}
+      />
+    </div>
+  )
+})
+
+function formatLiquidity(y: DatumValue) {
+  const p = Math.round(+y * 100) / 100
+  return `${p}L`
+}
+
+function Tooltip(props: { point: Point }) {
+  const { point } = props
+  return (
+    <Col className="border border-gray-300 bg-white py-2 px-3">
+      <div
+        className="pb-1"
+        style={{
+          color: point.serieColor,
+        }}
+      >
+        <strong>{point.serieId}</strong> {point.data.yFormatted}
+      </div>
+      <div>{formatLargeNumber(+point.data.x)}</div>
+    </Col>
+  )
+}
diff --git a/web/pages/swap.tsx b/web/pages/swap.tsx
new file mode 100644
index 00000000..e0158f67
--- /dev/null
+++ b/web/pages/swap.tsx
@@ -0,0 +1,269 @@
+import {
+  addBalancer,
+  addPosition,
+  buyYes,
+  calculateLPCost,
+  fromProb,
+  getSwap3Probability,
+  grossLiquidity,
+  noShares,
+  sortedTickStates,
+  Swap3Pool,
+  toProb,
+  yesShares,
+} from 'common/calculate-swap3'
+import { formatPercent } from 'common/util/format'
+import { useState } from 'react'
+import { LiquidityGraph } from 'web/components/contract/liquidity-graph'
+import { Col } from 'web/components/layout/col'
+import { Row } from 'web/components/layout/row'
+import { addLiquidity } from 'web/lib/firebase/fn-call'
+
+const users: Record<string, any> = {
+  alice: {
+    M: 100,
+    YES: 0,
+    NO: 0,
+  },
+  bob: {
+    M: 200,
+    YES: 0,
+    NO: 0,
+  },
+  kipply: {
+    M: 300,
+    YES: 0,
+    NO: 0,
+  },
+}
+
+function BalanceTable() {
+  /* Display all users current M, YES, and NO in a table */
+  return (
+    <table className="w-full">
+      <thead>
+        <tr>
+          <th className="px-4 py-2">User</th>
+          <th className="px-4 py-2">M</th>
+          <th className="px-4 py-2">YES</th>
+          <th className="px-4 py-2">NO</th>
+        </tr>
+      </thead>
+      <tbody>
+        {Object.keys(users).map((user) => (
+          <tr key={user}>
+            <td className="px-4 py-2">{user}</td>
+            <td className="px-4 py-2">{users[user].M}</td>
+            <td className="px-4 py-2">{users[user].YES}</td>
+            <td className="px-4 py-2">{users[user].NO}</td>
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  )
+}
+
+/* Show the values in pool */
+function PoolTable(props: { pool: Swap3Pool }) {
+  const { pool } = props
+  return (
+    <>
+      <Row className="gap-4">
+        <div>
+          <label>Implied: </label>
+          {formatPercent(getSwap3Probability(pool))}
+        </div>
+
+        <div>
+          <label>Liquidity: </label>
+          {pool.liquidity}
+        </div>
+        <div>
+          <label>Tick: </label>
+          {pool.tick}
+        </div>
+        <div>
+          <label>Pool YES: </label>
+          {yesShares(pool).toFixed(2)}
+        </div>
+        <div>
+          <label>Pool NO: </label>
+          {noShares(pool).toFixed(2)}
+        </div>
+      </Row>
+      {/* Render each tickState as another row in a table */}
+      <table className="w-full">
+        <thead>
+          <tr>
+            <th className="px-4 py-2">Tick</th>
+            <th className="px-4 py-2">Prob</th>
+            <th className="px-4 py-2">Net Liquidity</th>
+            <th className="px-4 py-2">Gross Liquidity</th>
+          </tr>
+        </thead>
+        {sortedTickStates(pool).map((tickState, i) => (
+          <tr key={i}>
+            <td className="px-4 py-2">{tickState.tick}</td>
+            <td className="px-4 py-2">
+              {formatPercent(toProb(tickState.tick))}
+            </td>
+            <td className="px-4 py-2">{tickState.liquidityNet}</td>
+            <td className="px-4 py-2">{tickState.liquidityGross}</td>
+          </tr>
+        ))}
+      </table>
+    </>
+  )
+}
+
+function Graph(props: { pool: Swap3Pool; previewMarker?: number }) {
+  const { pool, previewMarker } = props
+  const points = []
+  let lastGross = 0
+  for (const tickState of sortedTickStates(pool)) {
+    const { tick, liquidityGross } = tickState
+    points.push({ x: toProb(tick), y: lastGross })
+    points.push({ x: toProb(tick), y: liquidityGross })
+    lastGross = liquidityGross
+  }
+  return (
+    <LiquidityGraph
+      points={points}
+      marker={toProb(pool.tick)}
+      previewMarker={previewMarker}
+    />
+  )
+}
+
+function LiquidityPanel(props: {
+  pool: Swap3Pool
+  setPool: (pool: Swap3Pool) => void
+}) {
+  const { pool, setPool } = props
+  const [minTick, setMinTick] = useState(0)
+  const [maxTick, setMaxTick] = useState(0)
+  const [deltaL, setDeltaL] = useState(100)
+
+  const { requiredN, requiredY } = calculateLPCost(
+    pool.tick,
+    minTick,
+    maxTick,
+    deltaL
+  )
+
+  return (
+    <Col>
+      <h2 className="my-2 text-xl">Add liquidity</h2>
+      {/* <input className="input" placeholder="Amount" type="number" /> */}
+      <Row className="gap-2">
+        <input
+          className="input"
+          placeholder="Min%"
+          type="number"
+          onChange={(e) => setMinTick(inputPercentToTick(e))}
+        />
+        {/* Min Tick: {minTick} */}
+        <input
+          className="input"
+          placeholder="Max%"
+          type="number"
+          onChange={(e) => setMaxTick(inputPercentToTick(e))}
+        />
+        {/* Max Tick: {maxTick} */}
+        <input
+          className="input"
+          placeholder="delta Liquidity"
+          type="number"
+          value={deltaL}
+          onChange={(e) => setDeltaL(parseFloat(e.target.value))}
+        />
+      </Row>
+      <Row className="gap-2 py-2">
+        <div>Y required: {requiredY.toFixed(2)}</div>
+        <div>N required: {requiredN.toFixed(2)}</div>{' '}
+      </Row>
+      <button
+        className="btn"
+        onClick={() => {
+          addPosition(pool, minTick, maxTick, deltaL)
+          grossLiquidity(pool)
+          setPool({ ...pool })
+        }}
+      >
+        Create pool
+      </button>
+    </Col>
+  )
+}
+
+export default function Swap() {
+  // Set up an initial pool with 100 liquidity from 0% to 100%
+  // TODO: Not sure why maxTick of 2**23 breaks it, but 2**20 is okay...
+  let INIT_POOL: Swap3Pool = {
+    liquidity: 0,
+    tick: fromProb(0.3),
+    tickStates: [],
+  }
+  INIT_POOL = addPosition(INIT_POOL, -(2 ** 23), 2 ** 20, 1)
+  // INIT_POOL = addPosition(INIT_POOL, fromProb(0.32), fromProb(0.35), 100)
+  INIT_POOL = addBalancer(INIT_POOL, 0.3, 100)
+  INIT_POOL = grossLiquidity(INIT_POOL)
+
+  const [pool, setPool] = useState(INIT_POOL)
+  const [buyAmount, setBuyAmount] = useState(0)
+
+  const { newPoolTick, yesPurchased } = buyYes(pool, buyAmount)
+
+  return (
+    <Col className="mx-auto max-w-2xl gap-10 p-4">
+      {/* <BalanceTable /> */}
+      {/* <PoolTable pool={pool} /> */}
+      <Graph
+        pool={pool}
+        previewMarker={
+          newPoolTick === pool.tick ? undefined : toProb(newPoolTick)
+        }
+      />
+      <input
+        className="input"
+        placeholder="Current%"
+        type="number"
+        onChange={(e) => {
+          pool.tick = inputPercentToTick(e)
+          setPool({ ...pool })
+        }}
+      />
+
+      <LiquidityPanel pool={pool} setPool={setPool} />
+
+      <Col>
+        <h2 className="my-2 text-xl">Limit Order</h2>
+        TODO
+      </Col>
+
+      <Col>
+        <h2 className="my-2 text-xl">Buy Shares</h2>
+        {/* <input className="input" placeholder="User" type="text" /> */}
+        <input
+          className="input"
+          placeholder="Amount"
+          type="number"
+          onChange={(e) => setBuyAmount(parseFloat(e.target.value))}
+        />
+        <Row className="gap-2 py-2">
+          <div>Y shares purchaseable: {yesPurchased.toFixed(2)}</div>
+          <div>New Tick: {newPoolTick}</div>
+          <div>New prob: {formatPercent(toProb(newPoolTick))}</div>
+        </Row>
+        <Row className="gap-2">
+          <button className="btn">Buy YES</button>
+          {/* <button className="btn">Buy NO</button> */}
+        </Row>
+      </Col>
+    </Col>
+  )
+}
+
+function inputPercentToTick(event: React.ChangeEvent<HTMLInputElement>) {
+  return fromProb(parseFloat(event.target.value) / 100)
+}