From 256ac5380f8ab62ad7ad64aca88219b820847b6d Mon Sep 17 00:00:00 2001 From: Priyank Gupta Date: Mon, 19 Oct 2026 01:06:05 +0530 Subject: [PATCH 1/7] chore: Trying something new with a new diagram, first iteration --- .../VaultAdapterComparison.astro | 855 ++++++++++++++++++ .../vaultAdapterComparisonElement.ts | 276 ++++++ .../vaultAdapterComparisonModel.ts | 460 ++++++++++ .../vaultAdapterComparisonView.ts | 290 ++++++ .../cross-chain-vault-adapter/index.mdx | 92 +- .../cross-chain-vault-adapter/llms-full.txt | 82 +- src/lib/markdown/transformMarkdown.ts | 4 +- src/scripts/check-markdown-fidelity.ts | 1 + src/utils/solutionLlms.test.ts | 1 + 9 files changed, 1996 insertions(+), 65 deletions(-) create mode 100644 src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro create mode 100644 src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonElement.ts create mode 100644 src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonModel.ts create mode 100644 src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonView.ts diff --git a/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro b/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro new file mode 100644 index 00000000000..c18cc1da917 --- /dev/null +++ b/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro @@ -0,0 +1,855 @@ +--- +/** + * Deposit and redemption routes with and without the adapter, both drawn in full, one above + * the other, on the same three columns (Source chain, Chainlink CCIP, Hub chain). + * + * Usage in MDX: + * import VaultAdapterComparison from "@components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro" + * …literal Markdown fallback… + * + * Two native radio groups (Deposit | Redeem, where the result is delivered) reshape both rows at + * once. Numbered stops are buttons that write an inspector line. "Trace the route" walks one row + * on demand; nothing autoplays and the two rows never race. Step 1 of each row 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 } from "./vaultAdapterComparisonModel.ts" +import { renderRouteRows } from "./vaultAdapterComparisonView.ts" + +const uid = "vac-" + Math.random().toString(36).slice(2, 9) +const scenario = DEFAULT_SCENARIO +const interactive = renderRouteRows(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/VaultAdapterComparison/vaultAdapterComparisonElement.ts b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonElement.ts new file mode 100644 index 00000000000..6daef3760d6 --- /dev/null +++ b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonElement.ts @@ -0,0 +1,276 @@ +import { DEFAULT_SCENARIO, describeComparison, getRoute, isDelivery, isFlow } from "./vaultAdapterComparisonModel.ts" +import type { Mode, Scenario, Step } from "./vaultAdapterComparisonModel.ts" +import { ICONS, ROW_MODES, computeParts, escapeHtml, renderInspector } from "./vaultAdapterComparisonView.ts" + +const TAG = "vault-adapter-comparison" +const TRACE_STEP_MS = 1300 +const SETTLE_MS = 210 +const P = "vac" + +/** + * Both routes drawn at once. Two native radio groups reshape both rows. Each row keeps one + * selected step: the numbered stops are buttons that select their step, the arrows beside the + * explanation move through the route in order, and "Trace the route" walks it on a timer. + * Nothing autoplays, nothing races: only one row traces at a time. + * + * The server renders the default state (step 1 selected in both rows) from the same pure + * functions this element calls, so connectedCallback only binds controls and seeds the memo. + */ +export class VaultAdapterComparisonElement extends HTMLElement { + private scenario: Scenario = { flow: DEFAULT_SCENARIO.flow, mode: "manual", delivery: DEFAULT_SCENARIO.delivery } + private selected: Record = { manual: 0, adapter: 0 } + private initialized = false + private rendered = new Map() + private trace: { mode: Mode; index: number; timer: number } | null = null + private listeners = new AbortController() + + connectedCallback() { + if (this.initialized) return + this.initialized = true + const { flow, delivery } = this.dataset + this.scenario = { + flow: isFlow(flow) ? flow : DEFAULT_SCENARIO.flow, + mode: "manual", + delivery: isDelivery(delivery) ? delivery : DEFAULT_SCENARIO.delivery, + } + const { signal } = this.listeners + + this.querySelectorAll('input[data-part="flow"], input[data-part="delivery"]').forEach((input) => { + input.addEventListener( + "change", + () => { + if (!input.checked) return + if (input.dataset.part === "flow" && isFlow(input.value)) + this.scenario = { ...this.scenario, flow: input.value } + if (input.dataset.part === "delivery" && isDelivery(input.value)) { + this.scenario = { ...this.scenario, delivery: input.value } + } + this.stopTrace() + this.render() + }, + { signal } + ) + }) + this.getPart("reset").addEventListener( + "click", + () => { + this.scenario = { ...this.scenario, flow: DEFAULT_SCENARIO.flow, delivery: DEFAULT_SCENARIO.delivery } + this.stopTrace() + this.render() + }, + { signal } + ) + for (const mode of ROW_MODES) { + this.getPart(`trace-${mode}`).addEventListener( + "click", + () => { + if (this.trace?.mode === mode) this.stopTrace() + else this.startTrace(mode) + }, + { signal } + ) + this.getPart(`prev-${mode}`).addEventListener( + "click", + () => { + this.stopTrace() + this.select(mode, this.selected[mode] - 1) + }, + { signal } + ) + this.getPart(`next-${mode}`).addEventListener( + "click", + () => { + this.stopTrace() + this.select(mode, this.selected[mode] + 1) + }, + { signal } + ) + this.getPart(`track-${mode}`).addEventListener( + "click", + (event) => { + const badge = (event.target as HTMLElement).closest("button[data-step]") + if (!badge) return + this.stopTrace() + const index = this.rowSteps(mode).findIndex((item) => item.id === badge.dataset.step) + if (index >= 0) this.select(mode, 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) + this.syncControls() + this.querySelectorAll("input, button").forEach((control) => { + control.disabled = false + }) + for (const mode of ROW_MODES) this.syncSteppers(mode) + this.dataset.ready = "true" + } + + disconnectedCallback() { + this.stopTrace() + this.listeners.abort() + this.listeners = new AbortController() + this.initialized = false + } + + private rowSteps(mode: Mode): Step[] { + return getRoute({ ...this.scenario, mode }) + } + + 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 + } + + private syncControls() { + const { flow, delivery } = this.scenario + this.dataset.flow = flow + this.dataset.delivery = delivery + this.querySelectorAll("input[type=radio][data-part]").forEach((input) => { + const current = input.dataset.part === "flow" ? flow : input.dataset.part === "delivery" ? delivery : "" + input.checked = input.value === current + }) + } + + 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.startsWith("count-") && !reducedMotion) this.settle(this.getPart(name)) + } + for (const [name, value] of Object.entries(parts.html)) { + if (name.startsWith("inspector-")) continue + if (this.writeHtml(name, value) && !reducedMotion) this.settle(this.getPart(name)) + } + this.querySelectorAll(`.${P}-stops button`).forEach((button) => { + button.disabled = false + }) + for (const mode of ROW_MODES) this.select(mode, 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(mode: Mode) { + const track = this.getPart(`track-${mode}`) + 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(mode: Mode, html: string) { + if (this.rendered.get(`html:inspector-${mode}`) === html) return + this.rendered.set(`html:inspector-${mode}`, html) + this.getPart(`inspector-${mode}`).innerHTML = html + } + + private syncSteppers(mode: Mode) { + const last = this.rowSteps(mode).length - 1 + this.getPart(`prev-${mode}`).disabled = this.selected[mode] <= 0 + this.getPart(`next-${mode}`).disabled = this.selected[mode] >= last + } + + /** Selects one step of a row: highlights its node and badge and writes its explanation. */ + private select(mode: Mode, index: number) { + const steps = this.rowSteps(mode) + const clamped = Math.max(0, Math.min(index, steps.length - 1)) + this.selected[mode] = clamped + const step = steps[clamped] + const track = this.getPart(`track-${mode}`) + this.clearActive(mode) + 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(mode, renderInspector(P, step, clamped + 1, steps.length)) + this.syncSteppers(mode) + } + + private startTrace(mode: Mode) { + this.stopTrace() + const steps = this.rowSteps(mode) + const inspector = this.getPart(`inspector-${mode}`) + // One polite announcement at the start and one at the end; the per-step text is visual only. + this.setInspector(mode, `Tracing ${steps.length} steps.`) + const advance = () => { + if (!this.trace || this.trace.mode !== mode) return + if (inspector.getAttribute("aria-live") !== "off") inspector.setAttribute("aria-live", "off") + if (this.trace.index >= steps.length) { + this.finishTrace(mode) + return + } + this.select(mode, this.trace.index) + this.trace.index += 1 + this.trace.timer = window.setTimeout(advance, TRACE_STEP_MS) + } + this.trace = { mode, index: 0, timer: 0 } + this.setTraceButton(mode, true) + this.trace.timer = window.setTimeout(advance, 0) + } + + private finishTrace(mode: Mode) { + const row: Scenario = { ...this.scenario, mode } + const inspector = this.getPart(`inspector-${mode}`) + inspector.setAttribute("aria-live", "polite") + this.setInspector(mode, `Done. ${escapeHtml(describeComparison(row))}`) + this.trace = null + this.setTraceButton(mode, false) + } + + private stopTrace() { + if (!this.trace) return + window.clearTimeout(this.trace.timer) + const { mode } = this.trace + this.trace = null + this.getPart(`inspector-${mode}`).setAttribute("aria-live", "polite") + this.setTraceButton(mode, false) + this.select(mode, this.selected[mode]) + } + + private setTraceButton(mode: Mode, running: boolean) { + const button = this.getPart(`trace-${mode}`) + button.dataset.running = String(running) + this.getPart(`trace-label-${mode}`).textContent = running ? "Stop" : "Trace the route" + this.getPart(`trace-icon-${mode}`).innerHTML = running ? ICONS.stop : ICONS.play + } +} + +export function defineVaultAdapterComparison() { + if (!customElements.get(TAG)) customElements.define(TAG, VaultAdapterComparisonElement) +} diff --git a/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonModel.ts b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonModel.ts new file mode 100644 index 00000000000..3263eb9a67f --- /dev/null +++ b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonModel.ts @@ -0,0 +1,460 @@ +/** + * Route model for the Cross-Chain Vault Adapter comparison widget (VaultAdapterComparison.astro). + * + * Pure functions, no DOM. The widget renders 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 optionally 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 Delivery = "source" | "hub" +export type Token = "asset" | "shares" + +export interface Scenario { + flow: Flow + mode: Mode + delivery: Delivery +} + +/** Where a step happens on the drawing. */ +export type RouteNode = "source-wallet" | "ccip-out" | "hub-wallet" | "adapter" | "vault" | "ccip-back" | "receiver" + +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, output returned to the source chain). */ + 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 DELIVERIES: Delivery[] = ["source", "hub"] + +export const DEFAULT_SCENARIO: Scenario = { flow: "deposit", mode: "manual", delivery: "source" } + +export function isFlow(value: unknown): value is Flow { + return value === "deposit" || value === "redeem" +} + +export function isDelivery(value: unknown): value is Delivery { + return value === "source" || value === "hub" +} + +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, delivery } = 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)`, + } + ) + if (delivery === "source") { + 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), + }) + } + + if (delivery === "source") { + 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, + title: `Done: ${outputWord} 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.`, + }) + } else if (mode === "manual") { + add({ + id: "complete", + kind: "complete", + chain: "hub", + node: "hub-wallet", + token: output, + title: `Done: ${outputWord} on the hub chain`, + caption: `You hold the ${outputWord} in the hub-chain wallet that called the vault.`, + }) + } else { + add({ + id: "complete", + kind: "complete", + chain: "hub", + node: "receiver", + token: output, + title: `Done: ${outputWord} on the hub chain`, + caption: `The beneficiary named in your payload holds the ${outputWord} on the hub chain. No adapter fee applies.`, + }) + } + + return steps +} + +/** What the adapter does inside the one hub-chain transaction, in contract order. */ +export function getHubSubsteps(scenario: Scenario): HubSubstep[] { + const { flow, delivery } = 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") { + if (delivery === "source") { + list.push({ + title: "Adapter fee", + detail: + "Deducts any configured adapter fee from the asset before depositing. Charged only when the output returns to the source chain.", + 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)", + }) + if (delivery === "source") { + list.push({ + title: "Adapter fee", + detail: + "Deducts any configured adapter fee from the redeemed asset. Charged only when the output returns to the source chain.", + 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", + }) + + if (delivery === "source") { + 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", + }) + } else { + list.push({ + title: "Deliver", + detail: `Transfers the ${outputWord} to the beneficiary named in your payload, on the hub chain.`, + call: "safeTransfer", + }) + } + + 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 = scenario.delivery === "source" ? 2 : 1 + const ccipFeesPaid = scenario.mode === "manual" ? messages : 1 + return { + signatures: signed.length, + approvals, + signaturesWithoutApprovals: signed.length - approvals, + chainsSigned, + switches, + gasChains: chainsSigned, + messages, + ccipFeesPaid, + adapterFeeApplies: scenario.mode === "adapter" && scenario.delivery === "source", + } +} + +/** 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}` +} + +/** "(3 approvals)" */ +export function approvalsLabel(summary: Summary): string { + return `(${plural(summary.approvals, "approval")})` +} + +/** 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") +} + +/** Short fact lines for a route card: signatures, chains, gas and messages. */ +export function cardLines(summary: Summary): string[] { + return [ + `${plural(summary.signatures, "signature")} ${approvalsLabel(summary)}`, + `${plural(summary.chainsSigned, "chain")} · ${ + summary.switches === 0 ? "no network switch" : plural(summary.switches, "network switch", "network switches") + }`, + `gas on ${plural(summary.gasChains, "chain")} · ${plural(summary.messages, "CCIP message")}`, + ] +} + +/** One sentence that states the comparison for the live region. */ +export function describeComparison(scenario: Scenario): string { + const mine = summarize(scenario) + const other = summarize({ ...scenario, mode: scenario.mode === "manual" ? "adapter" : "manual" }) + const subject = scenario.mode === "manual" ? "Without the adapter" : "With the adapter" + const otherSubject = scenario.mode === "manual" ? "with the adapter" : "without it" + return `${subject}, your wallet signs ${plural(mine.signatures, "time")} on ${plural(mine.chainsSigned, "chain")}; ${otherSubject}, ${plural(other.signatures, "time")} on ${plural(other.chainsSigned, "chain")}.` +} + +export function allScenarios(): Scenario[] { + const list: Scenario[] = [] + for (const flow of FLOWS) + for (const mode of MODES) for (const delivery of DELIVERIES) list.push({ flow, mode, delivery }) + return list +} diff --git a/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonView.ts b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonView.ts new file mode 100644 index 00000000000..bba02ea5048 --- /dev/null +++ b/src/components/Solutions/VaultAdapterComparison/vaultAdapterComparisonView.ts @@ -0,0 +1,290 @@ +/** + * Markup for VaultAdapterComparison.astro: both routes drawn in full, one above the other, + * against the same 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 { + approvalsLabel, + cardLines, + feesLabel, + getHubSubsteps, + getRoute, + getStops, + summarize, + tokenLabel, +} from "./vaultAdapterComparisonModel.ts" +import type { Mode, RouteNode, Scenario, Step, StopBadge } from "./vaultAdapterComparisonModel.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: + '', + receiver: + '', + switch: '', + arrowRight: '', + arrowLeft: '', + play: '', + stop: '', +} + +/** + * 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: scenario.delivery === "source" ? `${input} out · ${output} back` : `${input} out`, + 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 ComparisonParts { + text: Record + html: Record +} + +export const ROW_MODES: Mode[] = ["manual", "adapter"] + +export const HINT = "Click a numbered stop, or use the arrows, to read each step." + +export function rowTitle(mode: Mode): string { + return mode === "manual" ? "Without the adapter" : "With the adapter" +} + +/** One line of facts under the row title, derived from the model. */ +export function rowMeta(scenario: Scenario): string { + const summary = summarize(scenario) + const lines = cardLines(summary) + return `${approvalsLabel(summary)} · ${lines[1]} · ${lines[2]} · ${feesLabel(summary)}` +} + +export function computeParts(scenario: Scenario): ComparisonParts { + const text: Record = {} + const html: Record = {} + for (const mode of ROW_MODES) { + const row: Scenario = { ...scenario, mode } + const labels = routeLabels(row) + const summary = labels.summary + text[`count-${mode}`] = String(summary.signatures) + text[`unit-${mode}`] = summary.signatures === 1 ? "signature" : "signatures" + text[`meta-${mode}`] = rowMeta(row) + text[`source-sub-${mode}`] = labels.sourceWalletSub + text[`chip-out-${mode}`] = labels.outChip + text[`chip-back-${mode}`] = labels.backChip + text[`back-caption-${mode}`] = labels.backCaption + text[`vault-out-${mode}`] = `${labels.input} ▸` + text[`vault-back-${mode}`] = `◂ ${labels.output}` + if (mode === "adapter") text["receiver-link-adapter"] = `▾ ${labels.output}` + const first = getRoute(row)[0] + html[`stops-source-${mode}`] = renderStops(P, stopsFor(row, "source-wallet"), first.id) + if (mode === "manual") html["stops-hub-manual"] = renderStops(P, stopsFor(row, "hub-wallet")) + html[`inspector-${mode}`] = renderInspector(P, first, 1, getRoute(row).length) + if (mode === "adapter") html.micro = renderMicroList(P, row) + } + return { text, html } +} + +function radio(name: string, part: string, value: string, label: string, checked: boolean): string { + return `` +} + +function leg(mode: Mode, parts: ComparisonParts): string { + const t = parts.text + return `
    +
    + ${escapeHtml(t[`chip-out-${mode}`])}${ + mode === "adapter" + ? `+ 128-byte payload+ payload` + : "" + } + ${ICONS.arrowRight} + 1st message · you pay the CCIP fee +
    +
    + ${ICONS.arrowLeft} + ${escapeHtml(t[`chip-back-${mode}`])} + ${escapeHtml(t[`back-caption-${mode}`])} +
    +
    ` +} + +function hubCell(mode: Mode, parts: ComparisonParts): string { + const t = parts.text + const h = parts.html + const link = `` + const vault = `
    + ${ICONS.vault} + ERC-4626 vault + the vault, unchanged +
    ` + if (mode === "manual") { + return `
    +
    + ${h["stops-hub-manual"]} + ${ICONS.wallet} + Your wallet on the hub chain + ${ICONS.switch}switch network · hold hub gas +
    + ${link} + ${vault} +
    ` + } + return `
    +
    + ${ICONS.adapter} + Adapter + one hub-chain transaction +
      ${h.micro}
    +
    + ${link} + ${vault} + +
    + ${ICONS.receiver} + Beneficiary on the hub chain + named in your payload +
    +
    ` +} + +function row(uid: string, mode: Mode, parts: ComparisonParts, scenario: Scenario): string { + const t = parts.text + const h = parts.html + const first = getRoute({ ...scenario, mode })[0] + return `
    +
    +
    +

    ${rowTitle(mode)}

    +

    ${t[`count-${mode}`]} ${t[`unit-${mode}`]}

    +

    ${escapeHtml(t[`meta-${mode}`])}

    +
    + +
    +
    +
    +
    + ${h[`stops-source-${mode}`]} + ${ICONS.wallet} + Your wallet + ${escapeHtml(t[`source-sub-${mode}`])} +
    +
    + ${leg(mode, parts)} + ${hubCell(mode, parts)} +
    +
    +
    + + +
    +

    ${h[`inspector-${mode}`]}

    +
    +
    ` +} + +/** The interactive region. The host element and the hidden text fallback (the slot) live in the Astro file. */ +export function renderRouteRows(uid: string, scenario: Scenario): string { + const parts = computeParts(scenario) + return `
    +
    +

    Route comparison

    +
    + ${radio(`${uid}-flow`, "flow", "deposit", "Deposit", scenario.flow === "deposit")} + ${radio(`${uid}-flow`, "flow", "redeem", "Redeem", scenario.flow === "redeem")} +
    +
    + ${radio(`${uid}-delivery`, "delivery", "source", "Back on the source chain", scenario.delivery === "source")} + ${radio(`${uid}-delivery`, "delivery", "hub", "On the hub chain", scenario.delivery === "hub")} +
    + +
    + + + + ${row(uid, "manual", parts, scenario)} + ${row(uid, "adapter", parts, scenario)} + +

    ${HINT} transaction you sign token approval ${ICONS.switch} wallet network switch second CCIP message

    +

    Counts assume a successful request from an EVM wallet, CCIP fees paid in the native gas token, and one transaction per action. Approvals are skipped when the allowance already exists. Nothing here measures time or fee amounts.

    +
    ` +} diff --git a/src/content/solutions/cross-chain-vault-adapter/index.mdx b/src/content/solutions/cross-chain-vault-adapter/index.mdx index 4f5dce6a72b..1e9901021e3 100644 --- a/src/content/solutions/cross-chain-vault-adapter/index.mdx +++ b/src/content/solutions/cross-chain-vault-adapter/index.mdx @@ -3,24 +3,16 @@ 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 VaultAdapterComparison from "@components/Solutions/VaultAdapterComparison/VaultAdapterComparison.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. +With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The hub chain is the chain where the vault and the adapter are deployed. A source chain is any other chain that users send deposits and redemptions from. 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: +The asset is the token users deposit and receive when they redeem. Shares represent their position in the vault. -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. - -With the Chainlink Cross-Chain Vault Adapter on the other hand, the user: - -1. Approves the CCIP Router and sends one CCIP message from the source chain. +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. The adapter does the hub-chain work in one transaction, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares 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, in the reverse direction. -And that's it. The user doesn't have to do anything else. +## Deposit or redeem from another chain + +Both routes below are drawn against the same three chains, and the text version carries the same facts. Redemptions take the same route with shares in and the asset out. + + + +The counts assume a successful request from an EVM wallet that uses CCIP directly, pays CCIP fees in the native gas token, and sends each transaction separately. Wallet network switches and CCIP execution are not transactions. A wallet whose allowance already covers the amount skips the approval steps. + +**Deposit without the adapter.** The user: + +1. Approves the source-chain Router to spend the asset. +2. Sends the asset through CCIP to their own wallet on the hub chain and pays the CCIP fee. +3. Switches the wallet to the hub chain and holds its gas token. +4. Approves the vault to spend the asset. +5. Deposits into the vault and receives shares. Hub-chain delivery ends here. +6. To return the shares, approves the hub-chain Router to spend them. +7. Sends the shares through CCIP to the source chain and pays a second CCIP fee. + +**Deposit with the adapter.** The user: + +1. Approves the source-chain Router to spend the asset. +2. Sends the asset and a 128-byte payload to the adapter through CCIP and pays the CCIP fee. + +CCIP delivers the asset to the adapter and calls it. In that one hub-chain transaction, the adapter validates the request, deducts any configured adapter fee when the shares return to the source chain, approves the vault and deposits, and checks that the output is nonzero and at least the minimum. It then transfers the shares to the beneficiary on the hub chain, or approves the hub-chain Router and sends them back in a second CCIP message, paying that message's CCIP fee from its own native balance. The user signs nothing on the hub chain. + +**Redeem.** The same routes with shares in and the asset out. Without the adapter, the user redeems their own shares, so there is no vault approval. With the adapter, the adapter redeems as owner and receiver, then deducts any configured adapter fee from the redeemed asset before returning it. + +**Transactions the user's wallet signs.** Approvals are included; a wallet with existing allowances signs the number in parentheses fewer times. -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. +| Action | Result delivered | Without the adapter | With the adapter | Chains signed on | Network switches | CCIP messages | +| ------- | ------------------------ | ------------------- | ---------------- | ---------------- | ---------------- | ------------- | +| Deposit | Back on the source chain | 6 (3 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 2 | +| Deposit | On the hub chain | 4 (2 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 1 | +| Redeem | Back on the source chain | 5 (2 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 2 | +| Redeem | On the hub chain | 3 (1 approval) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 1 | -The following diagram shows a deposit from a source chain into a vault on the hub chain, with both delivery options. +Fees the user pays directly: without the adapter, one CCIP fee per message they send, so two when the output returns. With the adapter, one CCIP fee, plus any configured adapter fee when the output returns to the source chain; the adapter pays the return message's CCIP fee. - + + +With the adapter, a request that fails after delivery is not reverted: the adapter keeps the tokens as a refundable failed message, and the refund or local recovery is a hub-chain action. [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) explains the payload, processing, both delivery options, and fees. 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. @@ -60,7 +83,7 @@ Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vault ## 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 | | ------------------------------------------------------------------------ | -------------------------------- | @@ -106,7 +129,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 +146,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 +170,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..7073e972b25 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,11 @@ 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. +With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The hub chain is the chain where the vault and the adapter are deployed. A source chain is any other chain that users send deposits and redemptions from. 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: +The asset is the token users deposit and receive when they redeem. Shares represent their position in the vault. -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. - -With the Chainlink Cross-Chain Vault Adapter on the other hand, the user: - -1. Approves the CCIP Router and sends one CCIP message from the source chain. +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. The adapter does the hub-chain work in one transaction, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares 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, in the reverse direction. > **NOTE: Message payload** > @@ -36,13 +27,43 @@ 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 + +Both routes below are drawn against the same three chains, and the text version carries the same facts. Redemptions take the same route with shares in and the asset out. + +The counts assume a successful request from an EVM wallet that uses CCIP directly, pays CCIP fees in the native gas token, and sends each transaction separately. Wallet network switches and CCIP execution are not transactions. A wallet whose allowance already covers the amount skips the approval steps. + +**Deposit without the adapter.** The user: + +1. Approves the source-chain Router to spend the asset. +2. Sends the asset through CCIP to their own wallet on the hub chain and pays the CCIP fee. +3. Switches the wallet to the hub chain and holds its gas token. +4. Approves the vault to spend the asset. +5. Deposits into the vault and receives shares. Hub-chain delivery ends here. +6. To return the shares, approves the hub-chain Router to spend them. +7. Sends the shares through CCIP to the source chain and pays a second CCIP fee. + +**Deposit with the adapter.** The user: + +1. Approves the source-chain Router to spend the asset. +2. Sends the asset and a 128-byte payload to the adapter through CCIP and pays the CCIP fee. + +CCIP delivers the asset to the adapter and calls it. In that one hub-chain transaction, the adapter validates the request, deducts any configured adapter fee when the shares return to the source chain, approves the vault and deposits, and checks that the output is nonzero and at least the minimum. It then transfers the shares to the beneficiary on the hub chain, or approves the hub-chain Router and sends them back in a second CCIP message, paying that message's CCIP fee from its own native balance. The user signs nothing on the hub chain. + +**Redeem.** The same routes with shares in and the asset out. Without the adapter, the user redeems their own shares, so there is no vault approval. With the adapter, the adapter redeems as owner and receiver, then deducts any configured adapter fee from the redeemed asset before returning it. + +**Transactions the user's wallet signs.** Approvals are included; a wallet with existing allowances signs the number in parentheses fewer times. -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. +| Action | Result delivered | Without the adapter | With the adapter | Chains signed on | Network switches | CCIP messages | +| ------- | ------------------------ | ------------------- | ---------------- | ---------------- | ---------------- | ------------- | +| Deposit | Back on the source chain | 6 (3 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 2 | +| Deposit | On the hub chain | 4 (2 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 1 | +| Redeem | Back on the source chain | 5 (2 approvals) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 2 | +| Redeem | On the hub chain | 3 (1 approval) | 2 (1 approval) | 2 vs 1 | 1 vs 0 | 1 | -The following diagram shows a deposit from a source chain into a vault on the hub chain, with both delivery options. +Fees the user pays directly: without the adapter, one CCIP fee per message they send, so two when the output returns. With the adapter, one CCIP fee, plus any configured adapter fee when the output returns to the source chain; the adapter pays the return message's CCIP fee. -![A user's wallet on a source chain sends the asset and a payload through Chainlink CCIP to the adapter on the hub chain. The adapter deposits into the ERC-4626 vault and receives shares, then delivers them to the user's wallet on the hub chain, or returns them through CCIP to the source chain when the share token is a CCIP cross-chain token.](/images/solutions/cross-chain-vault-adapter/at-a-glance.svg) +With the adapter, a request that fails after delivery is not reverted: the adapter keeps the tokens as a refundable failed message, and the refund or local recovery is a hub-chain action. [How it works](/solutions/cross-chain-vault-adapter/overview/how-it-works) explains the payload, processing, both delivery options, and fees. 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. @@ -57,7 +78,7 @@ Asynchronous vaults, multi-asset vaults, and targets that are not ERC-4626 vault ## 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 | | ------------------------------------------------------------------------ | -------------------------------- | @@ -96,7 +117,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 +134,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 +158,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..d87dddcd7dd 100644 --- a/src/lib/markdown/transformMarkdown.ts +++ b/src/lib/markdown/transformMarkdown.ts @@ -323,7 +323,9 @@ export async function transformMarkdown( if ( (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && - ["Fragment", "GitHubCards", "OverviewWrapper"].includes((node as MdxJsxNode).name ?? "") + ["Fragment", "GitHubCards", "OverviewWrapper", "VaultAdapterComparison"].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..88dce8f5312 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, + VaultAdapterComparison: true, ol: true, li: true, } diff --git a/src/utils/solutionLlms.test.ts b/src/utils/solutionLlms.test.ts index d8fd88b89dc..bb620a8d042 100644 --- a/src/utils/solutionLlms.test.ts +++ b/src/utils/solutionLlms.test.ts @@ -99,6 +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).not.toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg") expect(document).toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg") expect(document).not.toMatch(/^import /m) expect(document).not.toContain(" Date: Mon, 19 Oct 2026 01:14:03 +0530 Subject: [PATCH 2/7] fix: Minor fix to test file --- src/utils/solutionLlms.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/solutionLlms.test.ts b/src/utils/solutionLlms.test.ts index bb620a8d042..dd38343e311 100644 --- a/src/utils/solutionLlms.test.ts +++ b/src/utils/solutionLlms.test.ts @@ -100,7 +100,6 @@ describe("solution llms-full.txt", () => { expect(document).toContain("```solidity") expect(document).toContain("Message payload") expect(document).not.toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg") - expect(document).toContain("/images/solutions/cross-chain-vault-adapter/at-a-glance.svg") expect(document).not.toMatch(/^import /m) expect(document).not.toContain(" Date: Tue, 20 Oct 2026 15:58:26 +0530 Subject: [PATCH 3/7] fix: Minor ui improvements to the diagram --- .../VaultAdapterComparison.astro | 14 ++-- .../cross-chain-vault-adapter/index.mdx | 79 +++++++++---------- .../cross-chain-vault-adapter/llms-full.txt | 77 ++++++++---------- 3 files changed, 77 insertions(+), 93 deletions(-) diff --git a/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro b/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro index c18cc1da917..7ad8dab1abc 100644 --- a/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro +++ b/src/components/Solutions/VaultAdapterComparison/VaultAdapterComparison.astro @@ -224,8 +224,7 @@ const interactive = renderRouteRows(uid, scenario) } .vac .vac-col-ccip { padding-inline: 4px; - background: var(--blue-50, #eff6ff); - border-inline: 1px solid var(--border, #e4e8ed); + border-inline: 1px dashed var(--foreground, #141921); color: var(--foreground, #141921); font-size: 11px; letter-spacing: 0; @@ -335,8 +334,7 @@ const interactive = renderRouteRows(uid, scenario) .vac-cell-ccip { gap: 10px; padding-inline: 0; - border-inline: 1px solid var(--border, #e4e8ed); - background: var(--blue-50, #eff6ff); + border-inline: 1px dashed var(--foreground, #141921); } .vac-cell-hub { grid-template-columns: minmax(0, 1fr); @@ -467,7 +465,7 @@ const interactive = renderRouteRows(uid, scenario) .vac button.vac-badge { cursor: pointer; } - .vac button.vac-badge:hover:not(:disabled) { + .vac button.vac-badge:hover:not(:disabled):not([data-active="true"]) { box-shadow: inset 0 0 0 2px var(--brand, #0847f7), 0 0 0 3px var(--blue-100, #dcebff); @@ -486,9 +484,9 @@ const interactive = renderRouteRows(uid, scenario) color: var(--brand, #0847f7); } .vac .vac-badge[data-active="true"] { - box-shadow: - inset 0 0 0 2px var(--brand, #0847f7), - 0 0 0 3px var(--blue-100, #dcebff); + background: var(--foreground, #141921); + box-shadow: none; + color: var(--primary-foreground, #ffffff); } .vac button.vac-badge:focus-visible { outline: 2px solid var(--brand, #0847f7); diff --git a/src/content/solutions/cross-chain-vault-adapter/index.mdx b/src/content/solutions/cross-chain-vault-adapter/index.mdx index 1e9901021e3..e2e3953cf74 100644 --- a/src/content/solutions/cross-chain-vault-adapter/index.mdx +++ b/src/content/solutions/cross-chain-vault-adapter/index.mdx @@ -8,11 +8,27 @@ import VaultAdapterComparison from "@components/Solutions/VaultAdapterComparison ## What the adapter does -With the Cross-Chain Vault Adapter, a vault team can accept deposits and redemptions from users on other chains. The vault is the product, CCIP is the bridge, and the adapter is how users on other chains reach the vault. The hub chain is the chain where the vault and the adapter are deployed. A source chain is any other chain that users send deposits and redemptions from. 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. + + +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. -The asset is the token users deposit and receive when they redeem. Shares represent their position in the vault. + -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. The adapter does the hub-chain work in one transaction, then delivers the shares on the hub chain or returns them to the user on the source chain. Returning the shares 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, in the reverse direction. +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. + +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.