diff --git a/src/components/Solutions/VaultAdapterRoute/VaultAdapterRoute.astro b/src/components/Solutions/VaultAdapterRoute/VaultAdapterRoute.astro
new file mode 100644
index 00000000000..f867ce239a3
--- /dev/null
+++ b/src/components/Solutions/VaultAdapterRoute/VaultAdapterRoute.astro
@@ -0,0 +1,816 @@
+---
+/**
+ * One deposit or redemption route, drawn in full on three columns (Source chain, Chainlink CCIP,
+ * Hub chain). `mode="manual"` draws the route without the adapter; `mode="adapter"` draws it with
+ * the adapter.
+ *
+ * Usage in MDX:
+ * import VaultAdapterRoute from "@components/Solutions/VaultAdapterRoute/VaultAdapterRoute.astro"
+ * …literal Markdown fallback…
+ * …literal Markdown fallback…
+ *
+ * A native radio group (Deposit | Redeem) reshapes the route, and every other route on the page
+ * switches with it. Numbered stops are buttons that write an inspector line, and the arrows beside
+ * the explanation walk the route in order. Step 1 is selected on the server-rendered first paint,
+ * so the explanation panel and the selected stop are visible at once.
+ *
+ * Styles are global on purpose: the element re-renders badges at runtime. Every class is `vac-`.
+ * Several rules carry three class names so they outrank the article's global list and
+ * max-width rules (PageContent.astro), which would otherwise leak into the drawing.
+ */
+import { DEFAULT_SCENARIO, isMode } from "./vaultAdapterRouteModel.ts"
+import type { Mode, Scenario } from "./vaultAdapterRouteModel.ts"
+import { renderRoute } from "./vaultAdapterRouteView.ts"
+
+interface Props {
+ /** "manual" draws the route without the adapter; "adapter" draws it with the adapter. */
+ mode: Mode
+}
+
+const { mode } = Astro.props
+if (!isMode(mode)) {
+ throw new Error(`VaultAdapterRoute: mode must be "manual" or "adapter", got ${JSON.stringify(mode)}`)
+}
+
+const uid = "vac-" + Math.random().toString(36).slice(2, 9)
+const scenario: Scenario = { flow: DEFAULT_SCENARIO.flow, mode }
+const interactive = renderRoute(uid, scenario)
+---
+
+
+
+ {/* The literal Markdown children: not shown on screen, but kept for print, Copy page and the TXT export. */}
+
+
+
+
+
+
diff --git a/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteElement.ts b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteElement.ts
new file mode 100644
index 00000000000..def13a4ce0d
--- /dev/null
+++ b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteElement.ts
@@ -0,0 +1,225 @@
+import { DEFAULT_SCENARIO, getRoute, isFlow, isMode } from "./vaultAdapterRouteModel.ts"
+import type { Flow, Scenario, Step } from "./vaultAdapterRouteModel.ts"
+import { computeParts, renderInspector } from "./vaultAdapterRouteView.ts"
+
+const TAG = "vault-adapter-route"
+/** Dispatched (bubbling) by the route a reader switches, so every other route on the page follows. */
+const FLOW_EVENT = "vault-adapter-route:flow"
+const SETTLE_MS = 210
+const P = "vac"
+
+/**
+ * One route, without or with the adapter (`data-mode`). A native radio group (Deposit | Redeem)
+ * reshapes it, and the other routes on the page switch with it, so the two diagrams always show
+ * the same action. The route keeps one selected step: the numbered stops are buttons that select
+ * their step, and the arrows beside the explanation move through the route in order.
+ *
+ * The server renders the default state (step 1 selected) from the same pure functions this
+ * element calls, so connectedCallback only binds controls and seeds the memo.
+ */
+export class VaultAdapterRouteElement extends HTMLElement {
+ private scenario: Scenario = DEFAULT_SCENARIO
+ private selected = 0
+ private initialized = false
+ private rendered = new Map()
+ private listeners = new AbortController()
+
+ connectedCallback() {
+ if (this.initialized) return
+ this.initialized = true
+ const { flow, mode } = this.dataset
+ this.scenario = {
+ flow: isFlow(flow) ? flow : DEFAULT_SCENARIO.flow,
+ mode: isMode(mode) ? mode : DEFAULT_SCENARIO.mode,
+ }
+ const { signal } = this.listeners
+
+ this.querySelectorAll('input[data-part="flow"]').forEach((input) => {
+ input.addEventListener(
+ "change",
+ () => {
+ if (input.checked && isFlow(input.value)) this.chooseFlow(input.value)
+ },
+ { signal }
+ )
+ })
+ this.getPart("reset").addEventListener(
+ "click",
+ () => {
+ this.chooseFlow(DEFAULT_SCENARIO.flow)
+ },
+ { signal }
+ )
+ // Another route on the page switched: follow it, without announcing it back.
+ document.addEventListener(
+ FLOW_EVENT,
+ (event) => {
+ const flow = (event as CustomEvent<{ flow?: unknown }>).detail?.flow
+ if (event.target === this || !isFlow(flow) || flow === this.scenario.flow) return
+ this.scenario = { ...this.scenario, flow }
+ this.render()
+ },
+ { signal }
+ )
+ this.getPart("prev").addEventListener(
+ "click",
+ () => {
+ this.select(this.selected - 1)
+ },
+ { signal }
+ )
+ this.getPart("next").addEventListener(
+ "click",
+ () => {
+ this.select(this.selected + 1)
+ },
+ { signal }
+ )
+ this.getPart("track").addEventListener(
+ "click",
+ (event) => {
+ const badge = (event.target as HTMLElement).closest("button[data-step]")
+ if (!badge) return
+ const index = this.steps().findIndex((item) => item.id === badge.dataset.step)
+ if (index >= 0) this.select(index)
+ },
+ { signal }
+ )
+
+ const parts = computeParts(this.scenario)
+ for (const [name, value] of Object.entries(parts.text)) this.rendered.set(`text:${name}`, value)
+ for (const [name, value] of Object.entries(parts.html)) this.rendered.set(`html:${name}`, value)
+ // On a reconnect (the page moves both diagrams into their sections on load) the explanation
+ // still shows the selected step, not step 1, so the memo must match it.
+ const steps = this.steps()
+ const index = Math.min(this.selected, steps.length - 1)
+ this.rendered.set("html:inspector", renderInspector(P, steps[index], index + 1, steps.length))
+ this.syncControls()
+ this.querySelectorAll("input, button").forEach((control) => {
+ control.disabled = false
+ })
+ this.syncSteppers()
+ this.dataset.ready = "true"
+ }
+
+ disconnectedCallback() {
+ this.listeners.abort()
+ this.listeners = new AbortController()
+ this.initialized = false
+ }
+
+ private steps(): Step[] {
+ return getRoute(this.scenario)
+ }
+
+ private getPart(name: string): T {
+ const element = this.querySelector(`[data-part="${name}"]`)
+ if (!element) throw new Error(`${TAG}: missing part ${name}`)
+ return element
+ }
+
+ private writeText(name: string, value: string): boolean {
+ if (this.rendered.get(`text:${name}`) === value) return false
+ this.rendered.set(`text:${name}`, value)
+ this.getPart(name).textContent = value
+ return true
+ }
+
+ private writeHtml(name: string, value: string): boolean {
+ if (this.rendered.get(`html:${name}`) === value) return false
+ this.rendered.set(`html:${name}`, value)
+ this.getPart(name).innerHTML = value
+ return true
+ }
+
+ /** A reader picked Deposit or Redeem here (or pressed Reset): redraw, then tell the other routes. */
+ private chooseFlow(flow: Flow) {
+ this.scenario = { ...this.scenario, flow }
+ this.render()
+ this.dispatchEvent(new CustomEvent(FLOW_EVENT, { bubbles: true, detail: { flow } }))
+ }
+
+ private syncControls() {
+ const { flow } = this.scenario
+ this.dataset.flow = flow
+ this.querySelectorAll("input[type=radio][data-part]").forEach((input) => {
+ input.checked = input.value === flow
+ })
+ }
+
+ private render() {
+ this.syncControls()
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ const parts = computeParts(this.scenario)
+ for (const [name, value] of Object.entries(parts.text)) {
+ if (this.writeText(name, value) && name === "count" && !reducedMotion) this.settle(this.getPart(name))
+ }
+ for (const [name, value] of Object.entries(parts.html)) {
+ if (name === "inspector") continue
+ if (this.writeHtml(name, value) && !reducedMotion) this.settle(this.getPart(name))
+ }
+ this.querySelectorAll(`.${P}-stops button`).forEach((button) => {
+ button.disabled = false
+ })
+ this.select(0)
+ }
+
+ private settle(element: HTMLElement) {
+ element.animate(
+ [
+ { opacity: 0.35, transform: "translateY(2px)" },
+ { opacity: 1, transform: "translateY(0)" },
+ ],
+ {
+ duration: SETTLE_MS,
+ easing: "ease-out",
+ }
+ )
+ }
+
+ private clearActive() {
+ const track = this.getPart("track")
+ track.querySelectorAll("[data-active]").forEach((node) => {
+ delete node.dataset.active
+ delete node.dataset.token
+ })
+ }
+
+ /** Writes the explanation only when it changes, so the live region stays quiet otherwise. */
+ private setInspector(html: string) {
+ if (this.rendered.get("html:inspector") === html) return
+ this.rendered.set("html:inspector", html)
+ this.getPart("inspector").innerHTML = html
+ }
+
+ private syncSteppers() {
+ const last = this.steps().length - 1
+ this.getPart("prev").disabled = this.selected <= 0
+ this.getPart("next").disabled = this.selected >= last
+ }
+
+ /** Selects one step of the route: highlights its node and badge and writes its explanation. */
+ private select(index: number) {
+ const steps = this.steps()
+ const clamped = Math.max(0, Math.min(index, steps.length - 1))
+ this.selected = clamped
+ const step = steps[clamped]
+ const track = this.getPart("track")
+ this.clearActive()
+ const node = track.querySelector(`[data-node="${step.node}"]`)
+ if (node) {
+ node.dataset.active = "true"
+ node.dataset.token = step.token
+ }
+ if (step.number !== undefined) {
+ const badge = track.querySelector(`button[data-step="${step.id}"]`)
+ if (badge) badge.dataset.active = "true"
+ }
+ this.setInspector(renderInspector(P, step, clamped + 1, steps.length))
+ this.syncSteppers()
+ }
+}
+
+export function defineVaultAdapterRoute() {
+ if (!customElements.get(TAG)) customElements.define(TAG, VaultAdapterRouteElement)
+}
diff --git a/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteModel.ts b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteModel.ts
new file mode 100644
index 00000000000..a1df8a03466
--- /dev/null
+++ b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteModel.ts
@@ -0,0 +1,399 @@
+/**
+ * Route model for the Cross-Chain Vault Adapter route diagrams (VaultAdapterRoute.astro).
+ *
+ * Pure functions, no DOM. The diagrams render these facts:
+ *
+ * - A successful, unbatched request from an EVM wallet that uses CCIP directly and
+ * pays CCIP fees in the native gas token. The user already holds the input token
+ * and gas on every chain their wallet signs on, and is the beneficiary.
+ * - "Manual" is the baseline without the adapter: the user bridges the token to
+ * their own hub-chain wallet, calls the vault, and bridges the output back.
+ * - Every approval is drawn and counted (a hollow stop). Router.ccipSend pulls tokens with
+ * safeTransferFrom, so each sender approves the Router it sends through. A wallet whose
+ * allowance already covers the amount skips the hollow stops.
+ * - Wallet network switches, CCIP execution, and the adapter's own calls are not user
+ * transactions, but switches and hub gas are reported as facts.
+ *
+ * Adapter call order follows CrossChainERC4626Adapter.sol at 67c94bc:
+ * ccipReceive -> processMessage -> _processTarget -> safeTransfer or sendToken.
+ */
+
+export type Flow = "deposit" | "redeem"
+export type Mode = "manual" | "adapter"
+export type Token = "asset" | "shares"
+
+export interface Scenario {
+ flow: Flow
+ mode: Mode
+}
+
+/** Where a step happens on the drawing. */
+export type RouteNode = "source-wallet" | "ccip-out" | "hub-wallet" | "adapter" | "vault" | "ccip-back"
+
+export type StepKind = "approval" | "send" | "switch" | "automatic" | "hub-transaction" | "complete"
+
+export interface HubSubstep {
+ letter: string
+ title: string
+ detail: string
+ call: string
+}
+
+export interface Step {
+ id: string
+ kind: StepKind
+ chain: "source" | "transit" | "hub"
+ node: RouteNode
+ /** The token the user or the adapter holds at this step. */
+ token: Token
+ /** Stop badge number. Only signed steps (approval, send) carry one. */
+ number?: number
+ title: string
+ /** At most 20 words. */
+ caption: string
+ /** Contract call shown in inline code in the sequence list. */
+ call?: string
+ /** The step is identical on both routes. */
+ sameOnBothRoutes?: boolean
+ /** Only on hub-transaction steps: what runs inside the one hub-chain transaction. */
+ substeps?: HubSubstep[]
+}
+
+export interface Summary {
+ /** Transactions the user's wallet signs, approvals included. */
+ signatures: number
+ /** Token approvals among the signatures. */
+ approvals: number
+ /** Signatures a wallet with existing allowances submits. */
+ signaturesWithoutApprovals: number
+ /** Distinct chains the wallet signs on. */
+ chainsSigned: number
+ /** Wallet network switches. */
+ switches: number
+ /** Chains the user needs the native gas token on. */
+ gasChains: number
+ /** CCIP messages on the route. */
+ messages: number
+ /** CCIP fees the user pays directly. */
+ ccipFeesPaid: number
+ /** Any configured adapter fee applies (adapter route). */
+ adapterFeeApplies: boolean
+}
+
+export interface StopBadge {
+ number: number
+ hollow: boolean
+ stepId: string
+ title: string
+}
+
+export const FLOWS: Flow[] = ["deposit", "redeem"]
+export const MODES: Mode[] = ["manual", "adapter"]
+
+export const DEFAULT_SCENARIO: Scenario = { flow: "deposit", mode: "manual" }
+
+export function isFlow(value: unknown): value is Flow {
+ return value === "deposit" || value === "redeem"
+}
+
+export function isMode(value: unknown): value is Mode {
+ return value === "manual" || value === "adapter"
+}
+
+export function inputToken(flow: Flow): Token {
+ return flow === "deposit" ? "asset" : "shares"
+}
+
+export function outputToken(flow: Flow): Token {
+ return flow === "deposit" ? "shares" : "asset"
+}
+
+export function tokenLabel(token: Token): string {
+ return token === "asset" ? "asset" : "shares"
+}
+
+/** Ordered steps of one route. Signed steps are numbered from 1 in route order. */
+export function getRoute(scenario: Scenario): Step[] {
+ const { flow, mode } = scenario
+ const input = inputToken(flow)
+ const output = outputToken(flow)
+ const inputWord = tokenLabel(input)
+ const outputWord = tokenLabel(output)
+ const steps: Step[] = []
+ let number = 0
+ const add = (step: Omit) => {
+ const signed = step.kind === "approval" || step.kind === "send"
+ steps.push(signed ? { ...step, number: ++number } : step)
+ }
+
+ add({
+ id: "approve-source-router",
+ kind: "approval",
+ chain: "source",
+ node: "source-wallet",
+ token: input,
+ title: "Approve the source Router",
+ caption: `Approve the source-chain Router to spend your ${inputWord}. Skipped when your allowance already covers the amount.`,
+ call: `approve(sourceRouter, amount)`,
+ sameOnBothRoutes: true,
+ })
+
+ if (mode === "manual") {
+ add({
+ id: "send-to-hub",
+ kind: "send",
+ chain: "source",
+ node: "source-wallet",
+ token: input,
+ title: `Send the ${inputWord} to your hub wallet`,
+ caption: `Send the ${inputWord} through CCIP to your own wallet on the hub chain. You pay the CCIP fee.`,
+ call: `ccipSend(hubChain, { receiver: you, tokenAmounts: [${inputWord}] })`,
+ })
+ add({
+ id: "ccip-out",
+ kind: "automatic",
+ chain: "transit",
+ node: "ccip-out",
+ token: input,
+ title: "Crossing to the hub chain",
+ caption: `CCIP verifies the message and delivers the ${inputWord} to your wallet on the hub chain.`,
+ })
+ add({
+ id: "switch-network",
+ kind: "switch",
+ chain: "hub",
+ node: "hub-wallet",
+ token: input,
+ title: "Switch network and hold hub gas",
+ caption: "Switch your wallet to the hub chain and hold its gas token for the next transactions.",
+ })
+ if (flow === "deposit") {
+ add({
+ id: "approve-vault",
+ kind: "approval",
+ chain: "hub",
+ node: "hub-wallet",
+ token: input,
+ title: "Approve the vault",
+ caption: "Approve the vault to spend the asset. Skipped when your allowance already covers the amount.",
+ call: `approve(vault, amount)`,
+ })
+ }
+ add(
+ flow === "deposit"
+ ? {
+ id: "vault-call",
+ kind: "send",
+ chain: "hub",
+ node: "vault",
+ token: output,
+ title: "Deposit into the vault",
+ caption: "Deposit the asset and receive shares at the vault's exchange rate at execution time.",
+ call: `deposit(assets, you)`,
+ }
+ : {
+ id: "vault-call",
+ kind: "send",
+ chain: "hub",
+ node: "vault",
+ token: output,
+ title: "Redeem your shares",
+ caption: "Redeem as the owner, so no vault approval is needed. The vault sends you the asset.",
+ call: `redeem(shares, you, you)`,
+ }
+ )
+ add({
+ id: "approve-hub-router",
+ kind: "approval",
+ chain: "hub",
+ node: "hub-wallet",
+ token: output,
+ title: "Approve the hub Router",
+ caption: `Approve the hub-chain Router to spend your ${outputWord}. Skipped when your allowance already covers the amount.`,
+ call: `approve(hubRouter, amount)`,
+ })
+ add({
+ id: "send-back",
+ kind: "send",
+ chain: "hub",
+ node: "hub-wallet",
+ token: output,
+ title: `Send the ${outputWord} back`,
+ caption: `Send the ${outputWord} through CCIP to your wallet on the source chain. You pay this second CCIP fee.`,
+ call: `ccipSend(sourceChain, { receiver: you, tokenAmounts: [${outputWord}] })`,
+ })
+ } else {
+ add({
+ id: "send-request",
+ kind: "send",
+ chain: "source",
+ node: "source-wallet",
+ token: input,
+ title: `Send the ${inputWord} and instructions`,
+ caption: `Send the ${inputWord} plus a 128-byte payload to the adapter. You pay the CCIP fee and sign nothing else.`,
+ call: `ccipSend(hubChain, { receiver: adapter, tokenAmounts: [${inputWord}], data: payload })`,
+ })
+ add({
+ id: "ccip-out",
+ kind: "automatic",
+ chain: "transit",
+ node: "ccip-out",
+ token: input,
+ title: "Crossing to the adapter",
+ caption: `CCIP verifies the message, transfers the ${inputWord} to the adapter, and calls ccipReceive.`,
+ })
+ add({
+ id: "hub-transaction",
+ kind: "hub-transaction",
+ chain: "hub",
+ node: "adapter",
+ token: output,
+ title: "One hub-chain transaction",
+ caption:
+ "The adapter does all of this inside the transaction that delivers your message. Nothing here needs your wallet.",
+ call: "ccipReceive",
+ substeps: getHubSubsteps(scenario),
+ })
+ }
+
+ add({
+ id: "ccip-back",
+ kind: "automatic",
+ chain: "transit",
+ node: "ccip-back",
+ token: output,
+ title: "Crossing back to the source chain",
+ caption:
+ mode === "adapter"
+ ? `CCIP verifies the return message and delivers the ${outputWord} to your source-chain wallet. No wallet transaction needed.`
+ : `CCIP verifies the return message and delivers the ${outputWord} to your wallet on the source chain.`,
+ })
+ add({
+ id: "complete",
+ kind: "complete",
+ chain: "source",
+ node: "source-wallet",
+ token: output,
+ // The inspector already prefixes "Done." from the step kind, so the title must not repeat it.
+ title: `${outputWord.charAt(0).toUpperCase()}${outputWord.slice(1)} on the source chain`,
+ caption:
+ mode === "adapter"
+ ? `You hold the ${outputWord} in your wallet on the source chain. Your wallet never left it.`
+ : `You hold the ${outputWord} in your wallet on the source chain, the chain you started on.`,
+ })
+
+ return steps
+}
+
+/** What the adapter does inside the one hub-chain transaction, in contract order. */
+export function getHubSubsteps(scenario: Scenario): HubSubstep[] {
+ const { flow } = scenario
+ const outputWord = tokenLabel(outputToken(flow))
+ const list: Omit[] = []
+
+ list.push({
+ title: "Validate",
+ detail:
+ flow === "deposit"
+ ? "processMessage runs in try/catch: source chain allowed, one token, 128-byte payload, vault allowlisted, amount above zero, token is the vault's asset, deposits enabled."
+ : "processMessage runs in try/catch: source chain allowed, one token, 128-byte payload, vault allowlisted, amount above zero, token is the vault's share token, redemptions enabled.",
+ call: "processMessage",
+ })
+
+ if (flow === "deposit") {
+ list.push({
+ title: "Adapter fee",
+ detail: "Deducts any configured adapter fee from the asset before depositing.",
+ call: "_processTarget",
+ })
+ list.push({
+ title: "Deposit",
+ detail: "Approves the vault and calls deposit. The vault mints the shares to the adapter.",
+ call: "deposit(assets, adapter)",
+ })
+ } else {
+ list.push({
+ title: "Redeem",
+ detail: "Calls redeem as owner and receiver. The vault sends the asset to the adapter.",
+ call: "redeem(shares, adapter, adapter)",
+ })
+ list.push({
+ title: "Adapter fee",
+ detail: "Deducts any configured adapter fee from the redeemed asset.",
+ call: "_processTarget",
+ })
+ }
+
+ list.push({
+ title: "Check the output",
+ detail: `Output must be nonzero and at least minimumOut. Otherwise processMessage reverts with NoOutputReceived or MinimumOutputNotMet, the vault call rolls back, and your tokens become refundable.`,
+ call: "_processTarget",
+ })
+
+ list.push({
+ title: "Send back",
+ detail: `Approves the hub-chain Router and sends the ${outputWord} to the beneficiary named in your payload, on the source chain. The adapter pays this CCIP fee from its native balance.`,
+ call: "sendToken",
+ })
+
+ return list.map((item, index) => ({ ...item, letter: String.fromCharCode(97 + index) }))
+}
+
+export function summarize(scenario: Scenario): Summary {
+ const steps = getRoute(scenario)
+ const signed = steps.filter((step) => step.kind === "approval" || step.kind === "send")
+ const approvals = signed.filter((step) => step.kind === "approval").length
+ const chainsSigned = new Set(signed.map((step) => step.chain)).size
+ const switches = steps.filter((step) => step.kind === "switch").length
+ const messages = 2
+ const ccipFeesPaid = scenario.mode === "manual" ? 2 : 1
+ return {
+ signatures: signed.length,
+ approvals,
+ signaturesWithoutApprovals: signed.length - approvals,
+ chainsSigned,
+ switches,
+ gasChains: chainsSigned,
+ messages,
+ ccipFeesPaid,
+ adapterFeeApplies: scenario.mode === "adapter",
+ }
+}
+
+/** Stop badges per drawing node, in route order. */
+export function getStops(scenario: Scenario): Partial> {
+ const stops: Partial> = {}
+ for (const step of getRoute(scenario)) {
+ if (step.number === undefined) continue
+ const node: RouteNode = step.id === "vault-call" ? "hub-wallet" : step.node
+ ;(stops[node] ??= []).push({
+ number: step.number,
+ hollow: step.kind === "approval",
+ stepId: step.id,
+ title: step.title,
+ })
+ }
+ return stops
+}
+
+function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
+ return `${count} ${count === 1 ? singular : pluralForm}`
+}
+
+/** "2 CCIP messages" */
+export function messagesLabel(summary: Summary): string {
+ return plural(summary.messages, "CCIP message")
+}
+
+/** Fees the user pays or bears directly, as a short phrase. */
+export function feesLabel(summary: Summary): string {
+ return summary.adapterFeeApplies
+ ? `${plural(summary.ccipFeesPaid, "CCIP fee")}, plus any configured adapter fee taken from the asset`
+ : plural(summary.ccipFeesPaid, "CCIP fee")
+}
+
+export function allScenarios(): Scenario[] {
+ const list: Scenario[] = []
+ for (const flow of FLOWS) for (const mode of MODES) list.push({ flow, mode })
+ return list
+}
diff --git a/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteView.ts b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteView.ts
new file mode 100644
index 00000000000..fa9b58d3f8c
--- /dev/null
+++ b/src/components/Solutions/VaultAdapterRoute/vaultAdapterRouteView.ts
@@ -0,0 +1,278 @@
+/**
+ * Markup for VaultAdapterRoute.astro: one route, without or with the adapter, drawn in full
+ * against three columns. Pure HTML strings, so the Astro component (server-rendered first paint)
+ * and the custom element (re-render after a control change) produce identical markup.
+ */
+import {
+ feesLabel,
+ getHubSubsteps,
+ getRoute,
+ getStops,
+ messagesLabel,
+ summarize,
+ tokenLabel,
+} from "./vaultAdapterRouteModel.ts"
+import type { Mode, RouteNode, Scenario, Step, StopBadge } from "./vaultAdapterRouteModel.ts"
+
+export function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+}
+
+/** Inline SVG line icons, 24-unit viewBox, stroked with currentColor by the stylesheet. */
+export const ICONS = {
+ wallet:
+ '',
+ adapter: '',
+ vault:
+ '',
+ switch: '',
+ arrowRight: '',
+ arrowLeft: '',
+}
+
+/**
+ * Stop badge buttons for one drawing node. Filled = a transaction you sign, hollow = a token
+ * approval. The badge for `activeStepId` is drawn selected, so the first paint already shows
+ * that stops can be selected.
+ */
+export function renderStops(prefix: string, badges: StopBadge[] | undefined, activeStepId?: string): string {
+ if (!badges || badges.length === 0) return ""
+ return badges
+ .map((badge) => {
+ const kind = badge.hollow ? "approval" : "send"
+ const what = badge.hollow ? "token approval" : "transaction you sign"
+ const active = badge.stepId === activeStepId ? ' data-active="true"' : ""
+ return ``
+ })
+ .join("")
+}
+
+function describeKind(step: Step): string {
+ switch (step.kind) {
+ case "approval":
+ return "Token approval"
+ case "send":
+ return "You sign"
+ case "switch":
+ return "Wallet action"
+ case "complete":
+ return "Done"
+ default:
+ return "Automatic"
+ }
+}
+
+/** The explanation panel under a row: one step of the route, in reading order. */
+export function renderInspector(prefix: string, step: Step, position: number, total: number): string {
+ const detail =
+ step.substeps && step.substeps.length > 0
+ ? ` In order: ${step.substeps.map((item) => `${item.letter}. ${item.title.toLowerCase()}`).join(", ")}.`
+ : ""
+ return `Step ${position} of ${total}${describeKind(step)}. ${escapeHtml(step.title)}. ${escapeHtml(step.caption)}${escapeHtml(detail)}`
+}
+
+/** The lettered list of what runs inside the adapter's one hub-chain transaction. */
+export function renderMicroList(prefix: string, scenario: Scenario): string {
+ return getHubSubsteps(scenario)
+ .map((item) => `
${item.letter}${escapeHtml(item.title)}
`)
+ .join("")
+}
+
+/** Labels that change with the flow: what goes out, what comes back, the vault link. */
+export function routeLabels(scenario: Scenario) {
+ const input = tokenLabel(scenario.flow === "deposit" ? "asset" : "shares")
+ const output = tokenLabel(scenario.flow === "deposit" ? "shares" : "asset")
+ const summary = summarize(scenario)
+ return {
+ input,
+ output,
+ sourceWalletSub: `${input} out · ${output} back`,
+ outChip: input,
+ backChip: output,
+ backCaption:
+ scenario.mode === "adapter" ? "2nd message · CCIP fee paid by the adapter" : "2nd message · you pay the CCIP fee",
+ vaultLink: `${input} ▸ · ◂ ${output}`,
+ summary,
+ }
+}
+
+export function stopsFor(scenario: Scenario, node: RouteNode): StopBadge[] | undefined {
+ return getStops(scenario)[node]
+}
+
+const P = "vac"
+
+export interface RouteParts {
+ text: Record
+ html: Record
+}
+
+export function routeTitle(mode: Mode): string {
+ return mode === "manual" ? "Without the adapter" : "With the adapter"
+}
+
+/** One line of facts under the signature count, the same shape on both routes: CCIP messages, then fees. */
+export function routeMeta(scenario: Scenario): string {
+ const summary = summarize(scenario)
+ return `${messagesLabel(summary)} · ${feesLabel(summary)}`
+}
+
+/**
+ * Everything in one route that changes with Deposit | Redeem, keyed by `data-part` name. Only the
+ * route without the adapter has `stops-hub`, and only the route with it has `micro`.
+ */
+export function computeParts(scenario: Scenario): RouteParts {
+ const labels = routeLabels(scenario)
+ const summary = labels.summary
+ const steps = getRoute(scenario)
+ const first = steps[0]
+ const text: Record = {
+ count: String(summary.signatures),
+ unit: summary.signatures === 1 ? "signature" : "signatures",
+ meta: routeMeta(scenario),
+ "source-sub": labels.sourceWalletSub,
+ "chip-out": labels.outChip,
+ "chip-back": labels.backChip,
+ "back-caption": labels.backCaption,
+ "vault-out": `${labels.input} ▸`,
+ "vault-back": `◂ ${labels.output}`,
+ }
+ const html: Record = {
+ "stops-source": renderStops(P, stopsFor(scenario, "source-wallet"), first.id),
+ inspector: renderInspector(P, first, 1, steps.length),
+ }
+ if (scenario.mode === "manual") html["stops-hub"] = renderStops(P, stopsFor(scenario, "hub-wallet"))
+ else html.micro = renderMicroList(P, scenario)
+ return { text, html }
+}
+
+function radio(name: string, part: string, value: string, label: string, checked: boolean): string {
+ return ``
+}
+
+function leg(mode: Mode, parts: RouteParts): string {
+ const t = parts.text
+ return `
`
+}
+
+/** The route name sits beside its signature count, and also names the whole diagram (`${uid}-title`). */
+function row(uid: string, parts: RouteParts, scenario: Scenario): string {
+ const { mode } = scenario
+ const t = parts.text
+ const h = parts.html
+ const first = getRoute(scenario)[0]
+ return `
+
+
+
${routeTitle(mode)}
+
${t.count}${t.unit}
+
${escapeHtml(t.meta)}
+
+
+
+
+
+ ${h["stops-source"]}
+ ${ICONS.wallet}
+ Your wallet
+ ${escapeHtml(t["source-sub"])}
+
+
+ ${leg(mode, parts)}
+ ${hubCell(mode, parts)}
+
+
+
+
+
+
+
${h.inspector}
+
+
`
+}
+
+/** Icon key for the marks this route draws. The network-switch entry appears only on a route that switches. */
+function legend(scenario: Scenario): string {
+ const switches = getRoute(scenario).some((step) => step.kind === "switch")
+ const switchEntry = switches ? ` ${ICONS.switch} wallet network switch` : ""
+ return `
1 transaction you sign 1 token approval${switchEntry} second CCIP message
`
+}
+
+/** The interactive region. The host element and the hidden text fallback (the slot) live in the Astro file. */
+export function renderRoute(uid: string, scenario: Scenario): string {
+ const parts = computeParts(scenario)
+ return `
`
+}
diff --git a/src/content/solutions/cross-chain-vault-adapter/index.mdx b/src/content/solutions/cross-chain-vault-adapter/index.mdx
index 4f5dce6a72b..2f2563ccfc0 100644
--- a/src/content/solutions/cross-chain-vault-adapter/index.mdx
+++ b/src/content/solutions/cross-chain-vault-adapter/index.mdx
@@ -3,24 +3,38 @@ title: "Cross-Chain Vault Adapter"
description: "Let users on other CCIP-connected chains deposit into and redeem from your existing ERC-4626 vault through one adapter contract on the vault's hub chain."
---
-import { Aside, ClickToZoom } from "@components"
+import { Aside } from "@components"
+import VaultAdapterRoute from "@components/Solutions/VaultAdapterRoute/VaultAdapterRoute.astro"
## What the adapter does
-With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. The hub chain is the chain where the vault and the adapter are deployed, and a source chain is any other chain that users send deposits and redemptions from. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The vault holds one asset and earns yield. The adapter is a contract on the hub chain that receives tokens and instructions through CCIP and calls the vault for the user.
-
-Without the adapter, a user on a source chain who wants those shares in a vault on the hub chain must:
+
-1. Bridge the asset manually from the source chain to the hub chain,
-2. Switch their wallet to the hub chain,
-3. Get some native gas token on the hub chain,
-4. Approve the vault on the hub chain,
-5. Deposit into the vault on the hub chain, and,
-6. Bridge the shares back to the source chain.
+Right now, without the adapter, users of a De-Fi protocol backed by an ERC-4626 have to go through a multi-step process to deposit funds into, or redeem funds from, a vault deployed on a different chain from the one they currently have funds on.
+With Chainlink's Cross-Chain Vault Adapter, a team can accept deposits and redemptions from users on other chains in a single transaction.
+
+
-With the Chainlink Cross-Chain Vault Adapter on the other hand, the user:
+Without the adapter, a user on a source chain bridges the asset to the hub chain themselves, switches their wallet network, holds the hub chain's gas token, approves and calls the vault, and bridges the shares back.
+With the adapter, they approve the source-chain CCIP Router, unless an allowance already exists, and send one CCIP message from the chain they are on.
-1. Approves the CCIP Router and sends one CCIP message from the source chain.
+A return requires the vault's share token to be a CCIP cross-chain token on both chains, as [Requirements](#requirements) lists.
+Redemptions work the same way, in the reverse direction.
-And that's it. The user doesn't have to do anything else.
+## Deposit or redeem from another chain
+
+The two diagrams below follow the same deposit or redemption from a wallet on a source chain, first without the adapter and then with it. Both assume a successful request, CCIP fees paid in the native gas token, and one transaction per action.
+
+In a nutshell:
+
+1. When you deposit into a 4626 vault, you put in an ERC-20 asset compliant with the vault, and in return, you receive shares, sometimes referred to as vault tokens, or synthetic tokens, that represent your position in the vault.
+2. When you redeem those shares, you get back an equivalent amount of the underlying asset back.
+
+### Without the adapter
+
+After bridging the asset, the user waits for CCIP to deliver it before they can continue on the hub chain. Including token approvals, a deposit takes six signatures on two chains and two CCIP fees, and a redemption takes five signatures.
+
+
+
+The user signs six times, in order:
+
+1. Approves the source-chain Router to spend the asset.
+2. Sends the asset through CCIP to their own wallet on the hub chain.
+3. Once CCIP delivers it, switches the wallet to the hub chain and approves the vault to spend the asset.
+4. Deposits the asset into the vault.
+5. Approves the hub-chain Router to spend the shares.
+6. Sends the shares back to the source chain through CCIP.
+
+Signatures 1, 3, and 5 are token approvals, which the user skips when their allowance already covers the amount. A redemption sends shares out, brings the asset back, and needs no vault approval.
+
+
+
+### With the adapter
+
+A deposit drops from six signatures to two and a redemption from five to two, all on the source chain. In the hub-chain transaction that delivers the user's CCIP message, the adapter calls the vault and sends the output back, so the user never needs gas on the hub chain. The vault team funds the return CCIP fee through the adapter's native gas token balance and can charge a flat adapter fee, taken from the asset.
+
+
-The adapter deposits the asset, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares to the source chain completes the one-message experience: the user never has to touch the hub chain. A return requires the vault's share token to be a CCIP cross-chain token on both chains, as [Requirements](#requirements) lists. Redemptions work the same way, just in the reverse direction. [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) covers each step.
+The user signs twice, both times on the source chain:
-The following diagram shows a deposit from a source chain into a vault on the hub chain, with both delivery options.
+1. Approves the source-chain Router to spend the asset, unless their allowance already covers the amount.
+2. Sends the asset and a 128-byte payload to the adapter through CCIP.
-
+On the hub chain, in one transaction, the adapter validates the request, deducts any configured adapter fee, deposits the asset into the vault, checks the shares against the minimum output, and sends them back to the source chain. For a redemption, the adapter redeems the shares first and then deducts its fee from the asset.
-The repository also contains a separate [multibridge implementation](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/docs/multibridge/README.md) for vaults whose asset or share token moves over other cross-chain protocols. These docs cover the CCIP implementation.
+
+
+> The repository also contains a separate [multibridge implementation](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/docs/multibridge/README.md) for vaults whose asset or share token moves over other cross-chain protocols. These docs cover the CCIP implementation.
## Who it's for
-- **Vault teams**, such as issuers, protocols, and curators, that run an [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault on one chain and want deposits and redemptions from users on other chains connected by CCIP.
+- **Vault teams**, such as issuers, protocols, and curators, that run an [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault on one chain and want deposits and redemptions from users on other chains connected by CCIP to become seamless.
- **Integrators** who build deposit and redeem interfaces on source chains. They encode the payload and send the CCIP message, and deploy no contracts.
-The contract also supports Solana source chains. These docs cover EVM source chains.
-
-Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vaults need a fork; see [Use it as-is or customize it](#use-it-as-is-or-customize-it). An asynchronous vault that accepts synchronous ERC-4626 deposits needs the fork only for redemptions: deposits work with the adapter as-is.
+Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vaults need a fork; see [Use it as-is or customize it](#use-it-as-is-or-customize-it).
## Requirements
-The vault's **asset** is the token users deposit and receive when they redeem. **Shares** represent their position in the vault. Which tokens need CCIP support depends on where users send tokens from and where they want to receive the result:
+Only tokens that cross chains need CCIP support. Which tokens those are depends on where users send tokens from and where they want to receive the result:
| If you want users to... | Tokens that must be CCIP-enabled |
| ------------------------------------------------------------------------ | -------------------------------- |
@@ -73,7 +117,9 @@ Each token that crosses chains must be configured as a [CCIP cross-chain token (
**If your vault's asset is not CCIP-enabled, you can still offer redemptions from another chain with delivery on the hub chain.** Users send their CCIP-enabled shares to the adapter, and the adapter redeems them and delivers the asset locally. The asset does not cross chains in this flow.
-To enable cross-chain transfers of shares, the share token typically uses Lock & Release on the hub chain, because only the vault may mint shares, and Burn & Mint on source chains. See the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry).
+To enable cross-chain transfers of shares, the share token typically uses Lock & Release on the hub chain, because only the vault may mint shares, and Burn & Mint on source chains.
+
+> See the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry) for more details.
Your vault and deployment must also meet these requirements:
@@ -98,7 +144,11 @@ Your vault and deployment must also meet these requirements:
## What you deploy and operate
-You deploy one adapter on the hub chain with one call to `deploy` on the `CrossChainERC4626AdapterFactory`. That transaction sets the accepted source chains, allowlists your vault when you pass its address and enable it, sets the deposit and redeem switches and the adapter fees, and grants the admin, fee setter, and fee collector roles to your accounts. The factory then renounces all of its own roles. The [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract) reference lists the factory's deployed addresses and its inputs. One adapter can allowlist several vaults on the same chain, and each payload names the vault it targets.
+> You deploy one adapter on the hub chain with one call to `deploy` on the `CrossChainERC4626AdapterFactory`, and that's it.
+
+That transaction sets the accepted source chains, allowlists your vault when you pass its address and enable it, sets the deposit and redeem switches and the adapter fees, and grants the admin, fee setter, and fee collector roles to your accounts.
+The factory then renounces all of its own roles. The [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract) reference lists the factory's deployed addresses and its inputs.
+One adapter can allowlist several vaults on the same chain, and each payload names the vault it targets.
A lane's version is defined as the version of the CCIP OnRamp and OffRamp contracts that serve it, and the [CCIP Directory](/ccip/directory) shows it. The factory does not apply CCIP 2.0 lane settings or fund the adapter, so after deployment you:
@@ -106,7 +156,7 @@ A lane's version is defined as the version of the CCIP OnRamp and OffRamp contra
- Optionally set the inbound finality and [Cross-Chain Verifier (CCV)](/ccip/concepts/ccvs/overview) settings for each CCIP 2.0 source chain.
- Fund the adapter with the native gas token for return legs.
-Your team holds the three roles on the adapter:
+The [deployment guide](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter) covers configuration and funding. Your team holds the three roles on the adapter:
|
Role
| Controls |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -123,6 +173,18 @@ You also monitor the `MessageFailed` event. A failed request leaves its tokens i
responsibility](/ccip/concepts/service-responsibility).
+## Design choices
+
+**Vault accounting stays on the hub chain.** Every deposit and redemption executes against the vault on the hub chain, with no copy of the vault or its share price elsewhere. Each request waits for CCIP delivery and uses the vault's exchange rate at execution, not at send time. A [minimum output](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection) bounds the difference, measured after the adapter fee.
+
+**No contracts on source chains.** Users send a standard CCIP message, and the adapter has no sender allowlist, so any address on an enabled source chain can use it. Nothing checks the payload before the tokens reach the hub chain, and a malformed payload can strand tokens permanently. Build it as described in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends).
+
+**Processing failures are held, not reverted.** When processing reverts, for example in the vault call, the minimum output check, or the return send, the adapter rolls back the vault call, keeps the inbound tokens, and records a failed message. The [CCIP Explorer](https://ccip.chain.link) still shows Success, so track `MessageFailed` instead. Nothing is retried or refunded automatically.
+
+**Permissionless cross-chain refund, optional local recovery.** Anyone can call `refundFailedMessage` to return the tokens to the original sender on the source chain, and the caller pays the CCIP fee in the native gas token. Refunds go to the original sender, not the beneficiary. The payload's local refund address can call `recoverFailedMessageLocally` on the hub chain instead; that address must be set before sending. Either way, recovery is a hub-chain action. See [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery).
+
+**Fees.** Local delivery has no adapter fee. When output returns to the source chain, the user pays any configured adapter fee in asset units, and the adapter pays the return leg's CCIP fee from its native gas token balance. [Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees) explains how fees are charged and funded, and [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations) covers the remaining trade-offs.
+
## Use it as-is or customize it
Use the adapter as-is through the factory when your vault meets the [requirements](#requirements). Otherwise, fork the [repository](https://github.com/smartcontractkit/cross-chain-vault-adapters) and edit [`CrossChainERC4626Adapter.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol). The contract has no virtual hooks for processing, so you change `processMessage` and `_processTarget` directly. Common reasons to fork are asynchronous vaults (typically for the redemption path), multi-asset vaults, targets that are not ERC-4626 vaults, custom payload encoding, and custom validation of the source chain, sender, or beneficiary.
@@ -135,18 +197,7 @@ A fork must keep these safety properties:
- Address conversion checks reject malformed addresses, such as a beneficiary whose upper 96 bits are not zero.
- The failure handler (the `catch` path) must not revert on any input, because a revert there strands the tokens, as described in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path).
-The contracts in `src/ccip` are audited. A fork is not the audited code and needs its own review and its own deployment, because the factory deploys only the original adapter.
-
-## Design choices
-
-| Choice | What it means | Trade-off |
-| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Vault accounting stays on the hub chain | Every deposit and redemption executes against the vault on the hub chain. No copy of the vault or its share price runs elsewhere. | Each request waits for CCIP delivery and uses the vault's exchange rate at execution, not at send time. A [minimum output](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection) bounds the difference. |
-| No contracts on source chains | Users send a standard CCIP message. The adapter has no sender allowlist, so any address on an enabled source chain can use it. | Nothing checks the payload before the tokens reach the hub chain, and a malformed payload can strand tokens permanently. Build it as described in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). |
-| Processing failures are held, not reverted | When processing reverts, for example in the vault call, the minimum output check, or the return send, the adapter keeps the tokens and records a failed message. | The [CCIP Explorer](https://ccip.chain.link) still shows Success, so track `MessageFailed` instead. Nothing is retried or refunded automatically. |
-| Permissionless cross-chain refund, optional local recovery | Anyone can call `refundFailedMessage` to return the tokens to the original sender on the source chain. The payload's local refund address can call `recoverFailedMessageLocally` instead. | The refund caller pays the CCIP fee in the native gas token. Refunds go to the original sender, not the beneficiary. Local recovery needs a local refund address set before sending. See [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). |
-
-[Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees) explains the adapter fee, and [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations) covers the remaining trade-offs.
+The contracts in `src/ccip` are audited. A fork is not the audited code and needs its own review and its own deployment, because the factory deploys only the original adapter. [Customizing the adapter](/solutions/cross-chain-vault-adapter/reference/limitations#customizing-the-adapter) covers the changes and safety checks.
## Where to go next
diff --git a/src/content/solutions/cross-chain-vault-adapter/llms-full.txt b/src/content/solutions/cross-chain-vault-adapter/llms-full.txt
index c22f426ba6a..9a7ccf46cb2 100644
--- a/src/content/solutions/cross-chain-vault-adapter/llms-full.txt
+++ b/src/content/solutions/cross-chain-vault-adapter/llms-full.txt
@@ -13,20 +13,29 @@ cross-chain, vaults
## What the adapter does
-With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. The hub chain is the chain where the vault and the adapter are deployed, and a source chain is any other chain that users send deposits and redemptions from. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The vault holds one asset and earns yield. The adapter is a contract on the hub chain that receives tokens and instructions through CCIP and calls the vault for the user.
+> **NOTE: ERC-4626?**
+>
+> These docs assume familiarity with the ERC-4626 standard. Please refer to
+> [ethereum.org](https://ethereum.org/developers/docs/standards/tokens/erc-4626/) for more details.
-Without the adapter, a user on a source chain who wants those shares in a vault on the hub chain must:
+Right now, without the adapter, users of a De-Fi protocol backed by an ERC-4626 have to go through a multi-step process to deposit funds into, or redeem funds from, a vault deployed on a different chain from the one they currently have funds on.
+With Chainlink's Cross-Chain Vault Adapter, a team can accept deposits and redemptions from users on other chains in a single transaction.
-1. Bridge the asset manually from the source chain to the hub chain,
-2. Switch their wallet to the hub chain,
-3. Get some native gas token on the hub chain,
-4. Approve the vault on the hub chain,
-5. Deposit into the vault on the hub chain, and,
-6. Bridge the shares back to the source chain.
+> **NOTE: Key Terms You Need to Know**
+>
+> 1. **Hub Chain:** The chain where the vault and the adapter are deployed.
+> 2. **Source Chain:** Any other chain that users send deposits and redemptions from.
+> 3. **Vault:** An ERC-4626 contract. Holds the assets and earns yield for the user.
+> 4. **Adapter:** A Chainlink contract, deployed on the hub chain, that receives tokens and instructions through CCIP
+> and calls the vault for the user.
+> 5. **Asset:** The token users deposit and receive back when they redeem.
+> 6. **Shares**: Represent a user's position in the vault.
-With the Chainlink Cross-Chain Vault Adapter on the other hand, the user:
+Without the adapter, a user on a source chain bridges the asset to the hub chain themselves, switches their wallet network, holds the hub chain's gas token, approves and calls the vault, and bridges the shares back.
+With the adapter, they approve the source-chain CCIP Router, unless an allowance already exists, and send one CCIP message from the chain they are on.
-1. Approves the CCIP Router and sends one CCIP message from the source chain.
+A return requires the vault's share token to be a CCIP cross-chain token on both chains, as [Requirements](#requirements) lists.
+Redemptions work the same way, in the reverse direction.
> **NOTE: Message payload**
>
@@ -36,28 +45,53 @@ With the Chainlink Cross-Chain Vault Adapter on the other hand, the user:
> 2. A 128-byte payload that names: the vault, the beneficiary, the minimum output, the delivery option, and a local
> refund address (in case the message fails on delivery and a refund is needed).
-And that's it. The user doesn't have to do anything else.
+## Deposit or redeem from another chain
+
+The two diagrams below follow the same deposit or redemption from a wallet on a source chain, first without the adapter and then with it. Both assume a successful request, CCIP fees paid in the native gas token, and one transaction per action.
+
+In a nutshell:
+
+1. When you deposit into a 4626 vault, you put in an ERC-20 asset compliant with the vault, and in return, you receive shares, sometimes referred to as vault tokens, or synthetic tokens, that represent your position in the vault.
+2. When you redeem those shares, you get back an equivalent amount of the underlying asset back.
+
+### Without the adapter
+
+After bridging the asset, the user waits for CCIP to deliver it before they can continue on the hub chain. Including token approvals, a deposit takes six signatures on two chains and two CCIP fees, and a redemption takes five signatures.
-The adapter deposits the asset, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares to the source chain completes the one-message experience: the user never has to touch the hub chain. A return requires the vault's share token to be a CCIP cross-chain token on both chains, as [Requirements](#requirements) lists. Redemptions work the same way, just in the reverse direction. [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) covers each step.
+The user signs six times, in order:
-The following diagram shows a deposit from a source chain into a vault on the hub chain, with both delivery options.
+1. Approves the source-chain Router to spend the asset.
+2. Sends the asset through CCIP to their own wallet on the hub chain.
+3. Once CCIP delivers it, switches the wallet to the hub chain and approves the vault to spend the asset.
+4. Deposits the asset into the vault.
+5. Approves the hub-chain Router to spend the shares.
+6. Sends the shares back to the source chain through CCIP.
-
+Signatures 1, 3, and 5 are token approvals, which the user skips when their allowance already covers the amount. A redemption sends shares out, brings the asset back, and needs no vault approval.
-The repository also contains a separate [multibridge implementation](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/docs/multibridge/README.md) for vaults whose asset or share token moves over other cross-chain protocols. These docs cover the CCIP implementation.
+### With the adapter
+
+A deposit drops from six signatures to two and a redemption from five to two, all on the source chain. In the hub-chain transaction that delivers the user's CCIP message, the adapter calls the vault and sends the output back, so the user never needs gas on the hub chain. The vault team funds the return CCIP fee through the adapter's native gas token balance and can charge a flat adapter fee, taken from the asset.
+
+The user signs twice, both times on the source chain:
+
+1. Approves the source-chain Router to spend the asset, unless their allowance already covers the amount.
+2. Sends the asset and a 128-byte payload to the adapter through CCIP.
+
+On the hub chain, in one transaction, the adapter validates the request, deducts any configured adapter fee, deposits the asset into the vault, checks the shares against the minimum output, and sends them back to the source chain. For a redemption, the adapter redeems the shares first and then deducts its fee from the asset.
+
+> The repository also contains a separate [multibridge implementation](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/docs/multibridge/README.md) for vaults whose asset or share token moves over other cross-chain protocols. These docs cover the CCIP implementation.
## Who it's for
-- **Vault teams**, such as issuers, protocols, and curators, that run an [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault on one chain and want deposits and redemptions from users on other chains connected by CCIP.
+- **Vault teams**, such as issuers, protocols, and curators, that run an [ERC-4626](https://eips.ethereum.org/EIPS/eip-4626) vault on one chain and want deposits and redemptions from users on other chains connected by CCIP to become seamless.
- **Integrators** who build deposit and redeem interfaces on source chains. They encode the payload and send the CCIP message, and deploy no contracts.
-The contract also supports Solana source chains. These docs cover EVM source chains.
-
-Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vaults need a fork; see [Use it as-is or customize it](#use-it-as-is-or-customize-it). An asynchronous vault that accepts synchronous ERC-4626 deposits needs the fork only for redemptions: deposits work with the adapter as-is.
+Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vaults need a fork; see [Use it as-is or customize it](#use-it-as-is-or-customize-it).
## Requirements
-The vault's **asset** is the token users deposit and receive when they redeem. **Shares** represent their position in the vault. Which tokens need CCIP support depends on where users send tokens from and where they want to receive the result:
+Only tokens that cross chains need CCIP support. Which tokens those are depends on where users send tokens from and where they want to receive the result:
| If you want users to... | Tokens that must be CCIP-enabled |
| ------------------------------------------------------------------------ | -------------------------------- |
@@ -70,7 +104,9 @@ Each token that crosses chains must be configured as a [CCIP cross-chain token (
**If your vault's asset is not CCIP-enabled, you can still offer redemptions from another chain with delivery on the hub chain.** Users send their CCIP-enabled shares to the adapter, and the adapter redeems them and delivers the asset locally. The asset does not cross chains in this flow.
-To enable cross-chain transfers of shares, the share token typically uses Lock & Release on the hub chain, because only the vault may mint shares, and Burn & Mint on source chains. See the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry).
+To enable cross-chain transfers of shares, the share token typically uses Lock & Release on the hub chain, because only the vault may mint shares, and Burn & Mint on source chains.
+
+> See the [Lock & Mint tutorial](/ccip/evm/tutorials/cross-chain-tokens/register-from-eoa-lock-mint-foundry) for more details.
Your vault and deployment must also meet these requirements:
@@ -88,7 +124,11 @@ Your vault and deployment must also meet these requirements:
## What you deploy and operate
-You deploy one adapter on the hub chain with one call to `deploy` on the `CrossChainERC4626AdapterFactory`. That transaction sets the accepted source chains, allowlists your vault when you pass its address and enable it, sets the deposit and redeem switches and the adapter fees, and grants the admin, fee setter, and fee collector roles to your accounts. The factory then renounces all of its own roles. The [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract) reference lists the factory's deployed addresses and its inputs. One adapter can allowlist several vaults on the same chain, and each payload names the vault it targets.
+> You deploy one adapter on the hub chain with one call to `deploy` on the `CrossChainERC4626AdapterFactory`, and that's it.
+
+That transaction sets the accepted source chains, allowlists your vault when you pass its address and enable it, sets the deposit and redeem switches and the adapter fees, and grants the admin, fee setter, and fee collector roles to your accounts.
+The factory then renounces all of its own roles. The [Factory contract](/solutions/cross-chain-vault-adapter/reference/factory-contract) reference lists the factory's deployed addresses and its inputs.
+One adapter can allowlist several vaults on the same chain, and each payload names the vault it targets.
A lane's version is defined as the version of the CCIP OnRamp and OffRamp contracts that serve it, and the [CCIP Directory](/ccip/directory) shows it. The factory does not apply CCIP 2.0 lane settings or fund the adapter, so after deployment you:
@@ -96,7 +136,7 @@ A lane's version is defined as the version of the CCIP OnRamp and OffRamp contra
- Optionally set the inbound finality and [Cross-Chain Verifier (CCV)](/ccip/concepts/ccvs/overview) settings for each CCIP 2.0 source chain.
- Fund the adapter with the native gas token for return legs.
-Your team holds the three roles on the adapter:
+The [deployment guide](/solutions/cross-chain-vault-adapter/guides/deploy-the-adapter) covers configuration and funding. Your team holds the three roles on the adapter:
| Role | Controls |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -113,6 +153,18 @@ You also monitor the `MessageFailed` event. A failed request leaves its tokens i
> Chainlink does not deploy, operate, or control adapters. See [CCIP service
> responsibility](/ccip/concepts/service-responsibility).
+## Design choices
+
+**Vault accounting stays on the hub chain.** Every deposit and redemption executes against the vault on the hub chain, with no copy of the vault or its share price elsewhere. Each request waits for CCIP delivery and uses the vault's exchange rate at execution, not at send time. A [minimum output](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection) bounds the difference, measured after the adapter fee.
+
+**No contracts on source chains.** Users send a standard CCIP message, and the adapter has no sender allowlist, so any address on an enabled source chain can use it. Nothing checks the payload before the tokens reach the hub chain, and a malformed payload can strand tokens permanently. Build it as described in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends).
+
+**Processing failures are held, not reverted.** When processing reverts, for example in the vault call, the minimum output check, or the return send, the adapter rolls back the vault call, keeps the inbound tokens, and records a failed message. The [CCIP Explorer](https://ccip.chain.link) still shows Success, so track `MessageFailed` instead. Nothing is retried or refunded automatically.
+
+**Permissionless cross-chain refund, optional local recovery.** Anyone can call `refundFailedMessage` to return the tokens to the original sender on the source chain, and the caller pays the CCIP fee in the native gas token. Refunds go to the original sender, not the beneficiary. The payload's local refund address can call `recoverFailedMessageLocally` on the hub chain instead; that address must be set before sending. Either way, recovery is a hub-chain action. See [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery).
+
+**Fees.** Local delivery has no adapter fee. When output returns to the source chain, the user pays any configured adapter fee in asset units, and the adapter pays the return leg's CCIP fee from its native gas token balance. [Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees) explains how fees are charged and funded, and [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations) covers the remaining trade-offs.
+
## Use it as-is or customize it
Use the adapter as-is through the factory when your vault meets the [requirements](#requirements). Otherwise, fork the [repository](https://github.com/smartcontractkit/cross-chain-vault-adapters) and edit [`CrossChainERC4626Adapter.sol`](https://github.com/smartcontractkit/cross-chain-vault-adapters/blob/main/src/ccip/CrossChainERC4626Adapter.sol). The contract has no virtual hooks for processing, so you change `processMessage` and `_processTarget` directly. Common reasons to fork are asynchronous vaults (typically for the redemption path), multi-asset vaults, targets that are not ERC-4626 vaults, custom payload encoding, and custom validation of the source chain, sender, or beneficiary.
@@ -125,18 +177,7 @@ A fork must keep these safety properties:
- Address conversion checks reject malformed addresses, such as a beneficiary whose upper 96 bits are not zero.
- The failure handler (the `catch` path) must not revert on any input, because a revert there strands the tokens, as described in [Failures with no recovery path](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery#failures-with-no-recovery-path).
-The contracts in `src/ccip` are audited. A fork is not the audited code and needs its own review and its own deployment, because the factory deploys only the original adapter.
-
-## Design choices
-
-| Choice | What it means | Trade-off |
-| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Vault accounting stays on the hub chain | Every deposit and redemption executes against the vault on the hub chain. No copy of the vault or its share price runs elsewhere. | Each request waits for CCIP delivery and uses the vault's exchange rate at execution, not at send time. A [minimum output](/solutions/cross-chain-vault-adapter/overview/how-it-works#slippage-protection) bounds the difference. |
-| No contracts on source chains | Users send a standard CCIP message. The adapter has no sender allowlist, so any address on an enabled source chain can use it. | Nothing checks the payload before the tokens reach the hub chain, and a malformed payload can strand tokens permanently. Build it as described in [What a user sends](/solutions/cross-chain-vault-adapter/overview/how-it-works#what-a-user-sends). |
-| Processing failures are held, not reverted | When processing reverts, for example in the vault call, the minimum output check, or the return send, the adapter keeps the tokens and records a failed message. | The [CCIP Explorer](https://ccip.chain.link) still shows Success, so track `MessageFailed` instead. Nothing is retried or refunded automatically. |
-| Permissionless cross-chain refund, optional local recovery | Anyone can call `refundFailedMessage` to return the tokens to the original sender on the source chain. The payload's local refund address can call `recoverFailedMessageLocally` instead. | The refund caller pays the CCIP fee in the native gas token. Refunds go to the original sender, not the beneficiary. Local recovery needs a local refund address set before sending. See [Failures and recovery](/solutions/cross-chain-vault-adapter/overview/failures-and-recovery). |
-
-[Fees](/solutions/cross-chain-vault-adapter/overview/how-it-works#fees) explains the adapter fee, and [Limitations](/solutions/cross-chain-vault-adapter/reference/limitations) covers the remaining trade-offs.
+The contracts in `src/ccip` are audited. A fork is not the audited code and needs its own review and its own deployment, because the factory deploys only the original adapter. [Customizing the adapter](/solutions/cross-chain-vault-adapter/reference/limitations#customizing-the-adapter) covers the changes and safety checks.
## Where to go next
diff --git a/src/lib/markdown/transformMarkdown.ts b/src/lib/markdown/transformMarkdown.ts
index 79246b6de0b..a673cc3a976 100644
--- a/src/lib/markdown/transformMarkdown.ts
+++ b/src/lib/markdown/transformMarkdown.ts
@@ -323,7 +323,7 @@ export async function transformMarkdown(
if (
(node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") &&
- ["Fragment", "GitHubCards", "OverviewWrapper"].includes((node as MdxJsxNode).name ?? "")
+ ["Fragment", "GitHubCards", "OverviewWrapper", "VaultAdapterRoute"].includes((node as MdxJsxNode).name ?? "")
) {
return handleFragment(node as MdxJsxNode, parent, index)
}
diff --git a/src/scripts/check-markdown-fidelity.ts b/src/scripts/check-markdown-fidelity.ts
index 0d8b8b9efd1..3c023a5b2cc 100644
--- a/src/scripts/check-markdown-fidelity.ts
+++ b/src/scripts/check-markdown-fidelity.ts
@@ -159,6 +159,7 @@ const containerElements: Record = {
Fragment: true,
GitHubCards: true,
OverviewWrapper: true,
+ VaultAdapterRoute: true,
ol: true,
li: true,
}
diff --git a/src/utils/solutionLlms.test.ts b/src/utils/solutionLlms.test.ts
index d8fd88b89dc..dd38343e311 100644
--- a/src/utils/solutionLlms.test.ts
+++ b/src/utils/solutionLlms.test.ts
@@ -99,7 +99,7 @@ describe("solution llms-full.txt", () => {
expect(document).toContain("What the adapter does")
expect(document).toContain("```solidity")
expect(document).toContain("Message payload")
- expect(document).toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg")
+ expect(document).not.toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg")
expect(document).not.toMatch(/^import /m)
expect(document).not.toContain("